diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e1e139c90db..e720903da9b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -71,12 +71,12 @@ jobs: - name: Verify preload bundle output run: | - test -f apps/desktop/dist-electron/preload.js - grep -nE "desktopBridge|getLocalEnvironmentBootstrap|PICK_FOLDER_CHANNEL|wsUrl" apps/desktop/dist-electron/preload.js + test -f apps/desktop/dist-electron/preload.cjs + grep -nE "desktopBridge|getLocalEnvironmentBootstrap|PICK_FOLDER_CHANNEL|wsUrl" apps/desktop/dist-electron/preload.cjs release_smoke: name: Release Smoke - runs-on: ubuntu-24.04 + runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 steps: - name: Checkout @@ -92,5 +92,8 @@ jobs: with: node-version-file: package.json + - name: Install dependencies + run: bun install --frozen-lockfile + - name: Exercise release-only workflow steps run: node scripts/release-smoke.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 16469896ff8..ef6e9c13cea 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,11 +4,21 @@ on: push: tags: - "v*.*.*" + schedule: + - cron: "0 9 * * *" workflow_dispatch: inputs: + channel: + description: "Release channel" + required: false + default: stable + type: choice + options: + - stable + - nightly version: description: "Release version (for example 1.2.3 or v1.2.3)" - required: true + required: false type: string permissions: @@ -18,43 +28,24 @@ permissions: jobs: preflight: name: Preflight - runs-on: ubuntu-24.04 + runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 outputs: + release_channel: ${{ steps.release_meta.outputs.release_channel }} version: ${{ steps.release_meta.outputs.version }} tag: ${{ steps.release_meta.outputs.tag }} + release_name: ${{ steps.release_meta.outputs.name }} + short_sha: ${{ steps.release_meta.outputs.short_sha }} + previous_tag: ${{ steps.previous_tag.outputs.previous_tag }} + cli_dist_tag: ${{ steps.release_meta.outputs.cli_dist_tag }} is_prerelease: ${{ steps.release_meta.outputs.is_prerelease }} make_latest: ${{ steps.release_meta.outputs.make_latest }} ref: ${{ github.sha }} steps: - name: Checkout uses: actions/checkout@v6 - - - id: release_meta - name: Resolve release version - shell: bash - run: | - if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then - raw="${{ github.event.inputs.version }}" - else - raw="${GITHUB_REF_NAME}" - fi - - version="${raw#v}" - if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then - echo "Invalid release version: $raw" >&2 - exit 1 - fi - - echo "version=$version" >> "$GITHUB_OUTPUT" - echo "tag=v$version" >> "$GITHUB_OUTPUT" - if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "is_prerelease=false" >> "$GITHUB_OUTPUT" - echo "make_latest=true" >> "$GITHUB_OUTPUT" - else - echo "is_prerelease=true" >> "$GITHUB_OUTPUT" - echo "make_latest=false" >> "$GITHUB_OUTPUT" - fi + with: + fetch-depth: 0 - name: Setup Bun uses: oven-sh/setup-bun@v2 @@ -69,6 +60,60 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile + - id: release_meta + name: Resolve release version + shell: bash + env: + DISPATCH_CHANNEL: ${{ github.event.inputs.channel }} + DISPATCH_VERSION: ${{ github.event.inputs.version }} + NIGHTLY_DATE: ${{ github.run_started_at }} + NIGHTLY_SHA: ${{ github.sha }} + NIGHTLY_RUN_NUMBER: ${{ github.run_number }} + run: | + if [[ "${GITHUB_EVENT_NAME}" == "schedule" || ( "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${DISPATCH_CHANNEL:-stable}" == "nightly" ) ]]; then + nightly_date="$(date -u -d "$NIGHTLY_DATE" +%Y%m%d)" + + node scripts/resolve-nightly-release.ts \ + --date "$nightly_date" \ + --run-number "$NIGHTLY_RUN_NUMBER" \ + --sha "$NIGHTLY_SHA" \ + --github-output + + echo "release_channel=nightly" >> "$GITHUB_OUTPUT" + echo "cli_dist_tag=nightly" >> "$GITHUB_OUTPUT" + echo "is_prerelease=true" >> "$GITHUB_OUTPUT" + echo "make_latest=false" >> "$GITHUB_OUTPUT" + else + if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then + raw="${DISPATCH_VERSION}" + if [[ -z "$raw" ]]; then + echo "workflow_dispatch stable releases require the version input." >&2 + exit 1 + fi + else + raw="${GITHUB_REF_NAME}" + fi + + version="${raw#v}" + if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then + echo "Invalid release version: $raw" >&2 + exit 1 + fi + + echo "release_channel=stable" >> "$GITHUB_OUTPUT" + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "tag=v$version" >> "$GITHUB_OUTPUT" + echo "name=T3 Code v$version" >> "$GITHUB_OUTPUT" + echo "cli_dist_tag=latest" >> "$GITHUB_OUTPUT" + if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "is_prerelease=false" >> "$GITHUB_OUTPUT" + echo "make_latest=true" >> "$GITHUB_OUTPUT" + else + echo "is_prerelease=true" >> "$GITHUB_OUTPUT" + echo "make_latest=false" >> "$GITHUB_OUTPUT" + fi + fi + - name: Lint run: bun run lint @@ -78,6 +123,14 @@ jobs: - name: Test run: bun run test + - id: previous_tag + name: Resolve previous release tag + run: | + node scripts/resolve-previous-release-tag.ts \ + --channel "${{ steps.release_meta.outputs.release_channel }}" \ + --current-tag "${{ steps.release_meta.outputs.tag }}" \ + --github-output + build: name: Build ${{ matrix.label }} needs: preflight @@ -88,25 +141,30 @@ jobs: matrix: include: - label: macOS arm64 - runner: macos-14 + runner: blacksmith-12vcpu-macos-26 platform: mac target: dmg arch: arm64 - label: macOS x64 - runner: macos-15-intel + runner: blacksmith-12vcpu-macos-26 platform: mac target: dmg arch: x64 - label: Linux x64 - runner: ubuntu-24.04 + runner: blacksmith-32vcpu-ubuntu-2404 platform: linux target: AppImage arch: x64 - label: Windows x64 - runner: windows-2022 + runner: windows-2022 # blacksmith-32vcpu-windows-2025 platform: win target: nsis arch: x64 + # - label: Windows arm64 + # runner: windows-11-arm + # platform: win + # target: nsis + # arch: arm64 steps: - name: Checkout uses: actions/checkout@v6 @@ -208,18 +266,31 @@ jobs: "release/*.AppImage" \ "release/*.exe" \ "release/*.blockmap" \ - "release/latest*.yml"; do + "release/*.yml"; do for file in $pattern; do cp "$file" release-publish/ done done if [[ "${{ matrix.platform }}" == "mac" && "${{ matrix.arch }}" != "arm64" ]]; then - if [[ -f release-publish/latest-mac.yml ]]; then - mv release-publish/latest-mac.yml "release-publish/latest-mac-${{ matrix.arch }}.yml" - fi + shopt -s nullglob + for manifest in release-publish/*-mac.yml; do + mv "$manifest" "${manifest%.yml}-${{ matrix.arch }}.yml" + done fi + # Enable if Windows arm64 builds are enabled. + # Windows updater metadata is channel-specific (for example + # "latest.yml" or "nightly.yml"). Suffix each per-arch copy so the + # release job can merge matching arm64/x64 manifests back into one + # canonical manifest per channel. + # if [[ "${{ matrix.platform }}" == "win" ]]; then + # shopt -s nullglob + # for manifest in release-publish/*.yml; do + # mv "$manifest" "${manifest%.yml}-win-${{ matrix.arch }}.yml" + # done + # fi + - name: Upload build artifacts uses: actions/upload-artifact@v7 with: @@ -230,7 +301,7 @@ jobs: publish_cli: name: Publish CLI to npm needs: [preflight, build] - runs-on: ubuntu-24.04 + runs-on: ubuntu-24.04 # blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 steps: - name: Checkout @@ -273,7 +344,7 @@ jobs: release: name: Publish GitHub Release needs: [preflight, build, publish_cli] - runs-on: ubuntu-24.04 + runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 steps: - name: Checkout @@ -295,12 +366,66 @@ jobs: - name: Merge macOS updater manifests run: | - node scripts/merge-mac-update-manifests.ts \ - release-assets/latest-mac.yml \ - release-assets/latest-mac-x64.yml - rm -f release-assets/latest-mac-x64.yml + shopt -s nullglob + for x64_manifest in release-assets/*-mac-x64.yml; do + arm64_manifest="${x64_manifest%-x64.yml}.yml" + if [[ -f "$arm64_manifest" ]]; then + node scripts/merge-update-manifests.ts --platform mac "$arm64_manifest" "$x64_manifest" + rm -f "$x64_manifest" + fi + done + + # - name: Merge Windows updater manifests + # run: | + # shopt -s nullglob + # found_windows_manifest=false + # for x64_manifest in release-assets/*-win-x64.yml; do + # if [[ "$(basename "$x64_manifest")" == builder-debug-* ]]; then + # continue + # fi + + # arm64_manifest="${x64_manifest/-x64.yml/-arm64.yml}" + # output_manifest="${x64_manifest/-win-x64.yml/.yml}" + # if [[ ! -f "$arm64_manifest" ]]; then + # echo "Missing matching arm64 Windows manifest for $x64_manifest" >&2 + # exit 1 + # fi + + # found_windows_manifest=true + # node scripts/merge-update-manifests.ts --platform win \ + # "$arm64_manifest" \ + # "$x64_manifest" \ + # "$output_manifest" + # rm -f "$arm64_manifest" "$x64_manifest" + # done + + # if [[ "$found_windows_manifest" != true ]]; then + # echo "No Windows updater manifests found to merge." >&2 + # exit 1 + # fi - name: Publish release + if: needs.preflight.outputs.previous_tag != '' + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ needs.preflight.outputs.tag }} + target_commitish: ${{ needs.preflight.outputs.ref }} + name: MarCode v${{ needs.preflight.outputs.version }} + generate_release_notes: true + previous_tag: ${{ needs.preflight.outputs.previous_tag }} + prerelease: ${{ needs.preflight.outputs.is_prerelease }} + make_latest: ${{ needs.preflight.outputs.make_latest }} + files: | + release-assets/*.dmg + release-assets/*.zip + release-assets/*.AppImage + release-assets/*.exe + release-assets/*.blockmap + release-assets/*.yml + fail_on_unmatched_files: true + + - name: Publish first release + if: needs.preflight.outputs.previous_tag == '' uses: softprops/action-gh-release@v2 with: tag_name: ${{ needs.preflight.outputs.tag }} @@ -315,13 +440,14 @@ jobs: release-assets/*.AppImage release-assets/*.exe release-assets/*.blockmap - release-assets/latest*.yml + release-assets/*.yml fail_on_unmatched_files: true finalize: name: Finalize release + if: needs.preflight.outputs.release_channel == 'stable' needs: [preflight, release] - runs-on: ubuntu-24.04 + runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 steps: - id: app_token @@ -360,6 +486,9 @@ jobs: with: node-version-file: package.json + - name: Install dependencies + run: bun install --frozen-lockfile + - id: update_versions name: Update version strings env: diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 776d11b8035..dded6b0acde 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -10,7 +10,8 @@ "*.tsbuildinfo", "**/routeTree.gen.ts", "apps/web/public/mockServiceWorker.js", - "apps/web/src/lib/vendor/qrcodegen.ts" + "apps/web/src/lib/vendor/qrcodegen.ts", + "*.icon/**" ], "sortPackageJson": {} } diff --git a/README.md b/README.md index dcb6452a29e..5893a498730 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,14 @@ Observability guide: [docs/observability.md](./docs/observability.md) ## If you REALLY want to contribute still.... read this first +Before local development, prepare the environment and install dependencies: + +```bash +# Optional: only needed if you use mise for dev tool management. +mise install +bun install . +``` + Read [CONTRIBUTING.md](./CONTRIBUTING.md) before opening an issue or PR. Need support? Join the [Discord](https://discord.gg/jn4EGJjrvv). diff --git a/apps/desktop/package.json b/apps/desktop/package.json index b312546d152..12c8970c155 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -2,13 +2,14 @@ "name": "@marcode/desktop", "version": "1.3.2", "private": true, - "main": "dist-electron/main.js", + "type": "module", + "main": "dist-electron/main.cjs", "scripts": { "dev": "bun run --parallel dev:bundle dev:electron", "dev:bundle": "tsdown --watch", - "dev:electron": "bun run scripts/dev-electron.mjs", + "dev:electron": "node scripts/dev-electron.mjs", "build": "tsdown", - "start": "bun run scripts/start-electron.mjs", + "start": "node scripts/start-electron.mjs", "typecheck": "tsc --noEmit", "test": "vitest run --passWithNoTests", "smoke-test": "node scripts/smoke-test.mjs" diff --git a/apps/desktop/scripts/dev-electron.mjs b/apps/desktop/scripts/dev-electron.mjs index 45bbc20b109..ccc1d68bb6f 100644 --- a/apps/desktop/scripts/dev-electron.mjs +++ b/apps/desktop/scripts/dev-electron.mjs @@ -17,12 +17,12 @@ if (!Number.isInteger(port) || port <= 0) { } const requiredFiles = [ - "dist-electron/main.js", - "dist-electron/preload.js", + "dist-electron/main.cjs", + "dist-electron/preload.cjs", "../server/dist/bin.mjs", ]; const watchedDirectories = [ - { directory: "dist-electron", files: new Set(["main.js", "preload.js"]) }, + { directory: "dist-electron", files: new Set(["main.cjs", "preload.cjs"]) }, { directory: "../server/dist", files: new Set(["bin.mjs"]) }, ]; const forcedShutdownTimeoutMs = 1_500; @@ -69,7 +69,7 @@ function startApp() { const app = spawn( resolveElectronPath(), - [`--marcode-dev-root=${desktopDir}`, "dist-electron/main.js"], + [`--marcode-dev-root=${desktopDir}`, "dist-electron/main.cjs"], { cwd: desktopDir, env: childEnv, diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs index dd6dafd53aa..6765fb8652d 100644 --- a/apps/desktop/scripts/electron-launcher.mjs +++ b/apps/desktop/scripts/electron-launcher.mjs @@ -1,4 +1,7 @@ -// This file mostly exists because we want dev mode to say "MarCode (Dev)" instead of "electron" +// This file renames the packaged app bundle on macOS to "MarCode (Alpha)". +// Dev mode intentionally skips the rename because renaming the Electron.app +// bundle in-place breaks Electron helper resource lookup (e.g. icudtl.dat) on +// newer Electron versions — the Dock shows "Electron" in dev, which is fine. import { spawnSync } from "node:child_process"; import { @@ -6,8 +9,8 @@ import { cpSync, existsSync, mkdirSync, + mkdtempSync, readFileSync, - readdirSync, rmSync, statSync, writeFileSync, @@ -19,10 +22,13 @@ import { fileURLToPath } from "node:url"; const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); const APP_DISPLAY_NAME = isDevelopment ? "MarCode (Dev)" : "MarCode (Alpha)"; const APP_BUNDLE_ID = isDevelopment ? "com.marcode.marcode.dev" : "com.marcode.marcode"; -const LAUNCHER_VERSION = 1; +const LAUNCHER_VERSION = 2; const __dirname = dirname(fileURLToPath(import.meta.url)); export const desktopDir = resolve(__dirname, ".."); +const repoRoot = resolve(desktopDir, "..", ".."); +const defaultIconPath = join(desktopDir, "resources", "icon.icns"); +const developmentMacIconPngPath = join(repoRoot, "assets", "dev", "blueprint-macos-1024.png"); function setPlistString(plistPath, key, value) { const replaceResult = spawnSync("plutil", ["-replace", key, "-string", value, plistPath], { @@ -43,6 +49,68 @@ function setPlistString(plistPath, key, value) { throw new Error(`Failed to update plist key "${key}" at ${plistPath}: ${details}`.trim()); } +function runChecked(command, args) { + const result = spawnSync(command, args, { encoding: "utf8" }); + if (result.status === 0) { + return; + } + + const details = [result.stdout, result.stderr].filter(Boolean).join("\n"); + throw new Error(`Failed to run ${command} ${args.join(" ")}: ${details}`.trim()); +} + +function ensureDevelopmentIconIcns(runtimeDir) { + const generatedIconPath = join(runtimeDir, "icon-dev.icns"); + mkdirSync(runtimeDir, { recursive: true }); + + if (!existsSync(developmentMacIconPngPath)) { + return defaultIconPath; + } + + const sourceMtimeMs = statSync(developmentMacIconPngPath).mtimeMs; + if (existsSync(generatedIconPath) && statSync(generatedIconPath).mtimeMs >= sourceMtimeMs) { + return generatedIconPath; + } + + const iconsetRoot = mkdtempSync(join(runtimeDir, "dev-iconset-")); + const iconsetDir = join(iconsetRoot, "icon.iconset"); + mkdirSync(iconsetDir, { recursive: true }); + + try { + for (const size of [16, 32, 128, 256, 512]) { + runChecked("sips", [ + "-z", + String(size), + String(size), + developmentMacIconPngPath, + "--out", + join(iconsetDir, `icon_${size}x${size}.png`), + ]); + + const retinaSize = size * 2; + runChecked("sips", [ + "-z", + String(retinaSize), + String(retinaSize), + developmentMacIconPngPath, + "--out", + join(iconsetDir, `icon_${size}x${size}@2x.png`), + ]); + } + + runChecked("iconutil", ["-c", "icns", iconsetDir, "-o", generatedIconPath]); + return generatedIconPath; + } catch (error) { + console.warn( + "[desktop-launcher] Failed to generate dev macOS icon, falling back to default icon.", + error, + ); + return defaultIconPath; + } finally { + rmSync(iconsetRoot, { recursive: true, force: true }); + } +} + function patchMainBundleInfoPlist(appBundlePath, iconPath) { const infoPlistPath = join(appBundlePath, "Contents", "Info.plist"); setPlistString(infoPlistPath, "CFBundleDisplayName", APP_DISPLAY_NAME); @@ -55,40 +123,6 @@ function patchMainBundleInfoPlist(appBundlePath, iconPath) { copyFileSync(iconPath, join(resourcesDir, "electron.icns")); } -function patchHelperBundleInfoPlists(appBundlePath) { - const frameworksDir = join(appBundlePath, "Contents", "Frameworks"); - if (!existsSync(frameworksDir)) { - return; - } - - for (const entry of readdirSync(frameworksDir, { withFileTypes: true })) { - if (!entry.isDirectory() || !entry.name.endsWith(".app")) { - continue; - } - if (!entry.name.startsWith("Electron Helper")) { - continue; - } - - const helperPlistPath = join(frameworksDir, entry.name, "Contents", "Info.plist"); - if (!existsSync(helperPlistPath)) { - continue; - } - - const suffix = entry.name.replace("Electron Helper", "").replace(".app", "").trim(); - const helperName = suffix - ? `${APP_DISPLAY_NAME} Helper ${suffix}` - : `${APP_DISPLAY_NAME} Helper`; - const helperIdSuffix = suffix.replace(/[()]/g, "").trim().toLowerCase().replace(/\s+/g, "-"); - const helperBundleId = helperIdSuffix - ? `${APP_BUNDLE_ID}.helper.${helperIdSuffix}` - : `${APP_BUNDLE_ID}.helper`; - - setPlistString(helperPlistPath, "CFBundleDisplayName", helperName); - setPlistString(helperPlistPath, "CFBundleName", helperName); - setPlistString(helperPlistPath, "CFBundleIdentifier", helperBundleId); - } -} - function readJson(path) { try { return JSON.parse(readFileSync(path, "utf8")); @@ -102,7 +136,7 @@ function buildMacLauncher(electronBinaryPath) { const runtimeDir = join(desktopDir, ".electron-runtime"); const targetAppBundlePath = join(runtimeDir, `${APP_DISPLAY_NAME}.app`); const targetBinaryPath = join(targetAppBundlePath, "Contents", "MacOS", "Electron"); - const iconPath = join(desktopDir, "resources", "icon.icns"); + const iconPath = isDevelopment ? ensureDevelopmentIconIcns(runtimeDir) : defaultIconPath; const metadataPath = join(runtimeDir, "metadata.json"); mkdirSync(runtimeDir, { recursive: true }); @@ -126,7 +160,6 @@ function buildMacLauncher(electronBinaryPath) { rmSync(targetAppBundlePath, { recursive: true, force: true }); cpSync(sourceAppBundlePath, targetAppBundlePath, { recursive: true }); patchMainBundleInfoPlist(targetAppBundlePath, iconPath); - patchHelperBundleInfoPlists(targetAppBundlePath); writeFileSync(metadataPath, `${JSON.stringify(expectedMetadata, null, 2)}\n`); return targetBinaryPath; @@ -140,5 +173,12 @@ export function resolveElectronPath() { return electronBinaryPath; } + // Dev launches do not need a renamed app bundle badly enough to risk breaking + // Electron helper resource lookup (icudtl.dat) on macOS. The Dock will show + // "Electron" in dev; this is the intentional upstream tradeoff. + if (isDevelopment) { + return electronBinaryPath; + } + return buildMacLauncher(electronBinaryPath); } diff --git a/apps/desktop/scripts/smoke-test.mjs b/apps/desktop/scripts/smoke-test.mjs index 883da7203a5..fdbe69b7780 100644 --- a/apps/desktop/scripts/smoke-test.mjs +++ b/apps/desktop/scripts/smoke-test.mjs @@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url"; const __dirname = dirname(fileURLToPath(import.meta.url)); const desktopDir = resolve(__dirname, ".."); const electronBin = resolve(desktopDir, "node_modules/.bin/electron"); -const mainJs = resolve(desktopDir, "dist-electron/main.js"); +const mainJs = resolve(desktopDir, "dist-electron/main.cjs"); console.log("\nLaunching Electron smoke test..."); diff --git a/apps/desktop/scripts/start-electron.mjs b/apps/desktop/scripts/start-electron.mjs index bf93adb6b0d..375dbfe575f 100644 --- a/apps/desktop/scripts/start-electron.mjs +++ b/apps/desktop/scripts/start-electron.mjs @@ -5,7 +5,7 @@ import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs"; const childEnv = { ...process.env }; delete childEnv.ELECTRON_RUN_AS_NODE; -const child = spawn(resolveElectronPath(), ["dist-electron/main.js"], { +const child = spawn(resolveElectronPath(), ["dist-electron/main.cjs"], { stdio: "inherit", cwd: desktopDir, env: childEnv, diff --git a/apps/desktop/src/appBranding.test.ts b/apps/desktop/src/appBranding.test.ts new file mode 100644 index 00000000000..136493bff42 --- /dev/null +++ b/apps/desktop/src/appBranding.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; + +import { resolveDesktopAppBranding, resolveDesktopAppStageLabel } from "./appBranding.ts"; + +describe("resolveDesktopAppStageLabel", () => { + it("uses Dev in desktop development", () => { + expect( + resolveDesktopAppStageLabel({ + isDevelopment: true, + appVersion: "0.0.17-nightly.20260414.1", + }), + ).toBe("Dev"); + }); + + it("uses Nightly for packaged nightly builds", () => { + expect( + resolveDesktopAppStageLabel({ + isDevelopment: false, + appVersion: "0.0.17-nightly.20260414.1", + }), + ).toBe("Nightly"); + }); + + it("uses Alpha for packaged stable builds", () => { + expect( + resolveDesktopAppStageLabel({ + isDevelopment: false, + appVersion: "0.0.17", + }), + ).toBe("Alpha"); + }); +}); + +describe("resolveDesktopAppBranding", () => { + it("returns a complete desktop branding payload", () => { + expect( + resolveDesktopAppBranding({ + isDevelopment: false, + appVersion: "0.0.17-nightly.20260414.1", + }), + ).toEqual({ + baseName: "MarCode", + stageLabel: "Nightly", + displayName: "MarCode (Nightly)", + }); + }); +}); diff --git a/apps/desktop/src/appBranding.ts b/apps/desktop/src/appBranding.ts new file mode 100644 index 00000000000..80af3f5aadc --- /dev/null +++ b/apps/desktop/src/appBranding.ts @@ -0,0 +1,28 @@ +import type { DesktopAppBranding, DesktopAppStageLabel } from "@marcode/contracts"; + +import { isNightlyDesktopVersion } from "./updateChannels.ts"; + +const APP_BASE_NAME = "MarCode"; + +export function resolveDesktopAppStageLabel(input: { + readonly isDevelopment: boolean; + readonly appVersion: string; +}): DesktopAppStageLabel { + if (input.isDevelopment) { + return "Dev"; + } + + return isNightlyDesktopVersion(input.appVersion) ? "Nightly" : "Alpha"; +} + +export function resolveDesktopAppBranding(input: { + readonly isDevelopment: boolean; + readonly appVersion: string; +}): DesktopAppBranding { + const stageLabel = resolveDesktopAppStageLabel(input); + return { + baseName: APP_BASE_NAME, + stageLabel, + displayName: `${APP_BASE_NAME} (${stageLabel})`, + }; +} diff --git a/apps/desktop/src/backendPort.test.ts b/apps/desktop/src/backendPort.test.ts index 8f586deb702..774e31b8066 100644 --- a/apps/desktop/src/backendPort.test.ts +++ b/apps/desktop/src/backendPort.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -import { resolveDesktopBackendPort } from "./backendPort"; +import { resolveDesktopBackendPort } from "./backendPort.ts"; describe("resolveDesktopBackendPort", () => { it("returns the starting port when it is available", async () => { diff --git a/apps/desktop/src/backendReadiness.test.ts b/apps/desktop/src/backendReadiness.test.ts index fd6180b5dac..0d49842acba 100644 --- a/apps/desktop/src/backendReadiness.test.ts +++ b/apps/desktop/src/backendReadiness.test.ts @@ -4,10 +4,10 @@ import { BackendReadinessAbortedError, isBackendReadinessAborted, waitForHttpReady, -} from "./backendReadiness"; +} from "./backendReadiness.ts"; describe("waitForHttpReady", () => { - it("returns once the backend reports a successful session endpoint", async () => { + it("returns once the backend serves the requested readiness path", async () => { const fetchImpl = vi .fn() .mockResolvedValueOnce(new Response(null, { status: 503 })) @@ -20,6 +20,11 @@ describe("waitForHttpReady", () => { }); expect(fetchImpl).toHaveBeenCalledTimes(2); + expect(fetchImpl).toHaveBeenNthCalledWith( + 1, + "http://127.0.0.1:3773/", + expect.objectContaining({ redirect: "manual" }), + ); }); it("retries after a readiness request stalls past the per-request timeout", async () => { @@ -80,4 +85,30 @@ describe("waitForHttpReady", () => { expect(isBackendReadinessAborted(new BackendReadinessAbortedError())).toBe(true); expect(isBackendReadinessAborted(new Error("nope"))).toBe(false); }); + + it("supports custom readiness predicates", async () => { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(new Response(null, { status: 200 })) + .mockResolvedValueOnce(new Response(null, { status: 204 })); + + await waitForHttpReady("http://127.0.0.1:3773", { + fetchImpl, + timeoutMs: 1_000, + intervalMs: 0, + path: "/api/healthz", + isReady: (response) => response.status === 204, + }); + + expect(fetchImpl).toHaveBeenNthCalledWith( + 1, + "http://127.0.0.1:3773/api/healthz", + expect.objectContaining({ redirect: "manual" }), + ); + expect(fetchImpl).toHaveBeenNthCalledWith( + 2, + "http://127.0.0.1:3773/api/healthz", + expect.objectContaining({ redirect: "manual" }), + ); + }); }); diff --git a/apps/desktop/src/backendReadiness.ts b/apps/desktop/src/backendReadiness.ts index cd5a3c023ef..71c28929ebe 100644 --- a/apps/desktop/src/backendReadiness.ts +++ b/apps/desktop/src/backendReadiness.ts @@ -4,9 +4,11 @@ export interface WaitForHttpReadyOptions { readonly requestTimeoutMs?: number; readonly fetchImpl?: typeof fetch; readonly signal?: AbortSignal; + readonly path?: string; + readonly isReady?: (response: Response) => boolean; } -const DEFAULT_TIMEOUT_MS = 10_000; +const DEFAULT_TIMEOUT_MS = 30_000; const DEFAULT_INTERVAL_MS = 100; const DEFAULT_REQUEST_TIMEOUT_MS = 1_000; @@ -57,6 +59,8 @@ export async function waitForHttpReady( const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS; const intervalMs = options?.intervalMs ?? DEFAULT_INTERVAL_MS; const requestTimeoutMs = options?.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; + const readinessPath = options?.path ?? "/"; + const isReady = options?.isReady ?? ((response: Response) => response.ok); const deadline = Date.now() + timeoutMs; for (;;) { @@ -74,11 +78,11 @@ export async function waitForHttpReady( signal?.addEventListener("abort", abortRequest, { once: true }); try { - const response = await fetchImpl(`${baseUrl}/api/auth/session`, { + const response = await fetchImpl(new URL(readinessPath, baseUrl).toString(), { redirect: "manual", signal: requestController.signal, }); - if (response.ok) { + if (isReady(response)) { return; } } catch (error) { diff --git a/apps/desktop/src/clientPersistence.test.ts b/apps/desktop/src/clientPersistence.test.ts index e773cef5a96..4ee292ccd89 100644 --- a/apps/desktop/src/clientPersistence.test.ts +++ b/apps/desktop/src/clientPersistence.test.ts @@ -18,7 +18,7 @@ import { writeSavedEnvironmentRegistry, writeSavedEnvironmentSecret, type DesktopSecretStorage, -} from "./clientPersistence"; +} from "./clientPersistence.ts"; const tempDirectories: string[] = []; @@ -52,10 +52,13 @@ const clientSettings: ClientSettings = { confirmThreadArchive: true, confirmThreadDelete: false, diffWordWrap: true, + sidebarProjectGroupingMode: "repository_path", + sidebarProjectGroupingOverrides: { + "environment-1:/tmp/project-a": "separate", + }, sidebarProjectSortOrder: "manual", sidebarThreadSortOrder: "created_at", timestampFormat: "24-hour", - showTodosInComposer: true, turnNotificationMode: "off", turnNotificationSoundId: "default", turnNotificationCustomSounds: [], diff --git a/apps/desktop/src/clientPersistence.ts b/apps/desktop/src/clientPersistence.ts index bcb853ad7c5..b951a73f58d 100644 --- a/apps/desktop/src/clientPersistence.ts +++ b/apps/desktop/src/clientPersistence.ts @@ -1,8 +1,13 @@ import * as FS from "node:fs"; import * as Path from "node:path"; -import type { ClientSettings, PersistedSavedEnvironmentRecord } from "@marcode/contracts"; +import { + ClientSettingsSchema, + type ClientSettings, + type PersistedSavedEnvironmentRecord, +} from "@marcode/contracts"; import { Predicate } from "effect"; +import * as Schema from "effect/Schema"; interface ClientSettingsDocument { readonly settings: ClientSettings; @@ -83,7 +88,15 @@ function toPersistedSavedEnvironmentRecord( } export function readClientSettings(settingsPath: string): ClientSettings | null { - return readJsonFile(settingsPath)?.settings ?? null; + const raw = readJsonFile(settingsPath)?.settings; + if (!raw) { + return null; + } + try { + return Schema.decodeUnknownSync(ClientSettingsSchema)(raw); + } catch { + return null; + } } export function writeClientSettings(settingsPath: string, settings: ClientSettings): void { diff --git a/apps/desktop/src/confirmDialog.test.ts b/apps/desktop/src/confirmDialog.test.ts index 4a4c0ddbed6..de1d23eb178 100644 --- a/apps/desktop/src/confirmDialog.test.ts +++ b/apps/desktop/src/confirmDialog.test.ts @@ -11,7 +11,7 @@ vi.mock("electron", () => ({ }, })); -import { showDesktopConfirmDialog } from "./confirmDialog"; +import { showDesktopConfirmDialog } from "./confirmDialog.ts"; describe("showDesktopConfirmDialog", () => { beforeEach(() => { diff --git a/apps/desktop/src/desktopSettings.test.ts b/apps/desktop/src/desktopSettings.test.ts index 39980dde24e..70bfa261b5d 100644 --- a/apps/desktop/src/desktopSettings.test.ts +++ b/apps/desktop/src/desktopSettings.test.ts @@ -7,9 +7,11 @@ import { afterEach, describe, expect, it } from "vitest"; import { DEFAULT_DESKTOP_SETTINGS, readDesktopSettings, + resolveDefaultDesktopSettings, setDesktopServerExposurePreference, + setDesktopUpdateChannelPreference, writeDesktopSettings, -} from "./desktopSettings"; +} from "./desktopSettings.ts"; const tempDirectories: string[] = []; @@ -27,7 +29,15 @@ function makeSettingsPath() { describe("desktopSettings", () => { it("returns defaults when no settings file exists", () => { - expect(readDesktopSettings(makeSettingsPath())).toEqual(DEFAULT_DESKTOP_SETTINGS); + expect(readDesktopSettings(makeSettingsPath(), "0.0.17")).toEqual(DEFAULT_DESKTOP_SETTINGS); + }); + + it("defaults packaged nightly builds to the nightly update channel", () => { + expect(resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1")).toEqual({ + serverExposureMode: "local-only", + updateChannel: "nightly", + updateChannelConfiguredByUser: false, + }); }); it("persists and reloads the configured server exposure mode", () => { @@ -35,10 +45,14 @@ describe("desktopSettings", () => { writeDesktopSettings(settingsPath, { serverExposureMode: "network-accessible", + updateChannel: "latest", + updateChannelConfiguredByUser: true, }); - expect(readDesktopSettings(settingsPath)).toEqual({ + expect(readDesktopSettings(settingsPath, "0.0.17")).toEqual({ serverExposureMode: "network-accessible", + updateChannel: "latest", + updateChannelConfiguredByUser: true, }); }); @@ -47,11 +61,32 @@ describe("desktopSettings", () => { setDesktopServerExposurePreference( { serverExposureMode: "local-only", + updateChannel: "latest", + updateChannelConfiguredByUser: false, }, "network-accessible", ), ).toEqual({ serverExposureMode: "network-accessible", + updateChannel: "latest", + updateChannelConfiguredByUser: false, + }); + }); + + it("persists the requested nightly update channel", () => { + expect( + setDesktopUpdateChannelPreference( + { + serverExposureMode: "local-only", + updateChannel: "latest", + updateChannelConfiguredByUser: false, + }, + "nightly", + ), + ).toEqual({ + serverExposureMode: "local-only", + updateChannel: "nightly", + updateChannelConfiguredByUser: true, }); }); @@ -59,6 +94,54 @@ describe("desktopSettings", () => { const settingsPath = makeSettingsPath(); fs.writeFileSync(settingsPath, "{not-json", "utf8"); - expect(readDesktopSettings(settingsPath)).toEqual(DEFAULT_DESKTOP_SETTINGS); + expect(readDesktopSettings(settingsPath, "0.0.17")).toEqual(DEFAULT_DESKTOP_SETTINGS); + }); + + it("falls back to the nightly channel for legacy nightly settings without an update track", () => { + const settingsPath = makeSettingsPath(); + fs.writeFileSync(settingsPath, JSON.stringify({ serverExposureMode: "local-only" }), "utf8"); + + expect(readDesktopSettings(settingsPath, "0.0.17-nightly.20260415.1")).toEqual({ + serverExposureMode: "local-only", + updateChannel: "nightly", + updateChannelConfiguredByUser: false, + }); + }); + + it("migrates legacy implicit stable settings to nightly when running a nightly build", () => { + const settingsPath = makeSettingsPath(); + fs.writeFileSync( + settingsPath, + JSON.stringify({ + serverExposureMode: "local-only", + updateChannel: "latest", + }), + "utf8", + ); + + expect(readDesktopSettings(settingsPath, "0.0.17-nightly.20260415.1")).toEqual({ + serverExposureMode: "local-only", + updateChannel: "nightly", + updateChannelConfiguredByUser: false, + }); + }); + + it("preserves an explicit stable choice on nightly builds", () => { + const settingsPath = makeSettingsPath(); + fs.writeFileSync( + settingsPath, + JSON.stringify({ + serverExposureMode: "local-only", + updateChannel: "latest", + updateChannelConfiguredByUser: true, + }), + "utf8", + ); + + expect(readDesktopSettings(settingsPath, "0.0.17-nightly.20260415.1")).toEqual({ + serverExposureMode: "local-only", + updateChannel: "latest", + updateChannelConfiguredByUser: true, + }); }); }); diff --git a/apps/desktop/src/desktopSettings.ts b/apps/desktop/src/desktopSettings.ts index 1e6cc3cd6f3..e7edfa87cac 100644 --- a/apps/desktop/src/desktopSettings.ts +++ b/apps/desktop/src/desktopSettings.ts @@ -1,15 +1,28 @@ import * as FS from "node:fs"; import * as Path from "node:path"; -import type { DesktopServerExposureMode } from "@marcode/contracts"; +import type { DesktopServerExposureMode, DesktopUpdateChannel } from "@marcode/contracts"; + +import { resolveDefaultDesktopUpdateChannel } from "./updateChannels.ts"; export interface DesktopSettings { readonly serverExposureMode: DesktopServerExposureMode; + readonly updateChannel: DesktopUpdateChannel; + readonly updateChannelConfiguredByUser: boolean; } export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { serverExposureMode: "local-only", + updateChannel: "latest", + updateChannelConfiguredByUser: false, }; +export function resolveDefaultDesktopSettings(appVersion: string): DesktopSettings { + return { + ...DEFAULT_DESKTOP_SETTINGS, + updateChannel: resolveDefaultDesktopUpdateChannel(appVersion), + }; +} + export function setDesktopServerExposurePreference( settings: DesktopSettings, requestedMode: DesktopServerExposureMode, @@ -22,23 +35,51 @@ export function setDesktopServerExposurePreference( }; } -export function readDesktopSettings(settingsPath: string): DesktopSettings { +export function setDesktopUpdateChannelPreference( + settings: DesktopSettings, + requestedChannel: DesktopUpdateChannel, +): DesktopSettings { + return { + ...settings, + updateChannel: requestedChannel, + updateChannelConfiguredByUser: true, + }; +} + +export function readDesktopSettings(settingsPath: string, appVersion: string): DesktopSettings { + const defaultSettings = resolveDefaultDesktopSettings(appVersion); + try { if (!FS.existsSync(settingsPath)) { - return DEFAULT_DESKTOP_SETTINGS; + return defaultSettings; } const raw = FS.readFileSync(settingsPath, "utf8"); const parsed = JSON.parse(raw) as { readonly serverExposureMode?: unknown; + readonly updateChannel?: unknown; + readonly updateChannelConfiguredByUser?: unknown; }; + const parsedUpdateChannel = + parsed.updateChannel === "nightly" || parsed.updateChannel === "latest" + ? parsed.updateChannel + : null; + const isLegacySettings = parsed.updateChannelConfiguredByUser === undefined; + const updateChannelConfiguredByUser = + parsed.updateChannelConfiguredByUser === true || + (isLegacySettings && parsedUpdateChannel === "nightly"); return { serverExposureMode: parsed.serverExposureMode === "network-accessible" ? "network-accessible" : "local-only", + updateChannel: + updateChannelConfiguredByUser && parsedUpdateChannel !== null + ? parsedUpdateChannel + : defaultSettings.updateChannel, + updateChannelConfiguredByUser, }; } catch { - return DEFAULT_DESKTOP_SETTINGS; + return defaultSettings; } } diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index cad12ed6b1f..544a42ef869 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -10,6 +10,7 @@ declare const __EMBEDDED_MARCODE_JIRA_TOKEN_PROXY_URL__: string; import { app, BrowserWindow, + type BrowserWindowConstructorOptions, clipboard, dialog, ipcMain, @@ -20,12 +21,14 @@ import { safeStorage, shell, } from "electron"; -import type { MenuItemConstructorOptions } from "electron"; +import type { MenuItemConstructorOptions, OpenDialogOptions } from "electron"; import type { ClientSettings, DesktopTheme, + DesktopAppBranding, DesktopServerExposureMode, DesktopServerExposureState, + DesktopUpdateChannel, PersistedSavedEnvironmentRecord, DesktopUpdateActionResult, DesktopUpdateCheckResult, @@ -36,13 +39,14 @@ import { autoUpdater } from "electron-updater"; import type { ContextMenuItem } from "@marcode/contracts"; import { RotatingFileSink } from "@marcode/shared/logging"; import { parsePersistedServerObservabilitySettings } from "@marcode/shared/serverSettings"; -import { DEFAULT_DESKTOP_BACKEND_PORT, resolveDesktopBackendPort } from "./backendPort"; +import { DEFAULT_DESKTOP_BACKEND_PORT, resolveDesktopBackendPort } from "./backendPort.ts"; import { DEFAULT_DESKTOP_SETTINGS, readDesktopSettings, setDesktopServerExposurePreference, + setDesktopUpdateChannelPreference, writeDesktopSettings, -} from "./desktopSettings"; +} from "./desktopSettings.ts"; import { readClientSettings, readSavedEnvironmentRegistry, @@ -51,12 +55,14 @@ import { writeClientSettings, writeSavedEnvironmentRegistry, writeSavedEnvironmentSecret, -} from "./clientPersistence"; -import { isBackendReadinessAborted, waitForHttpReady } from "./backendReadiness"; -import { showDesktopConfirmDialog } from "./confirmDialog"; -import { resolveDesktopServerExposure } from "./serverExposure"; -import { syncShellEnvironment } from "./syncShellEnvironment"; -import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState"; +} from "./clientPersistence.ts"; +import { isBackendReadinessAborted, waitForHttpReady } from "./backendReadiness.ts"; +import { showDesktopConfirmDialog } from "./confirmDialog.ts"; +import { resolveDesktopServerExposure } from "./serverExposure.ts"; +import { syncShellEnvironment } from "./syncShellEnvironment.ts"; +import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState.ts"; +import { doesVersionMatchDesktopUpdateChannel } from "./updateChannels.ts"; +import { ServerListeningDetector } from "./serverListeningDetector.ts"; import { createInitialDesktopUpdateState, reduceDesktopUpdateStateOnCheckFailure, @@ -68,10 +74,10 @@ import { reduceDesktopUpdateStateOnInstallFailure, reduceDesktopUpdateStateOnNoUpdate, reduceDesktopUpdateStateOnUpdateAvailable, -} from "./updateMachine"; -import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch"; -import { readWindowState, resolveWindowBounds, writeWindowState } from "./windowState"; -import type { WindowState } from "./windowState"; +} from "./updateMachine.ts"; +import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch.ts"; +import { readWindowState, resolveWindowBounds, writeWindowState } from "./windowState.ts"; +import type { WindowState } from "./windowState.ts"; syncShellEnvironment(); @@ -83,10 +89,12 @@ const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; const MENU_ACTION_CHANNEL = "desktop:menu-action"; const UPDATE_STATE_CHANNEL = "desktop:update-state"; const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; +const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel"; const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; const FULLSCREEN_STATE_CHANNEL = "desktop:fullscreen-change"; const UPDATE_CHECK_CHANNEL = "desktop:update-check"; +const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding"; const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap"; const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings"; const SET_CLIENT_SETTINGS_CHANNEL = "desktop:set-client-settings"; @@ -112,6 +120,11 @@ const LINUX_DESKTOP_ENTRY_NAME = isDevelopment ? "marcode-dev.desktop" : "marcod const LINUX_WM_CLASS = isDevelopment ? "marcode-dev" : "marcode"; const USER_DATA_DIR_NAME = isDevelopment ? "marcode-dev" : "marcode"; const LEGACY_USER_DATA_DIR_NAME = isDevelopment ? "MarCode (Dev)" : "MarCode (Alpha)"; +const desktopAppBranding: DesktopAppBranding = { + baseName: "MarCode", + stageLabel: isDevelopment ? "Dev" : "Alpha", + displayName: APP_DISPLAY_NAME, +}; const COMMIT_HASH_PATTERN = /^[0-9a-f]{7,40}$/i; const COMMIT_HASH_DISPLAY_LENGTH = 12; const LOG_DIR = Path.join(STATE_DIR, "logs"); @@ -121,10 +134,72 @@ const APP_RUN_ID = Crypto.randomBytes(6).toString("hex"); const SERVER_SETTINGS_PATH = Path.join(STATE_DIR, "settings.json"); const AUTO_UPDATE_STARTUP_DELAY_MS = 15_000; const AUTO_UPDATE_POLL_INTERVAL_MS = 4 * 60 * 60 * 1000; -const DESKTOP_UPDATE_CHANNEL = "latest"; -const DESKTOP_UPDATE_ALLOW_PRERELEASE = false; + +function resolvePickFolderDefaultPath(rawOptions: unknown): string | undefined { + if (typeof rawOptions !== "object" || rawOptions === null) { + return undefined; + } + + const { initialPath } = rawOptions as { initialPath?: unknown }; + if (typeof initialPath !== "string") { + return undefined; + } + + const trimmedPath = initialPath.trim(); + if (trimmedPath.length === 0) { + return undefined; + } + + if (trimmedPath === "~") { + return OS.homedir(); + } + + if (trimmedPath.startsWith("~/") || trimmedPath.startsWith("~\\")) { + return Path.join(OS.homedir(), trimmedPath.slice(2)); + } + + return Path.resolve(trimmedPath); +} const DESKTOP_LOOPBACK_HOST = "127.0.0.1"; const DESKTOP_REQUIRED_PORT_PROBE_HOSTS = ["0.0.0.0", "::"] as const; +const TITLEBAR_HEIGHT = 40; +const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux +const TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937"; +const TITLEBAR_DARK_SYMBOL_COLOR = "#f8fafc"; + +function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextMenuItem[] { + const normalizedItems: ContextMenuItem[] = []; + + for (const sourceItem of source) { + if (typeof sourceItem.id !== "string" || typeof sourceItem.label !== "string") { + continue; + } + + const normalizedItem: ContextMenuItem = { + id: sourceItem.id, + label: sourceItem.label, + destructive: sourceItem.destructive === true, + disabled: sourceItem.disabled === true, + }; + + if (sourceItem.children) { + const normalizedChildren = normalizeContextMenuItems(sourceItem.children); + if (normalizedChildren.length === 0) { + continue; + } + normalizedItem.children = normalizedChildren; + } + + normalizedItems.push(normalizedItem); + } + + return normalizedItems; +} + +type WindowTitleBarOptions = Pick< + BrowserWindowConstructorOptions, + "titleBarOverlay" | "titleBarStyle" | "trafficLightPosition" +>; type DesktopUpdateErrorContext = DesktopUpdateState["errorContext"]; type LinuxDesktopNamedApp = Electron.App & { @@ -141,6 +216,8 @@ let backendWsUrl = ""; let backendEndpointUrl: string | null = null; let backendAdvertisedHost: string | null = null; let backendReadinessAbortController: AbortController | null = null; +let backendInitialWindowOpenInFlight: Promise | null = null; +let backendListeningDetector: ServerListeningDetector | null = null; let restartAttempt = 0; let restartTimer: ReturnType | null = null; let isQuitting = false; @@ -150,7 +227,7 @@ let desktopLogSink: RotatingFileSink | null = null; let backendLogSink: RotatingFileSink | null = null; let restoreStdIoCapture: (() => void) | null = null; let backendObservabilitySettings = readPersistedBackendObservabilitySettings(); -let desktopSettings = readDesktopSettings(DESKTOP_SETTINGS_PATH); +let desktopSettings = readDesktopSettings(DESKTOP_SETTINGS_PATH, app.getVersion()); let desktopServerExposureMode: DesktopServerExposureMode = desktopSettings.serverExposureMode; let lastWindowState: WindowState = readWindowState(WINDOW_STATE_PATH); @@ -162,7 +239,11 @@ const desktopRuntimeInfo = resolveDesktopRuntimeInfo({ runningUnderArm64Translation: app.runningUnderARM64Translation === true, }); const initialUpdateState = (): DesktopUpdateState => - createInitialDesktopUpdateState(app.getVersion(), desktopRuntimeInfo); + createInitialDesktopUpdateState( + app.getVersion(), + desktopRuntimeInfo, + desktopSettings.updateChannel, + ); function logTimestamp(): string { return new Date().toISOString(); @@ -371,13 +452,22 @@ function getSafeTheme(rawTheme: unknown): DesktopTheme | null { return null; } -async function waitForBackendHttpReady(baseUrl: string): Promise { +async function waitForBackendHttpReady( + baseUrl: string, + options?: Parameters[1], +): Promise { cancelBackendReadinessWait(); const controller = new AbortController(); backendReadinessAbortController = controller; try { await waitForHttpReady(baseUrl, { + // `/` redirects to the Vite dev server in development (302) which + // fails the default `response.ok` readiness predicate. Probe the auth + // session endpoint instead — it returns 200 in both dev and packaged + // builds once the backend is accepting requests. + path: "/api/auth/session", + ...options, signal: controller.signal, }); } finally { @@ -392,6 +482,88 @@ function cancelBackendReadinessWait(): void { backendReadinessAbortController = null; } +async function waitForBackendWindowReady(baseUrl: string): Promise<"listening" | "http"> { + const httpReadyPromise = waitForBackendHttpReady(baseUrl, { + timeoutMs: 60_000, + }); + const listeningPromise = backendListeningDetector?.promise; + + if (!listeningPromise) { + await httpReadyPromise; + return "http"; + } + + return await new Promise<"listening" | "http">((resolve, reject) => { + let settled = false; + + const settleResolve = (source: "listening" | "http") => { + if (settled) { + return; + } + settled = true; + if (source === "listening") { + cancelBackendReadinessWait(); + } + resolve(source); + }; + + const settleReject = (error: unknown) => { + if (settled) { + return; + } + settled = true; + reject(error); + }; + + listeningPromise.then( + () => settleResolve("listening"), + (error) => settleReject(error), + ); + httpReadyPromise.then( + () => settleResolve("http"), + (error) => { + if (settled && isBackendReadinessAborted(error)) { + return; + } + settleReject(error); + }, + ); + }); +} + +function ensureInitialBackendWindowOpen(): void { + const existingWindow = mainWindow ?? BrowserWindow.getAllWindows()[0] ?? null; + if (isDevelopment || existingWindow !== null || backendInitialWindowOpenInFlight !== null) { + return; + } + + const nextOpen = waitForBackendWindowReady(backendHttpUrl) + .then((source) => { + writeDesktopLogHeader(`bootstrap backend ready source=${source}`); + if (mainWindow ?? BrowserWindow.getAllWindows()[0]) { + return; + } + mainWindow = createWindow(); + writeDesktopLogHeader("bootstrap main window created"); + }) + .catch((error) => { + if (isBackendReadinessAborted(error)) { + return; + } + writeDesktopLogHeader( + `bootstrap backend readiness warning message=${formatErrorMessage(error)}`, + ); + console.warn("[desktop] backend readiness check timed out during packaged bootstrap", error); + }) + .finally(() => { + if (backendInitialWindowOpenInFlight === nextOpen) { + backendInitialWindowOpenInFlight = null; + } + }); + + backendInitialWindowOpenInFlight = nextOpen; +} + function writeDesktopStreamChunk( streamName: "stdout" | "stderr", chunk: unknown, @@ -469,14 +641,16 @@ function initializePackagedLogging(): void { } function captureBackendOutput(child: ChildProcess.ChildProcess): void { - if (!app.isPackaged || backendLogSink === null) return; - const writeChunk = (chunk: unknown): void => { - if (!backendLogSink) return; - const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), "utf8"); - backendLogSink.write(buffer); + const attachStream = (stream: NodeJS.ReadableStream | null | undefined): void => { + stream?.on("data", (chunk: unknown) => { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), "utf8"); + backendLogSink?.write(buffer); + backendListeningDetector?.push(buffer); + }); }; - child.stdout?.on("data", writeChunk); - child.stderr?.on("data", writeChunk); + + attachStream(child.stdout); + attachStream(child.stderr); } initializePackagedLogging(); @@ -893,6 +1067,18 @@ function resolveResourcePath(fileName: string): string | null { } function resolveIconPath(ext: "ico" | "icns" | "png"): string | null { + if (isDevelopment && process.platform === "darwin" && ext === "png") { + const developmentDockIconPath = Path.join( + ROOT_DIR, + "assets", + "dev", + "blueprint-macos-1024.png", + ); + if (FS.existsSync(developmentDockIconPath)) { + return developmentDockIconPath; + } + } + return resolveResourcePath(`icon.${ext}`); } @@ -992,6 +1178,26 @@ function setUpdateState(patch: Partial): void { emitUpdateState(); } +function createBaseUpdateState( + channel: DesktopUpdateChannel, + enabled: boolean, +): DesktopUpdateState { + return { + ...createInitialDesktopUpdateState(app.getVersion(), desktopRuntimeInfo, channel), + enabled, + status: enabled ? "idle" : "disabled", + }; +} + +function applyAutoUpdaterChannel(channel: DesktopUpdateChannel): void { + autoUpdater.channel = channel; + autoUpdater.allowPrerelease = channel === "nightly"; + autoUpdater.allowDowngrade = channel === "nightly"; + console.info( + `[desktop-updater] Using update channel '${channel}' (allowPrerelease=${channel === "nightly"}, allowDowngrade=${channel === "nightly"}).`, + ); +} + function shouldEnableAutoUpdates(): boolean { const hasUpdateFeedConfig = readAppUpdateYml() !== null || Boolean(process.env.MARCODE_DESKTOP_MOCK_UPDATES); @@ -1111,11 +1317,7 @@ function configureAutoUpdater(): void { } const enabled = shouldEnableAutoUpdates(); - setUpdateState({ - ...createInitialDesktopUpdateState(app.getVersion(), desktopRuntimeInfo), - enabled, - status: enabled ? "idle" : "disabled", - }); + setUpdateState(createBaseUpdateState(desktopSettings.updateChannel, enabled)); if (!enabled) { return; } @@ -1123,10 +1325,7 @@ function configureAutoUpdater(): void { autoUpdater.autoDownload = false; autoUpdater.autoInstallOnAppQuit = false; - // Keep alpha branding, but force all installs onto the stable update track. - autoUpdater.channel = DESKTOP_UPDATE_CHANNEL; - autoUpdater.allowPrerelease = DESKTOP_UPDATE_ALLOW_PRERELEASE; - autoUpdater.allowDowngrade = false; + applyAutoUpdaterChannel(desktopSettings.updateChannel); autoUpdater.disableDifferentialDownload = isArm64HostRunningIntelBuild(desktopRuntimeInfo); let lastLoggedDownloadMilestone = -1; @@ -1140,6 +1339,15 @@ function configureAutoUpdater(): void { console.info("[desktop-updater] Looking for updates..."); }); autoUpdater.on("update-available", (info) => { + if (!doesVersionMatchDesktopUpdateChannel(info.version, updateState.channel)) { + console.info( + `[desktop-updater] Ignoring ${info.version} because it does not match the selected '${updateState.channel}' channel.`, + ); + setUpdateState(reduceDesktopUpdateStateOnNoUpdate(updateState, new Date().toISOString())); + lastLoggedDownloadMilestone = -1; + return; + } + setUpdateState( reduceDesktopUpdateStateOnUpdateAvailable( updateState, @@ -1231,7 +1439,7 @@ function startBackend(): void { return; } - const captureBackendLogs = app.isPackaged && backendLogSink !== null; + const captureBackendLogs = !isDevelopment; const child = ChildProcess.spawn(process.execPath, [backendEntry, "--bootstrap-fd", "3"], { cwd: resolveBackendCwd(), // In Electron main, process.execPath points to the Electron binary. @@ -1268,6 +1476,8 @@ function startBackend(): void { scheduleBackendRestart("missing desktop bootstrap pipe"); return; } + const listeningDetector = new ServerListeningDetector(); + backendListeningDetector = listeningDetector; backendProcess = child; let backendSessionClosed = false; const closeBackendSession = (details: string) => { @@ -1286,6 +1496,10 @@ function startBackend(): void { }); child.on("error", (error) => { + if (backendListeningDetector === listeningDetector) { + listeningDetector.fail(error); + backendListeningDetector = null; + } const wasExpected = expectedBackendExitChildren.has(child); if (backendProcess === child) { backendProcess = null; @@ -1298,6 +1512,14 @@ function startBackend(): void { }); child.on("exit", (code, signal) => { + if (backendListeningDetector === listeningDetector) { + listeningDetector.fail( + new Error( + `backend exited before logging readiness (code=${code ?? "null"} signal=${signal ?? "null"})`, + ), + ); + backendListeningDetector = null; + } const wasExpected = expectedBackendExitChildren.has(child); if (backendProcess === child) { backendProcess = null; @@ -1309,10 +1531,13 @@ function startBackend(): void { const reason = `code=${code ?? "null"} signal=${signal ?? "null"}`; scheduleBackendRestart(reason); }); + + ensureInitialBackendWindowOpen(); } function stopBackend(): void { cancelBackendReadinessWait(); + backendListeningDetector = null; if (restartTimer) { clearTimeout(restartTimer); restartTimer = null; @@ -1387,6 +1612,11 @@ async function stopBackendAndWaitForExit(timeoutMs = 5_000): Promise { } function registerIpcHandlers(): void { + ipcMain.removeAllListeners(GET_APP_BRANDING_CHANNEL); + ipcMain.on(GET_APP_BRANDING_CHANNEL, (event) => { + event.returnValue = desktopAppBranding; + }); + ipcMain.removeAllListeners(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL); ipcMain.on(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL, (event) => { event.returnValue = { @@ -1500,15 +1730,16 @@ function registerIpcHandlers(): void { }); ipcMain.removeHandler(PICK_FOLDER_CHANNEL); - ipcMain.handle(PICK_FOLDER_CHANNEL, async () => { + ipcMain.handle(PICK_FOLDER_CHANNEL, async (_event, rawOptions: unknown) => { const owner = BrowserWindow.getFocusedWindow() ?? mainWindow; + const defaultPath = resolvePickFolderDefaultPath(rawOptions); + const openDialogOptions: OpenDialogOptions = { + properties: ["openDirectory", "createDirectory"], + ...(defaultPath ? { defaultPath } : {}), + }; const result = owner - ? await dialog.showOpenDialog(owner, { - properties: ["openDirectory", "createDirectory"], - }) - : await dialog.showOpenDialog({ - properties: ["openDirectory", "createDirectory"], - }); + ? await dialog.showOpenDialog(owner, openDialogOptions) + : await dialog.showOpenDialog(openDialogOptions); if (result.canceled) return null; return result.filePaths[0] ?? null; }); @@ -1537,14 +1768,7 @@ function registerIpcHandlers(): void { ipcMain.handle( CONTEXT_MENU_CHANNEL, async (_event, items: ContextMenuItem[], position?: { x: number; y: number }) => { - const normalizedItems = items - .filter((item) => typeof item.id === "string" && typeof item.label === "string") - .map((item) => ({ - id: item.id, - label: item.label, - destructive: item.destructive === true, - disabled: item.disabled === true, - })); + const normalizedItems = normalizeContextMenuItems(items); if (normalizedItems.length === 0) { return null; } @@ -1565,28 +1789,37 @@ function registerIpcHandlers(): void { if (!window) return null; return new Promise((resolve) => { - const template: MenuItemConstructorOptions[] = []; - let hasInsertedDestructiveSeparator = false; - for (const item of normalizedItems) { - if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) { - template.push({ type: "separator" }); - hasInsertedDestructiveSeparator = true; - } - const itemOption: MenuItemConstructorOptions = { - label: item.label, - enabled: !item.disabled, - click: () => resolve(item.id), - }; - if (item.destructive) { - const destructiveIcon = getDestructiveMenuIcon(); - if (destructiveIcon) { - itemOption.icon = destructiveIcon; + const buildTemplate = ( + entries: readonly ContextMenuItem[], + ): MenuItemConstructorOptions[] => { + const template: MenuItemConstructorOptions[] = []; + let hasInsertedDestructiveSeparator = false; + for (const item of entries) { + if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) { + template.push({ type: "separator" }); + hasInsertedDestructiveSeparator = true; + } + const itemOption: MenuItemConstructorOptions = { + label: item.label, + enabled: !item.disabled, + }; + if (item.children && item.children.length > 0) { + itemOption.submenu = buildTemplate(item.children); + } else { + itemOption.click = () => resolve(item.id); } + if (item.destructive && (!item.children || item.children.length === 0)) { + const destructiveIcon = getDestructiveMenuIcon(); + if (destructiveIcon) { + itemOption.icon = destructiveIcon; + } + } + template.push(itemOption); } - template.push(itemOption); - } + return template; + }; - const menu = Menu.buildFromTemplate(template); + const menu = Menu.buildFromTemplate(buildTemplate(normalizedItems)); menu.popup({ window, ...popupPosition, @@ -1614,6 +1847,43 @@ function registerIpcHandlers(): void { ipcMain.removeHandler(UPDATE_GET_STATE_CHANNEL); ipcMain.handle(UPDATE_GET_STATE_CHANNEL, async () => updateState); + ipcMain.removeHandler(UPDATE_SET_CHANNEL_CHANNEL); + ipcMain.handle(UPDATE_SET_CHANNEL_CHANNEL, async (_event, rawChannel: unknown) => { + if (rawChannel !== "latest" && rawChannel !== "nightly") { + throw new Error("Invalid desktop update channel input."); + } + if (updateCheckInFlight || updateDownloadInFlight || updateInstallInFlight) { + throw new Error("Cannot change update tracks while an update action is in progress."); + } + + const nextChannel = rawChannel as DesktopUpdateChannel; + + desktopSettings = setDesktopUpdateChannelPreference(desktopSettings, nextChannel); + writeDesktopSettings(DESKTOP_SETTINGS_PATH, desktopSettings); + + if (nextChannel === updateState.channel) { + return updateState; + } + + const enabled = shouldEnableAutoUpdates(); + setUpdateState(createBaseUpdateState(nextChannel, enabled)); + + if (!enabled || !updaterConfigured) { + return updateState; + } + + applyAutoUpdaterChannel(nextChannel); + const allowDowngrade = autoUpdater.allowDowngrade; + // An explicit channel switch should allow the immediate nightly->stable rollback path. + autoUpdater.allowDowngrade = true; + try { + await checkForUpdates("channel-change"); + } finally { + autoUpdater.allowDowngrade = allowDowngrade; + } + return updateState; + }); + ipcMain.removeHandler(UPDATE_DOWNLOAD_CHANNEL); ipcMain.handle(UPDATE_DOWNLOAD_CHANNEL, async () => { const result = await downloadAvailableUpdate(); @@ -1686,6 +1956,46 @@ function saveWindowState(window: BrowserWindow): void { writeWindowState(WINDOW_STATE_PATH, lastWindowState); } +function getWindowTitleBarOptions(): WindowTitleBarOptions { + if (process.platform === "darwin") { + return { + titleBarStyle: "hiddenInset", + trafficLightPosition: { x: 16, y: 18 }, + }; + } + + return { + titleBarStyle: "hidden", + titleBarOverlay: { + color: TITLEBAR_COLOR, + height: TITLEBAR_HEIGHT, + symbolColor: nativeTheme.shouldUseDarkColors + ? TITLEBAR_DARK_SYMBOL_COLOR + : TITLEBAR_LIGHT_SYMBOL_COLOR, + }, + }; +} + +function syncWindowAppearance(window: BrowserWindow): void { + if (window.isDestroyed()) { + return; + } + + window.setBackgroundColor(getInitialWindowBackgroundColor()); + const { titleBarOverlay } = getWindowTitleBarOptions(); + if (typeof titleBarOverlay === "object") { + window.setTitleBarOverlay(titleBarOverlay); + } +} + +function syncAllWindowAppearance(): void { + for (const window of BrowserWindow.getAllWindows()) { + syncWindowAppearance(window); + } +} + +nativeTheme.on("updated", syncAllWindowAppearance); + function createWindow(options?: { deferLoad?: boolean }): BrowserWindow { const restoredBounds = resolveWindowBounds(lastWindowState); @@ -1693,15 +2003,14 @@ function createWindow(options?: { deferLoad?: boolean }): BrowserWindow { ...restoredBounds, minWidth: 840, minHeight: 620, - show: isDevelopment, + show: false, autoHideMenuBar: true, backgroundColor: getInitialWindowBackgroundColor(), ...getIconOption(), title: APP_DISPLAY_NAME, - titleBarStyle: "hiddenInset", - trafficLightPosition: { x: 16, y: 18 }, + ...getWindowTitleBarOptions(), webPreferences: { - preload: Path.join(__dirname, "preload.js"), + preload: Path.join(__dirname, "preload.cjs"), contextIsolation: true, nodeIntegration: false, sandbox: true, @@ -1790,22 +2099,25 @@ function createWindow(options?: { deferLoad?: boolean }): BrowserWindow { window.on("leave-full-screen", () => { window.webContents.send(FULLSCREEN_STATE_CHANNEL, false); }); - if (!isDevelopment) { - window.once("ready-to-show", () => { - revealWindow(window); - }); - } + + let initialRevealScheduled = false; + const revealInitialWindow = () => { + if (initialRevealScheduled) { + return; + } + initialRevealScheduled = true; + revealWindow(window); + }; + + window.once("ready-to-show", revealInitialWindow); if (isDevelopment) { if (!options?.deferLoad) { void window.loadURL(resolveDesktopDevServerUrl()); } window.webContents.openDevTools({ mode: "detach" }); - setImmediate(() => { - revealWindow(window); - }); } else { - void window.loadURL(resolveDesktopWindowUrl()); + void window.loadURL(backendHttpUrl); } window.on("closed", () => { @@ -1817,14 +2129,6 @@ function createWindow(options?: { deferLoad?: boolean }): BrowserWindow { return window; } -function resolveDesktopWindowUrl(): string { - if (backendHttpUrl) { - return backendHttpUrl; - } - - return `${DESKTOP_SCHEME}://app`; -} - // Override Electron's userData path before the `ready` event so that // Chromium session data uses a filesystem-friendly directory name. // Must be called synchronously at the top level — before `app.whenReady()`. @@ -1905,10 +2209,7 @@ async function bootstrap(): Promise { return; } - await waitForBackendHttpReady(backendHttpUrl); - writeDesktopLogHeader("bootstrap backend ready"); - mainWindow = createWindow(); - writeDesktopLogHeader("bootstrap main window created"); + ensureInitialBackendWindowOpen(); } app.on("before-quit", () => { @@ -1945,7 +2246,11 @@ app revealWindow(existingWindow); return; } - mainWindow = createWindow(); + if (isDevelopment) { + mainWindow = createWindow(); + return; + } + ensureInitialBackendWindowOpen(); }); }) .catch((error) => { diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 09b32f53567..42f83c299ea 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -9,10 +9,12 @@ const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; const MENU_ACTION_CHANNEL = "desktop:menu-action"; const UPDATE_STATE_CHANNEL = "desktop:update-state"; const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; +const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel"; const FULLSCREEN_CHANGE_CHANNEL = "desktop:fullscreen-change"; const UPDATE_CHECK_CHANNEL = "desktop:update-check"; const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; +const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding"; const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap"; const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings"; const SET_CLIENT_SETTINGS_CHANNEL = "desktop:set-client-settings"; @@ -25,6 +27,13 @@ const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state"; const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; contextBridge.exposeInMainWorld("desktopBridge", { + getAppBranding: () => { + const result = ipcRenderer.sendSync(GET_APP_BRANDING_CHANNEL); + if (typeof result !== "object" || result === null) { + return null; + } + return result as ReturnType; + }, getLocalEnvironmentBootstrap: () => { const result = ipcRenderer.sendSync(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL); if (typeof result !== "object" || result === null) { @@ -45,7 +54,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.invoke(REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId), getServerExposureState: () => ipcRenderer.invoke(GET_SERVER_EXPOSURE_STATE_CHANNEL), setServerExposureMode: (mode) => ipcRenderer.invoke(SET_SERVER_EXPOSURE_MODE_CHANNEL, mode), - pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL), + pickFolder: (options) => ipcRenderer.invoke(PICK_FOLDER_CHANNEL, options), confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message), setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme), showContextMenu: (items, position) => ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items, position), @@ -62,6 +71,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { }; }, getUpdateState: () => ipcRenderer.invoke(UPDATE_GET_STATE_CHANNEL), + setUpdateChannel: (channel) => ipcRenderer.invoke(UPDATE_SET_CHANNEL_CHANNEL, channel), checkForUpdate: () => ipcRenderer.invoke(UPDATE_CHECK_CHANNEL), downloadUpdate: () => ipcRenderer.invoke(UPDATE_DOWNLOAD_CHANNEL), installUpdate: () => ipcRenderer.invoke(UPDATE_INSTALL_CHANNEL), diff --git a/apps/desktop/src/runtimeArch.test.ts b/apps/desktop/src/runtimeArch.test.ts index 258a8fb2152..a3173598949 100644 --- a/apps/desktop/src/runtimeArch.test.ts +++ b/apps/desktop/src/runtimeArch.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch"; +import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch.ts"; describe("resolveDesktopRuntimeInfo", () => { it("detects Rosetta-translated Intel builds on Apple Silicon", () => { diff --git a/apps/desktop/src/serverExposure.test.ts b/apps/desktop/src/serverExposure.test.ts index b1ae4bef4f5..c83bbc210e0 100644 --- a/apps/desktop/src/serverExposure.test.ts +++ b/apps/desktop/src/serverExposure.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { resolveDesktopServerExposure, resolveLanAdvertisedHost } from "./serverExposure"; +import { resolveDesktopServerExposure, resolveLanAdvertisedHost } from "./serverExposure.ts"; describe("resolveLanAdvertisedHost", () => { it("prefers an explicit host override", () => { diff --git a/apps/desktop/src/serverListeningDetector.test.ts b/apps/desktop/src/serverListeningDetector.test.ts new file mode 100644 index 00000000000..fcf9f50ae96 --- /dev/null +++ b/apps/desktop/src/serverListeningDetector.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; + +import { ServerListeningDetector } from "./serverListeningDetector.ts"; + +describe("ServerListeningDetector", () => { + it("resolves when the server logs the listening line", async () => { + const detector = new ServerListeningDetector(); + + detector.push("[01:23:30.571] INFO (#148): Listening on http://0.0.0.0:7011\n"); + + await expect(detector.promise).resolves.toBeUndefined(); + }); + + it("resolves when the listening line arrives across multiple chunks", async () => { + const detector = new ServerListeningDetector(); + + detector.push("[01:23:30.571] INFO (#148): Listen"); + detector.push("ing on http://0.0.0.0:7011\n"); + + await expect(detector.promise).resolves.toBeUndefined(); + }); + + it("rejects when the server exits before logging readiness", async () => { + const detector = new ServerListeningDetector(); + const error = new Error("server exited"); + + detector.fail(error); + + await expect(detector.promise).rejects.toBe(error); + }); +}); diff --git a/apps/desktop/src/serverListeningDetector.ts b/apps/desktop/src/serverListeningDetector.ts new file mode 100644 index 00000000000..e738aacc38d --- /dev/null +++ b/apps/desktop/src/serverListeningDetector.ts @@ -0,0 +1,56 @@ +const LISTENING_LOG_FRAGMENT = "Listening on http://"; +const MAX_BUFFER_CHARS = 8_192; + +export class ServerListeningDetector { + private buffer = ""; + private settled = false; + private readonly resolvePromise: () => void; + private readonly rejectPromise: (error: unknown) => void; + readonly promise: Promise; + + constructor() { + let resolvePromise: (() => void) | null = null; + let rejectPromise: ((error: unknown) => void) | null = null; + + this.promise = new Promise((resolve, reject) => { + resolvePromise = resolve; + rejectPromise = reject; + }); + + this.resolvePromise = () => { + if (this.settled) { + return; + } + this.settled = true; + resolvePromise?.(); + }; + this.rejectPromise = (error) => { + if (this.settled) { + return; + } + this.settled = true; + rejectPromise?.(error); + }; + } + + push(chunk: unknown): void { + if (this.settled) { + return; + } + + const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk); + this.buffer = `${this.buffer}${text.replace(/\r/g, "")}`; + if (this.buffer.includes(LISTENING_LOG_FRAGMENT)) { + this.resolvePromise(); + return; + } + + if (this.buffer.length > MAX_BUFFER_CHARS) { + this.buffer = this.buffer.slice(-MAX_BUFFER_CHARS); + } + } + + fail(error: unknown): void { + this.rejectPromise(error); + } +} diff --git a/apps/desktop/src/syncShellEnvironment.test.ts b/apps/desktop/src/syncShellEnvironment.test.ts index 817d8c09c55..a58e7bc9d70 100644 --- a/apps/desktop/src/syncShellEnvironment.test.ts +++ b/apps/desktop/src/syncShellEnvironment.test.ts @@ -1,16 +1,17 @@ import { describe, expect, it, vi } from "vitest"; -import { syncShellEnvironment } from "./syncShellEnvironment"; +import { syncShellEnvironment } from "./syncShellEnvironment.ts"; describe("syncShellEnvironment", () => { it("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on macOS", () => { const env: NodeJS.ProcessEnv = { SHELL: "/bin/zsh", - PATH: "/usr/bin", + PATH: "/Users/test/.local/bin:/usr/bin", }; const readEnvironment = vi.fn(() => ({ PATH: "/opt/homebrew/bin:/usr/bin", SSH_AUTH_SOCK: "/tmp/secretive.sock", + HOMEBREW_PREFIX: "/opt/homebrew", })); syncShellEnvironment(env, { @@ -21,11 +22,17 @@ describe("syncShellEnvironment", () => { expect(readEnvironment).toHaveBeenCalledWith("/bin/zsh", [ "PATH", "SSH_AUTH_SOCK", + "HOMEBREW_PREFIX", + "HOMEBREW_CELLAR", + "HOMEBREW_REPOSITORY", + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", "MARCODE_JIRA_REDIRECT_URI", "MARCODE_JIRA_TOKEN_PROXY_URL", ]); - expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin"); + expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin:/Users/test/.local/bin"); expect(env.SSH_AUTH_SOCK).toBe("/tmp/secretive.sock"); + expect(env.HOMEBREW_PREFIX).toBe("/opt/homebrew"); }); it("preserves an inherited SSH_AUTH_SOCK value", () => { @@ -85,6 +92,11 @@ describe("syncShellEnvironment", () => { expect(readEnvironment).toHaveBeenCalledWith("/bin/zsh", [ "PATH", "SSH_AUTH_SOCK", + "HOMEBREW_PREFIX", + "HOMEBREW_CELLAR", + "HOMEBREW_REPOSITORY", + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", "MARCODE_JIRA_REDIRECT_URI", "MARCODE_JIRA_TOKEN_PROXY_URL", ]); @@ -92,7 +104,59 @@ describe("syncShellEnvironment", () => { expect(env.SSH_AUTH_SOCK).toBe("/tmp/secretive.sock"); }); - it("does nothing outside macOS and linux", () => { + it("falls back to launchctl PATH on macOS when shell probing does not return one", () => { + const env: NodeJS.ProcessEnv = { + SHELL: "/opt/homebrew/bin/nu", + PATH: "/usr/bin", + }; + const readEnvironment = vi + .fn() + .mockImplementationOnce(() => { + throw new Error("unknown flag"); + }) + .mockImplementationOnce(() => ({})); + const readLaunchctlPath = vi.fn(() => "/opt/homebrew/bin:/usr/bin"); + const logWarning = vi.fn(); + + syncShellEnvironment(env, { + platform: "darwin", + readEnvironment, + readLaunchctlPath, + userShell: "/bin/zsh", + logWarning, + }); + + expect(readEnvironment).toHaveBeenNthCalledWith(1, "/opt/homebrew/bin/nu", [ + "PATH", + "SSH_AUTH_SOCK", + "HOMEBREW_PREFIX", + "HOMEBREW_CELLAR", + "HOMEBREW_REPOSITORY", + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", + "MARCODE_JIRA_REDIRECT_URI", + "MARCODE_JIRA_TOKEN_PROXY_URL", + ]); + expect(readEnvironment).toHaveBeenNthCalledWith(2, "/bin/zsh", [ + "PATH", + "SSH_AUTH_SOCK", + "HOMEBREW_PREFIX", + "HOMEBREW_CELLAR", + "HOMEBREW_REPOSITORY", + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", + "MARCODE_JIRA_REDIRECT_URI", + "MARCODE_JIRA_TOKEN_PROXY_URL", + ]); + expect(readLaunchctlPath).toHaveBeenCalledTimes(1); + expect(logWarning).toHaveBeenCalledWith( + "Failed to read login shell environment from /opt/homebrew/bin/nu.", + expect.any(Error), + ); + expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin"); + }); + + it("does nothing on unsupported platforms", () => { const env: NodeJS.ProcessEnv = { SHELL: "C:/Program Files/Git/bin/bash.exe", PATH: "C:\\Windows\\System32", @@ -104,7 +168,7 @@ describe("syncShellEnvironment", () => { })); syncShellEnvironment(env, { - platform: "win32", + platform: "freebsd", readEnvironment, }); @@ -112,4 +176,122 @@ describe("syncShellEnvironment", () => { expect(env.PATH).toBe("C:\\Windows\\System32"); expect(env.SSH_AUTH_SOCK).toBe("/tmp/inherited.sock"); }); + + it("hydrates PATH on Windows by merging PowerShell PATH with inherited PATH", () => { + const env: NodeJS.ProcessEnv = { + PATH: "C:\\Windows\\System32", + APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", + LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local", + USERPROFILE: "C:\\Users\\testuser", + }; + const readWindowsEnvironment = vi.fn(() => ({ + PATH: "C:\\Custom\\Bin;C:\\Windows\\System32", + })); + const isWindowsCommandAvailable = vi.fn(() => true); + + syncShellEnvironment(env, { + platform: "win32", + readWindowsEnvironment, + isWindowsCommandAvailable, + }); + + expect(readWindowsEnvironment).toHaveBeenCalledWith(["PATH"], { loadProfile: false }); + expect(env.PATH).toBe( + [ + "C:\\Users\\testuser\\AppData\\Roaming\\npm", + "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", + "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", + "C:\\Users\\testuser\\AppData\\Local\\pnpm", + "C:\\Users\\testuser\\.bun\\bin", + "C:\\Users\\testuser\\scoop\\shims", + "C:\\Custom\\Bin", + "C:\\Windows\\System32", + ].join(";"), + ); + expect(isWindowsCommandAvailable).toHaveBeenCalledTimes(1); + }); + + it("loads the PowerShell profile on Windows when node is not available", () => { + const env: NodeJS.ProcessEnv = { + PATH: "C:\\Windows\\System32", + APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", + LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local", + USERPROFILE: "C:\\Users\\testuser", + }; + const readWindowsEnvironment = vi.fn( + (_names: ReadonlyArray, options?: { loadProfile?: boolean }) => + options?.loadProfile + ? { + PATH: "C:\\Profile\\Node;C:\\Windows\\System32", + FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm", + FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123", + } + : { PATH: "C:\\Custom\\Bin;C:\\Windows\\System32" }, + ); + const isWindowsCommandAvailable = vi.fn().mockReturnValueOnce(false).mockReturnValueOnce(true); + + syncShellEnvironment(env, { + platform: "win32", + readWindowsEnvironment, + isWindowsCommandAvailable, + }); + + expect(env.PATH).toBe( + [ + "C:\\Profile\\Node", + "C:\\Windows\\System32", + "C:\\Users\\testuser\\AppData\\Roaming\\npm", + "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", + "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", + "C:\\Users\\testuser\\AppData\\Local\\pnpm", + "C:\\Users\\testuser\\.bun\\bin", + "C:\\Users\\testuser\\scoop\\shims", + "C:\\Custom\\Bin", + ].join(";"), + ); + expect(env.FNM_DIR).toBe("C:\\Users\\testuser\\AppData\\Roaming\\fnm"); + expect(env.FNM_MULTISHELL_PATH).toBe( + "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123", + ); + expect(readWindowsEnvironment).toHaveBeenNthCalledWith(1, ["PATH"], { loadProfile: false }); + expect(readWindowsEnvironment).toHaveBeenNthCalledWith( + 2, + ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"], + { loadProfile: true }, + ); + }); + + it("preserves baseline Windows env when the profile probe fails", () => { + const env: NodeJS.ProcessEnv = { + PATH: "C:\\Windows\\System32", + APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", + USERPROFILE: "C:\\Users\\testuser", + }; + const readWindowsEnvironment = vi.fn( + (_names: ReadonlyArray, options?: { loadProfile?: boolean }) => { + if (options?.loadProfile) { + throw new Error("profile load failed"); + } + return { PATH: "C:\\Custom\\Bin;C:\\Windows\\System32" }; + }, + ); + const isWindowsCommandAvailable = vi.fn(() => false); + + syncShellEnvironment(env, { + platform: "win32", + readWindowsEnvironment, + isWindowsCommandAvailable, + }); + + expect(env.PATH).toBe( + [ + "C:\\Users\\testuser\\AppData\\Roaming\\npm", + "C:\\Users\\testuser\\.bun\\bin", + "C:\\Users\\testuser\\scoop\\shims", + "C:\\Custom\\Bin", + "C:\\Windows\\System32", + ].join(";"), + ); + expect(env.SSH_AUTH_SOCK).toBeUndefined(); + }); }); diff --git a/apps/desktop/src/syncShellEnvironment.ts b/apps/desktop/src/syncShellEnvironment.ts index 2af3d5beef3..d92f9fa8352 100644 --- a/apps/desktop/src/syncShellEnvironment.ts +++ b/apps/desktop/src/syncShellEnvironment.ts @@ -1,45 +1,113 @@ import { + listLoginShellCandidates, + mergePathEntries, + readPathFromLaunchctl, readEnvironmentFromLoginShell, - resolveLoginShell, + resolveWindowsEnvironment, +} from "@marcode/shared/shell"; +import type { + CommandAvailabilityOptions, ShellEnvironmentReader, + WindowsShellEnvironmentReader, } from "@marcode/shared/shell"; +type WindowsCommandAvailabilityChecker = ( + command: string, + options?: CommandAvailabilityOptions, +) => boolean; + const JIRA_ENV_VARS = ["MARCODE_JIRA_REDIRECT_URI", "MARCODE_JIRA_TOKEN_PROXY_URL"] as const; +const LOGIN_SHELL_ENV_NAMES = [ + "PATH", + "SSH_AUTH_SOCK", + "HOMEBREW_PREFIX", + "HOMEBREW_CELLAR", + "HOMEBREW_REPOSITORY", + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", + ...JIRA_ENV_VARS, +] as const; + +function logShellEnvironmentWarning(message: string, error?: unknown): void { + console.warn(`[desktop] ${message}`, error instanceof Error ? error.message : (error ?? "")); +} + export function syncShellEnvironment( env: NodeJS.ProcessEnv = process.env, options: { platform?: NodeJS.Platform; readEnvironment?: ShellEnvironmentReader; + readWindowsEnvironment?: WindowsShellEnvironmentReader; + isWindowsCommandAvailable?: WindowsCommandAvailabilityChecker; + readLaunchctlPath?: typeof readPathFromLaunchctl; + userShell?: string; + logWarning?: (message: string, error?: unknown) => void; } = {}, ): void { const platform = options.platform ?? process.platform; - if (platform !== "darwin" && platform !== "linux") return; + + const logWarning = options.logWarning ?? logShellEnvironmentWarning; + const readEnvironment = options.readEnvironment ?? readEnvironmentFromLoginShell; + const shellEnvironment: Partial> = {}; try { - const shell = resolveLoginShell(platform, env.SHELL); - if (!shell) return; + if (platform === "win32") { + const repairedEnvironment = resolveWindowsEnvironment(env, { + ...(options.readWindowsEnvironment + ? { readEnvironment: options.readWindowsEnvironment } + : {}), + ...(options.isWindowsCommandAvailable + ? { commandAvailable: options.isWindowsCommandAvailable } + : {}), + }); + for (const [key, value] of Object.entries(repairedEnvironment)) { + if (value !== undefined) { + env[key] = value; + } + } + return; + } - const shellEnvironment = (options.readEnvironment ?? readEnvironmentFromLoginShell)(shell, [ - "PATH", - "SSH_AUTH_SOCK", - ...JIRA_ENV_VARS, - ]); + if (platform !== "darwin" && platform !== "linux") return; + + for (const shell of listLoginShellCandidates(platform, env.SHELL, options.userShell)) { + try { + Object.assign(shellEnvironment, readEnvironment(shell, LOGIN_SHELL_ENV_NAMES)); + if (shellEnvironment.PATH) { + break; + } + } catch (error) { + logWarning(`Failed to read login shell environment from ${shell}.`, error); + } + } - if (shellEnvironment.PATH) { - env.PATH = shellEnvironment.PATH; + const launchctlPath = + platform === "darwin" && !shellEnvironment.PATH + ? (options.readLaunchctlPath ?? readPathFromLaunchctl)() + : undefined; + const mergedPath = mergePathEntries(shellEnvironment.PATH ?? launchctlPath, env.PATH, platform); + if (mergedPath) { + env.PATH = mergedPath; } if (!env.SSH_AUTH_SOCK && shellEnvironment.SSH_AUTH_SOCK) { env.SSH_AUTH_SOCK = shellEnvironment.SSH_AUTH_SOCK; } - for (const name of JIRA_ENV_VARS) { + for (const name of [ + "HOMEBREW_PREFIX", + "HOMEBREW_CELLAR", + "HOMEBREW_REPOSITORY", + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", + ...JIRA_ENV_VARS, + ] as const) { if (!env[name] && shellEnvironment[name]) { env[name] = shellEnvironment[name]; } } - } catch { - // Keep inherited environment if shell lookup fails. + } catch (error) { + logWarning("Failed to synchronize the desktop shell environment.", error); } } diff --git a/apps/desktop/src/updateChannels.test.ts b/apps/desktop/src/updateChannels.test.ts new file mode 100644 index 00000000000..f815fbd81cc --- /dev/null +++ b/apps/desktop/src/updateChannels.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; + +import { + doesVersionMatchDesktopUpdateChannel, + isNightlyDesktopVersion, + resolveDefaultDesktopUpdateChannel, +} from "./updateChannels.ts"; + +describe("isNightlyDesktopVersion", () => { + it("detects packaged nightly versions", () => { + expect(isNightlyDesktopVersion("0.0.17-nightly.20260415.1")).toBe(true); + }); + + it("does not flag stable versions as nightly", () => { + expect(isNightlyDesktopVersion("0.0.17")).toBe(false); + }); +}); + +describe("resolveDefaultDesktopUpdateChannel", () => { + it("defaults stable builds to latest", () => { + expect(resolveDefaultDesktopUpdateChannel("0.0.17")).toBe("latest"); + }); + + it("defaults nightly builds to nightly", () => { + expect(resolveDefaultDesktopUpdateChannel("0.0.17-nightly.20260415.1")).toBe("nightly"); + }); +}); + +describe("doesVersionMatchDesktopUpdateChannel", () => { + it("accepts nightly releases on the nightly channel", () => { + expect(doesVersionMatchDesktopUpdateChannel("0.0.17-nightly.20260416.1", "nightly")).toBe(true); + }); + + it("rejects stable releases on the nightly channel", () => { + expect(doesVersionMatchDesktopUpdateChannel("0.0.17", "nightly")).toBe(false); + }); + + it("rejects nightly releases on the stable channel", () => { + expect(doesVersionMatchDesktopUpdateChannel("0.0.17-nightly.20260416.1", "latest")).toBe(false); + }); +}); diff --git a/apps/desktop/src/updateChannels.ts b/apps/desktop/src/updateChannels.ts new file mode 100644 index 00000000000..9e579433661 --- /dev/null +++ b/apps/desktop/src/updateChannels.ts @@ -0,0 +1,18 @@ +import type { DesktopUpdateChannel } from "@marcode/contracts"; + +const NIGHTLY_VERSION_PATTERN = /-nightly\.\d{8}\.\d+$/; + +export function isNightlyDesktopVersion(version: string): boolean { + return NIGHTLY_VERSION_PATTERN.test(version); +} + +export function resolveDefaultDesktopUpdateChannel(appVersion: string): DesktopUpdateChannel { + return isNightlyDesktopVersion(appVersion) ? "nightly" : "latest"; +} + +export function doesVersionMatchDesktopUpdateChannel( + version: string, + channel: DesktopUpdateChannel, +): boolean { + return resolveDefaultDesktopUpdateChannel(version) === channel; +} diff --git a/apps/desktop/src/updateMachine.test.ts b/apps/desktop/src/updateMachine.test.ts index 7fbc982eff8..e2f0519d350 100644 --- a/apps/desktop/src/updateMachine.test.ts +++ b/apps/desktop/src/updateMachine.test.ts @@ -11,7 +11,7 @@ import { reduceDesktopUpdateStateOnInstallFailure, reduceDesktopUpdateStateOnNoUpdate, reduceDesktopUpdateStateOnUpdateAvailable, -} from "./updateMachine"; +} from "./updateMachine.ts"; const runtimeInfo = { hostArch: "x64", @@ -23,7 +23,7 @@ describe("updateMachine", () => { it("clears transient errors when a check starts", () => { const state = reduceDesktopUpdateStateOnCheckStart( { - ...createInitialDesktopUpdateState("1.0.0", runtimeInfo), + ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"), enabled: true, status: "error", message: "network", @@ -42,7 +42,7 @@ describe("updateMachine", () => { it("records a check failure without exposing an action", () => { const state = reduceDesktopUpdateStateOnCheckFailure( { - ...createInitialDesktopUpdateState("1.0.0", runtimeInfo), + ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"), enabled: true, status: "checking", }, @@ -58,7 +58,7 @@ describe("updateMachine", () => { it("preserves available version on download failure for retry", () => { const state = reduceDesktopUpdateStateOnDownloadFailure( { - ...createInitialDesktopUpdateState("1.0.0", runtimeInfo), + ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"), enabled: true, status: "downloading", availableVersion: "1.1.0", @@ -76,7 +76,7 @@ describe("updateMachine", () => { it("transitions to downloaded and then preserves install retry state", () => { const downloaded = reduceDesktopUpdateStateOnDownloadComplete( { - ...createInitialDesktopUpdateState("1.0.0", runtimeInfo), + ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"), enabled: true, status: "downloading", availableVersion: "1.1.0", @@ -98,7 +98,7 @@ describe("updateMachine", () => { it("clears stale download state when no update is available", () => { const state = reduceDesktopUpdateStateOnNoUpdate( { - ...createInitialDesktopUpdateState("1.0.0", runtimeInfo), + ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"), enabled: true, status: "error", availableVersion: "1.1.0", @@ -120,7 +120,7 @@ describe("updateMachine", () => { it("tracks available, download start, and progress cleanly", () => { const available = reduceDesktopUpdateStateOnUpdateAvailable( { - ...createInitialDesktopUpdateState("1.0.0", runtimeInfo), + ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"), enabled: true, status: "checking", }, @@ -131,6 +131,7 @@ describe("updateMachine", () => { const progress = reduceDesktopUpdateStateOnDownloadProgress(downloading, 55.5); expect(available.status).toBe("available"); + expect(available.channel).toBe("latest"); expect(downloading.status).toBe("downloading"); expect(downloading.downloadPercent).toBe(0); expect(progress.downloadPercent).toBe(55.5); diff --git a/apps/desktop/src/updateMachine.ts b/apps/desktop/src/updateMachine.ts index 26eb63bb360..16b12fd468e 100644 --- a/apps/desktop/src/updateMachine.ts +++ b/apps/desktop/src/updateMachine.ts @@ -1,14 +1,20 @@ -import type { DesktopRuntimeInfo, DesktopUpdateState } from "@marcode/contracts"; +import type { + DesktopRuntimeInfo, + DesktopUpdateChannel, + DesktopUpdateState, +} from "@marcode/contracts"; -import { getCanRetryAfterDownloadFailure, nextStatusAfterDownloadFailure } from "./updateState"; +import { getCanRetryAfterDownloadFailure, nextStatusAfterDownloadFailure } from "./updateState.ts"; export function createInitialDesktopUpdateState( currentVersion: string, runtimeInfo: DesktopRuntimeInfo, + channel: DesktopUpdateChannel, ): DesktopUpdateState { return { enabled: false, status: "disabled", + channel, currentVersion, hostArch: runtimeInfo.hostArch, appArch: runtimeInfo.appArch, diff --git a/apps/desktop/src/updateState.test.ts b/apps/desktop/src/updateState.test.ts index 1864d1dc04b..b80ede93dab 100644 --- a/apps/desktop/src/updateState.test.ts +++ b/apps/desktop/src/updateState.test.ts @@ -6,11 +6,12 @@ import { getAutoUpdateDisabledReason, nextStatusAfterDownloadFailure, shouldBroadcastDownloadProgress, -} from "./updateState"; +} from "./updateState.ts"; const baseState: DesktopUpdateState = { enabled: true, status: "idle", + channel: "latest", currentVersion: "1.0.0", hostArch: "x64", appArch: "x64", diff --git a/apps/desktop/src/windowState.integration-guard.test.ts b/apps/desktop/src/windowState.integration-guard.test.ts index 261398ab82f..528aa64156e 100644 --- a/apps/desktop/src/windowState.integration-guard.test.ts +++ b/apps/desktop/src/windowState.integration-guard.test.ts @@ -9,15 +9,21 @@ describe("window position persistence regression guard", () => { const mainSource = fs.readFileSync(MAIN_TS_PATH, "utf8"); it("main.ts imports readWindowState from windowState module", () => { - expect(mainSource).toMatch(/import\s+.*readWindowState.*from\s+["']\.\/windowState["']/); + expect(mainSource).toMatch( + /import\s+.*readWindowState.*from\s+["']\.\/windowState(?:\.ts)?["']/, + ); }); it("main.ts imports writeWindowState from windowState module", () => { - expect(mainSource).toMatch(/import\s+.*writeWindowState.*from\s+["']\.\/windowState["']/); + expect(mainSource).toMatch( + /import\s+.*writeWindowState.*from\s+["']\.\/windowState(?:\.ts)?["']/, + ); }); it("main.ts imports resolveWindowBounds from windowState module", () => { - expect(mainSource).toMatch(/import\s+.*resolveWindowBounds.*from\s+["']\.\/windowState["']/); + expect(mainSource).toMatch( + /import\s+.*resolveWindowBounds.*from\s+["']\.\/windowState(?:\.ts)?["']/, + ); }); it("main.ts defines WINDOW_STATE_PATH constant", () => { diff --git a/apps/desktop/src/windowState.test.ts b/apps/desktop/src/windowState.test.ts index d22ef5396ef..cbaf9be6709 100644 --- a/apps/desktop/src/windowState.test.ts +++ b/apps/desktop/src/windowState.test.ts @@ -9,8 +9,8 @@ import { readWindowState, resolveWindowBounds, writeWindowState, -} from "./windowState"; -import type { WindowState } from "./windowState"; +} from "./windowState.ts"; +import type { WindowState } from "./windowState.ts"; vi.mock("electron", () => ({ screen: { diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json index 0ca5bcaa76a..ff3e4cd0f38 100644 --- a/apps/desktop/tsconfig.json +++ b/apps/desktop/tsconfig.json @@ -3,7 +3,7 @@ "compilerOptions": { "composite": true, "types": ["node", "electron"], - "lib": ["ES2023", "DOM", "esnext.disposable"] + "lib": ["ESNext", "DOM", "esnext.disposable"] }, "include": ["src", "tsdown.config.ts"] } diff --git a/apps/desktop/tsdown.config.ts b/apps/desktop/tsdown.config.ts index 6e00fd58397..162d5901dfb 100644 --- a/apps/desktop/tsdown.config.ts +++ b/apps/desktop/tsdown.config.ts @@ -4,7 +4,7 @@ const shared = { format: "cjs" as const, outDir: "dist-electron", sourcemap: true, - outExtensions: () => ({ js: ".js" }), + outExtensions: () => ({ js: ".cjs" }), }; const EMBEDDED_JIRA_KEYS = ["MARCODE_JIRA_REDIRECT_URI", "MARCODE_JIRA_TOKEN_PROXY_URL"] as const; diff --git a/apps/landing/tsconfig.json b/apps/landing/tsconfig.json index c06d5838dfb..001a4a5aef8 100644 --- a/apps/landing/tsconfig.json +++ b/apps/landing/tsconfig.json @@ -4,6 +4,11 @@ "composite": true, "jsx": "preserve", "lib": ["ES2023", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "allowImportingTsExtensions": false, + "rewriteRelativeImportExtensions": false, + "verbatimModuleSyntax": false, "plugins": [ { "name": "next" diff --git a/apps/server/package.json b/apps/server/package.json index fd8fd3f4518..6e2409abef5 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -15,12 +15,14 @@ ], "type": "module", "scripts": { - "dev": "bun run src/bin.ts", + "dev": "node --watch src/bin.ts", "build": "node scripts/cli.ts build", + "build:bundle": "tsdown", "start": "node dist/bin.mjs", "prepare": "effect-language-service patch", "typecheck": "tsc --noEmit", - "test": "vitest run" + "test": "vitest run", + "test:process-reaper": "vitest run src/server.test.ts src/provider/Layers/ClaudeAdapter.test.ts src/provider/Layers/ProviderSessionDirectory.test.ts src/provider/Layers/ProviderSessionReaper.test.ts src/provider/Layers/CodexAdapter.test.ts" }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.111", diff --git a/apps/server/scripts/cli.ts b/apps/server/scripts/cli.ts index bf8c77557a1..bfaae5a5dd1 100644 --- a/apps/server/scripts/cli.ts +++ b/apps/server/scripts/cli.ts @@ -147,13 +147,13 @@ const buildCmd = Command.make( yield* Effect.log("[cli] Running tsdown..."); yield* runCommand( - ChildProcess.make({ + ChildProcess.make(process.execPath, ["--run", "build:bundle"], { cwd: serverDir, stdout: config.verbose ? "inherit" : "ignore", stderr: "inherit", - // Windows needs shell mode to resolve .cmd shims (e.g. bun.cmd). + // Windows needs shell mode to resolve `.cmd` shims on PATH. shell: process.platform === "win32", - })`bun tsdown`, + }), ); const webDist = path.join(repoRoot, "apps/web/dist"); @@ -203,10 +203,8 @@ const publishCmd = Command.make( } yield* Effect.acquireUseRelease( - // Acquire: backup package.json, resolve catalog: deps, strip devDependencies/scripts + // Acquire: backup package.json, resolve catalog dependencies, and strip devDependencies/scripts Effect.gen(function* () { - // Resolve catalog dependencies before any file mutations. If this throws, - // acquire fails and no release hook runs, so filesystem must still be untouched. const version = Option.getOrElse(config.appVersion, () => serverPackageJson.version); const pkg: PackageJson = { name: serverPackageJson.name, @@ -216,25 +214,22 @@ const publishCmd = Command.make( version, engines: serverPackageJson.engines, files: serverPackageJson.files, - dependencies: serverPackageJson.dependencies, - overrides: rootPackageJson.overrides, + dependencies: resolveCatalogDependencies( + serverPackageJson.dependencies, + rootPackageJson.workspaces.catalog, + "apps/server", + ), + overrides: resolveCatalogDependencies( + rootPackageJson.overrides, + rootPackageJson.workspaces.catalog, + "apps/server", + ), }; - pkg.dependencies = resolveCatalogDependencies( - pkg.dependencies, - rootPackageJson.workspaces.catalog, - "apps/server dependencies", - ); - pkg.overrides = resolveCatalogDependencies( - pkg.overrides, - rootPackageJson.workspaces.catalog, - "root overrides", - ); - const original = yield* fs.readFileString(packageJsonPath); yield* fs.writeFileString(backupPath, original); yield* fs.writeFileString(packageJsonPath, `${JSON.stringify(pkg, null, 2)}\n`); - yield* Effect.log("[cli] Resolved package.json for publish"); + yield* Effect.log("[cli] Prepared package.json for publish"); const iconBackups = yield* applyPublishIconOverrides(repoRoot, serverDir); return { iconBackups }; diff --git a/apps/server/src/auth/Layers/AuthControlPlane.test.ts b/apps/server/src/auth/Layers/AuthControlPlane.test.ts index ea461dc29b1..7fc6a4c9d5b 100644 --- a/apps/server/src/auth/Layers/AuthControlPlane.test.ts +++ b/apps/server/src/auth/Layers/AuthControlPlane.test.ts @@ -2,7 +2,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; import { Effect, Layer } from "effect"; -import { ServerConfigShape } from "../../config.ts"; +import type { ServerConfigShape } from "../../config.ts"; import { ServerConfig } from "../../config.ts"; import { BootstrapCredentialServiceLive } from "./BootstrapCredentialService.ts"; import { ServerSecretStoreLive } from "./ServerSecretStore.ts"; diff --git a/apps/server/src/auth/Layers/AuthControlPlane.ts b/apps/server/src/auth/Layers/AuthControlPlane.ts index d3202a15c3d..d3a6028e6fc 100644 --- a/apps/server/src/auth/Layers/AuthControlPlane.ts +++ b/apps/server/src/auth/Layers/AuthControlPlane.ts @@ -10,8 +10,10 @@ import { layerConfig as SqlitePersistenceLayerLive } from "../../persistence/Lay import { AuthControlPlane, AuthControlPlaneError, - AuthControlPlaneShape, DEFAULT_SESSION_SUBJECT, +} from "../Services/AuthControlPlane.ts"; +import type { + AuthControlPlaneShape, IssuedBearerSession, IssuedPairingLink, } from "../Services/AuthControlPlane.ts"; diff --git a/apps/server/src/auth/Services/AuthControlPlane.ts b/apps/server/src/auth/Services/AuthControlPlane.ts index 7cf04a69e4a..cdc6bdbd818 100644 --- a/apps/server/src/auth/Services/AuthControlPlane.ts +++ b/apps/server/src/auth/Services/AuthControlPlane.ts @@ -5,7 +5,7 @@ import type { AuthSessionId, } from "@marcode/contracts"; import { Data, DateTime, Duration, Effect, Context } from "effect"; -import { SessionRole } from "./SessionCredentialService"; +import type { SessionRole } from "./SessionCredentialService.ts"; export const DEFAULT_SESSION_SUBJECT = "cli-issued-session"; diff --git a/apps/server/src/auth/utils.test.ts b/apps/server/src/auth/utils.test.ts index ec1881fc557..5ee8a6cbd8a 100644 --- a/apps/server/src/auth/utils.test.ts +++ b/apps/server/src/auth/utils.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { deriveAuthClientMetadata } from "./utils"; +import { deriveAuthClientMetadata } from "./utils.ts"; describe("deriveAuthClientMetadata", () => { it("labels Electron user agents as Electron instead of Chrome", () => { diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index dde54fc4a05..72311504500 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -5,12 +5,12 @@ import * as Layer from "effect/Layer"; import { Command } from "effect/unstable/cli"; import { NetService } from "@marcode/shared/Net"; -import { cli } from "./cli"; -import { version } from "../package.json" with { type: "json" }; +import { cli } from "./cli.ts"; +import packageJson from "../package.json" with { type: "json" }; const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); -Command.run(cli, { version }).pipe( +Command.run(cli, { version: packageJson.version }).pipe( Effect.scoped, Effect.provide(CliRuntimeLayer), (effect) => effect as Effect.Effect, diff --git a/apps/server/src/bootstrap.test.ts b/apps/server/src/bootstrap.test.ts index 94c7504347e..11d50ff2c28 100644 --- a/apps/server/src/bootstrap.test.ts +++ b/apps/server/src/bootstrap.test.ts @@ -10,7 +10,7 @@ import * as Fiber from "effect/Fiber"; import { TestClock } from "effect/testing"; import { vi } from "vitest"; -import { readBootstrapEnvelope, resolveFdPath } from "./bootstrap"; +import { readBootstrapEnvelope, resolveFdPath } from "./bootstrap.ts"; import { assertNone, assertSome } from "@effect/vitest/utils"; const openSyncInterceptor = vi.hoisted(() => ({ failPath: null as string | null })); diff --git a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts index a9f73bdec57..d1878d13004 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts @@ -85,10 +85,15 @@ describe("CheckpointDiffQueryLive", () => { getListingSnapshot: () => Effect.die("CheckpointDiffQuery should not request the listing snapshot"), getThread: () => Effect.die("CheckpointDiffQuery should not request a single thread"), + getShellSnapshot: () => + Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), + getThreadShellById: () => Effect.succeed(Option.none()), + getThreadDetailById: () => Effect.succeed(Option.none()), }), ), ); @@ -142,10 +147,15 @@ describe("CheckpointDiffQueryLive", () => { getListingSnapshot: () => Effect.die("CheckpointDiffQuery should not request the listing snapshot"), getThread: () => Effect.die("CheckpointDiffQuery should not request a single thread"), + getShellSnapshot: () => + Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), getThreadCheckpointContext: () => Effect.succeed(Option.none()), + getThreadShellById: () => Effect.succeed(Option.none()), + getThreadDetailById: () => Effect.succeed(Option.none()), }), ), ); diff --git a/apps/server/src/cli-config.test.ts b/apps/server/src/cli-config.test.ts index c31d825d85f..3bde6e981a0 100644 --- a/apps/server/src/cli-config.test.ts +++ b/apps/server/src/cli-config.test.ts @@ -5,8 +5,8 @@ import { ConfigProvider, Effect, FileSystem, Layer, Option, Path } from "effect" import { NetService } from "@marcode/shared/Net"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { deriveServerPaths } from "./config"; -import { resolveServerConfig } from "./cli"; +import { deriveServerPaths } from "./config.ts"; +import { resolveServerConfig } from "./cli.ts"; it.layer(NodeServices.layer)("cli config resolution", (it) => { const defaultObservabilityConfig = { diff --git a/apps/server/src/cli.ts b/apps/server/src/cli.ts index 24685c67362..dd0fcdd4247 100644 --- a/apps/server/src/cli.ts +++ b/apps/server/src/cli.ts @@ -40,30 +40,31 @@ import { RuntimeMode, type ServerConfigShape, type StartupPresentation, -} from "./config"; -import { readBootstrapEnvelope } from "./bootstrap"; -import { expandHomePath, resolveBaseDir } from "./os-jank"; -import { runServer } from "./server"; +} from "./config.ts"; +import { readBootstrapEnvelope } from "./bootstrap.ts"; +import { expandHomePath, resolveBaseDir } from "./os-jank.ts"; +import { runServer } from "./server.ts"; import { AuthControlPlaneRuntimeLive } from "./auth/Layers/AuthControlPlane.ts"; import { formatIssuedPairingCredential, formatIssuedSession, formatPairingCredentialList, formatSessionList, -} from "./cliAuthFormat"; -import { AuthControlPlane, AuthControlPlaneShape } from "./auth/Services/AuthControlPlane.ts"; +} from "./cliAuthFormat.ts"; +import { AuthControlPlane } from "./auth/Services/AuthControlPlane.ts"; +import type { AuthControlPlaneShape } from "./auth/Services/AuthControlPlane.ts"; import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine.ts"; import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; -import { OrchestrationLayerLive } from "./orchestration/runtimeLayer"; +import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts"; import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver.ts"; -import { getAutoBootstrapDefaultModelSelection } from "./serverRuntimeStartup"; +import { getAutoBootstrapDefaultModelSelection } from "./serverRuntimeStartup.ts"; import { clearPersistedServerRuntimeState, readPersistedServerRuntimeState, -} from "./serverRuntimeState"; -import { WorkspacePaths } from "./workspace/Services/WorkspacePaths"; -import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths"; +} from "./serverRuntimeState.ts"; +import { WorkspacePaths } from "./workspace/Services/WorkspacePaths.ts"; +import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })); diff --git a/apps/server/src/codexAppServerManager.test.ts b/apps/server/src/codexAppServerManager.test.ts index 9a4fbc6e16f..b03556f3233 100644 --- a/apps/server/src/codexAppServerManager.test.ts +++ b/apps/server/src/codexAppServerManager.test.ts @@ -15,7 +15,7 @@ import { normalizeCodexModelSlug, readCodexAccountSnapshot, resolveCodexModelForAccount, -} from "./codexAppServerManager"; +} from "./codexAppServerManager.ts"; const asThreadId = (value: string): ThreadId => ThreadId.make(value); @@ -470,6 +470,154 @@ describe("startSession", () => { manager.stopAll(); } }); + + it("disposes an existing session before starting a replacement for the same thread", async () => { + const manager = new CodexAppServerManager(); + const existingContext = { + session: { + provider: "codex", + status: "ready", + threadId: asThreadId("thread-1"), + runtimeMode: "full-access", + createdAt: "2026-02-10T00:00:00.000Z", + updatedAt: "2026-02-10T00:00:00.000Z", + }, + }; + + ( + manager as unknown as { + sessions: Map; + } + ).sessions.set(asThreadId("thread-1"), existingContext); + + const disposeSession = vi + .spyOn( + manager as unknown as { + disposeSession: ( + context: typeof existingContext, + options?: { readonly emitLifecycleEvent?: boolean }, + ) => void; + }, + "disposeSession", + ) + .mockImplementation(() => {}); + const assertSupportedCodexCliVersion = vi + .spyOn( + manager as unknown as { + assertSupportedCodexCliVersion: (input: { + binaryPath: string; + cwd: string; + homePath?: string; + }) => void; + }, + "assertSupportedCodexCliVersion", + ) + .mockImplementation(() => {}); + const processCwd = vi.spyOn(process, "cwd").mockImplementation(() => { + throw new Error("cwd missing"); + }); + + try { + await expect( + manager.startSession({ + threadId: asThreadId("thread-1"), + provider: "codex", + binaryPath: "codex", + runtimeMode: "full-access", + }), + ).rejects.toThrow("cwd missing"); + + expect(disposeSession).toHaveBeenCalledWith(existingContext, { + emitLifecycleEvent: false, + }); + expect(assertSupportedCodexCliVersion).not.toHaveBeenCalled(); + } finally { + disposeSession.mockRestore(); + assertSupportedCodexCliVersion.mockRestore(); + processCwd.mockRestore(); + ( + manager as unknown as { + sessions: Map; + } + ).sessions.clear(); + manager.stopAll(); + } + }); + + it("continues replacement start when existing session disposal fails", async () => { + const manager = new CodexAppServerManager(); + const existingContext = { + session: { + provider: "codex", + status: "ready", + threadId: asThreadId("thread-1"), + runtimeMode: "full-access", + createdAt: "2026-02-10T00:00:00.000Z", + updatedAt: "2026-02-10T00:00:00.000Z", + }, + }; + + ( + manager as unknown as { + sessions: Map; + } + ).sessions.set(asThreadId("thread-1"), existingContext); + + const disposeSession = vi + .spyOn( + manager as unknown as { + disposeSession: ( + context: typeof existingContext, + options?: { readonly emitLifecycleEvent?: boolean }, + ) => void; + }, + "disposeSession", + ) + .mockImplementation(() => { + throw new Error("dispose failed"); + }); + const assertSupportedCodexCliVersion = vi + .spyOn( + manager as unknown as { + assertSupportedCodexCliVersion: (input: { + binaryPath: string; + cwd: string; + homePath?: string; + }) => void; + }, + "assertSupportedCodexCliVersion", + ) + .mockImplementation(() => {}); + const processCwd = vi.spyOn(process, "cwd").mockImplementation(() => { + throw new Error("cwd missing"); + }); + + try { + await expect( + manager.startSession({ + threadId: asThreadId("thread-1"), + provider: "codex", + binaryPath: "codex", + runtimeMode: "full-access", + }), + ).rejects.toThrow("cwd missing"); + + expect(disposeSession).toHaveBeenCalledWith(existingContext, { + emitLifecycleEvent: false, + }); + expect(assertSupportedCodexCliVersion).not.toHaveBeenCalled(); + } finally { + disposeSession.mockRestore(); + assertSupportedCodexCliVersion.mockRestore(); + processCwd.mockRestore(); + ( + manager as unknown as { + sessions: Map; + } + ).sessions.clear(); + manager.stopAll(); + } + }); }); describe("sendTurn", () => { diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts index 262bce08d60..db21df6235b 100644 --- a/apps/server/src/codexAppServerManager.ts +++ b/apps/server/src/codexAppServerManager.ts @@ -25,16 +25,16 @@ import { formatCodexCliUpgradeMessage, isCodexCliVersionSupported, parseCodexCliVersion, -} from "./provider/codexCliVersion"; +} from "./provider/codexCliVersion.ts"; import { readCodexAccountSnapshot, resolveCodexModelForAccount, type CodexAccountSnapshot, -} from "./provider/codexAccount"; -import { buildCodexInitializeParams, killCodexChildProcess } from "./provider/codexAppServer"; +} from "./provider/codexAccount.ts"; +import { buildCodexInitializeParams, killCodexChildProcess } from "./provider/codexAppServer.ts"; -export { buildCodexInitializeParams } from "./provider/codexAppServer"; -export { readCodexAccountSnapshot, resolveCodexModelForAccount } from "./provider/codexAccount"; +export { buildCodexInitializeParams } from "./provider/codexAppServer.ts"; +export { readCodexAccountSnapshot, resolveCodexModelForAccount } from "./provider/codexAccount.ts"; type PendingRequestKey = string; @@ -450,6 +450,25 @@ export class CodexAppServerManager extends EventEmitter ({ runProcess: vi.fn(), })); -import { runProcess } from "../../processRunner"; +import { runProcess } from "../../processRunner.ts"; import { GitHubCli } from "../Services/GitHubCli.ts"; import { GitHubCliLive } from "./GitHubCli.ts"; diff --git a/apps/server/src/git/Layers/GitHubCli.ts b/apps/server/src/git/Layers/GitHubCli.ts index be2d2be751b..f78e97bf953 100644 --- a/apps/server/src/git/Layers/GitHubCli.ts +++ b/apps/server/src/git/Layers/GitHubCli.ts @@ -5,7 +5,7 @@ import { join } from "node:path"; import { Effect, Layer, Schema, SchemaIssue } from "effect"; import { PositiveInt, TrimmedNonEmptyString } from "@marcode/contracts"; -import { runProcess } from "../../processRunner"; +import { runProcess } from "../../processRunner.ts"; import { GitHostCliError } from "@marcode/contracts"; import { GitHubCli, diff --git a/apps/server/src/git/Layers/GitLabCli.ts b/apps/server/src/git/Layers/GitLabCli.ts index 406ff4e037d..f69a212e987 100644 --- a/apps/server/src/git/Layers/GitLabCli.ts +++ b/apps/server/src/git/Layers/GitLabCli.ts @@ -6,7 +6,7 @@ import { Effect, Schema } from "effect"; import { PositiveInt, TrimmedNonEmptyString } from "@marcode/contracts"; -import { runProcess } from "../../processRunner"; +import { runProcess } from "../../processRunner.ts"; import { GitHostCliError } from "@marcode/contracts"; import type { GitHostCliShape, diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index 16d41d30b83..cf3d68150c2 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -29,7 +29,7 @@ import { type GitManagerShape, type GitRunStackedActionOptions, } from "../Services/GitManager.ts"; -import { GitCore, GitStatusDetails } from "../Services/GitCore.ts"; +import { GitCore, type GitStatusDetails } from "../Services/GitCore.ts"; import { GitHostCli } from "../Services/GitHostCli.ts"; import { TextGeneration } from "../Services/TextGeneration.ts"; import { ProjectSetupScriptRunner } from "../../project/Services/ProjectSetupScriptRunner.ts"; diff --git a/apps/server/src/git/Layers/RoutingGitHostCli.test.ts b/apps/server/src/git/Layers/RoutingGitHostCli.test.ts index b683cda66b9..988aa68a367 100644 --- a/apps/server/src/git/Layers/RoutingGitHostCli.test.ts +++ b/apps/server/src/git/Layers/RoutingGitHostCli.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { parseHostnameFromRemoteUrl, providerFromHostname } from "./RoutingGitHostCli"; +import { parseHostnameFromRemoteUrl, providerFromHostname } from "./RoutingGitHostCli.ts"; describe("parseHostnameFromRemoteUrl", () => { it("extracts hostname from SSH URL", () => { diff --git a/apps/server/src/git/Layers/RoutingGitHostCli.ts b/apps/server/src/git/Layers/RoutingGitHostCli.ts index c3004d2af42..6d3d2a78fec 100644 --- a/apps/server/src/git/Layers/RoutingGitHostCli.ts +++ b/apps/server/src/git/Layers/RoutingGitHostCli.ts @@ -10,7 +10,7 @@ import { Context, Effect, Layer } from "effect"; import { GitHostCliError } from "@marcode/contracts"; -import { runProcess } from "../../processRunner"; +import { runProcess } from "../../processRunner.ts"; import { GitCore } from "../Services/GitCore.ts"; import { GitHubCli } from "../Services/GitHubCli.ts"; import { GitHostCli, type GitHostCliShape, type GitHostProvider } from "../Services/GitHostCli.ts"; diff --git a/apps/server/src/git/Services/GitHubCli.ts b/apps/server/src/git/Services/GitHubCli.ts index 19b86370c17..f5fac83b402 100644 --- a/apps/server/src/git/Services/GitHubCli.ts +++ b/apps/server/src/git/Services/GitHubCli.ts @@ -8,7 +8,7 @@ import { Context } from "effect"; import type { Effect } from "effect"; -import type { ProcessRunResult } from "../../processRunner"; +import type { ProcessRunResult } from "../../processRunner.ts"; import type { GitHubCliError } from "@marcode/contracts"; export interface GitHubPullRequestSummary { diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index d572450b855..7b29ee6bb09 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -15,12 +15,12 @@ import { ATTACHMENTS_ROUTE_PREFIX, normalizeAttachmentRelativePath, resolveAttachmentRelativePath, -} from "./attachmentPaths"; -import { resolveAttachmentPathById } from "./attachmentStore"; -import { resolveStaticDir, ServerConfig } from "./config"; +} from "./attachmentPaths.ts"; +import { resolveAttachmentPathById } from "./attachmentStore.ts"; +import { resolveStaticDir, ServerConfig } from "./config.ts"; import { decodeOtlpTraceRecords } from "./observability/TraceRecord.ts"; import { BrowserTraceCollector } from "./observability/Services/BrowserTraceCollector.ts"; -import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver"; +import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver.ts"; import { ServerAuth } from "./auth/Services/ServerAuth.ts"; import { respondToAuthError } from "./auth/http.ts"; import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts"; @@ -226,6 +226,7 @@ export const staticAndDevRouteLayer = HttpRouter.add( Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest; const url = HttpServerRequest.toURL(request); + if (Option.isNone(url)) { return HttpServerResponse.text("Bad Request", { status: 400 }); } diff --git a/apps/server/src/jira/Layers/JiraApiClient.ts b/apps/server/src/jira/Layers/JiraApiClient.ts index 86e847ee33e..9d6e74f38c1 100644 --- a/apps/server/src/jira/Layers/JiraApiClient.ts +++ b/apps/server/src/jira/Layers/JiraApiClient.ts @@ -1,5 +1,5 @@ import { Effect, Layer, Option } from "effect"; -import { ServerConfig } from "../../config"; +import { ServerConfig } from "../../config.ts"; import type { JiraConnectionStatus, JiraGetAttachmentResult, @@ -10,9 +10,9 @@ import type { JiraSite, JiraUser, } from "@marcode/contracts"; -import { JiraApiClient, type JiraApiClientShape } from "../Services/JiraApiClient"; -import { JiraTokenService } from "../Services/JiraTokenService"; -import { JiraApiError } from "../Errors"; +import { JiraApiClient, type JiraApiClientShape } from "../Services/JiraApiClient.ts"; +import { JiraTokenService } from "../Services/JiraTokenService.ts"; +import { JiraApiError } from "../Errors.ts"; const ATLASSIAN_API_BASE = "https://api.atlassian.com"; diff --git a/apps/server/src/jira/Layers/JiraTokenService.ts b/apps/server/src/jira/Layers/JiraTokenService.ts index 3991d3ff14b..24cce877135 100644 --- a/apps/server/src/jira/Layers/JiraTokenService.ts +++ b/apps/server/src/jira/Layers/JiraTokenService.ts @@ -1,12 +1,12 @@ import { Effect, FileSystem, Layer, Option, PubSub, Semaphore, Stream } from "effect"; -import { ServerConfig } from "../../config"; -import { JiraTokenError } from "../Errors"; +import { ServerConfig } from "../../config.ts"; +import { JiraTokenError } from "../Errors.ts"; import { JiraTokenService, type JiraTokenServiceShape, type JiraTokenSet, -} from "../Services/JiraTokenService"; -import { decryptTokens, deriveKey, encryptTokens } from "../crypto"; +} from "../Services/JiraTokenService.ts"; +import { decryptTokens, deriveKey, encryptTokens } from "../crypto.ts"; const REFRESH_BUFFER_MS = 5 * 60 * 1000; diff --git a/apps/server/src/jira/Services/JiraApiClient.ts b/apps/server/src/jira/Services/JiraApiClient.ts index 72cc3b2df22..3e48dc23524 100644 --- a/apps/server/src/jira/Services/JiraApiClient.ts +++ b/apps/server/src/jira/Services/JiraApiClient.ts @@ -13,7 +13,7 @@ import type { JiraListSprintsResult, JiraSite, } from "@marcode/contracts"; -import { JiraApiError, JiraTokenError } from "../Errors"; +import { JiraApiError, JiraTokenError } from "../Errors.ts"; export interface JiraApiClientShape { readonly getConnectionStatus: Effect.Effect; diff --git a/apps/server/src/jira/Services/JiraTokenService.ts b/apps/server/src/jira/Services/JiraTokenService.ts index 0f6f103a164..d1ba4835fa6 100644 --- a/apps/server/src/jira/Services/JiraTokenService.ts +++ b/apps/server/src/jira/Services/JiraTokenService.ts @@ -1,5 +1,5 @@ import { Context, Effect, Option, Stream } from "effect"; -import { JiraTokenError } from "../Errors"; +import { JiraTokenError } from "../Errors.ts"; export interface JiraTokenSet { readonly accessToken: string; diff --git a/apps/server/src/jira/oauthRoutes.ts b/apps/server/src/jira/oauthRoutes.ts index 845337497a4..7c337c9ff44 100644 --- a/apps/server/src/jira/oauthRoutes.ts +++ b/apps/server/src/jira/oauthRoutes.ts @@ -3,11 +3,11 @@ import * as http from "node:http"; import { Effect, Option } from "effect"; import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; -import { ServerConfig, type ServerConfigShape } from "../config"; -import { JiraTokenService } from "./Services/JiraTokenService"; -import type { JiraTokenSet } from "./Services/JiraTokenService"; -import { JiraApiClient } from "./Services/JiraApiClient"; -import { JiraOAuthError, JiraTokenError, JiraApiError } from "./Errors"; +import { ServerConfig, type ServerConfigShape } from "../config.ts"; +import { JiraTokenService } from "./Services/JiraTokenService.ts"; +import type { JiraTokenSet } from "./Services/JiraTokenService.ts"; +import { JiraApiClient } from "./Services/JiraApiClient.ts"; +import { JiraOAuthError, JiraTokenError, JiraApiError } from "./Errors.ts"; const ATLASSIAN_AUTHORIZE_URL = "https://auth.atlassian.com/authorize"; diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 22c8492fb48..ac1ef644d7e 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -3,7 +3,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { assertFailure } from "@effect/vitest/utils"; import { Cause, Effect, FileSystem, Layer, Logger, Path, Schema } from "effect"; -import { ServerConfig } from "./config"; +import { ServerConfig } from "./config.ts"; import { DEFAULT_KEYBINDINGS, @@ -13,7 +13,7 @@ import { compileResolvedKeybindingRule, compileResolvedKeybindingsConfig, parseKeybindingShortcut, -} from "./keybindings"; +} from "./keybindings.ts"; import { KeybindingsConfigError } from "@marcode/contracts"; const KeybindingsConfigJson = Schema.fromJsonString(KeybindingsConfig); diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index f43a6ea338b..9d1084571cf 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -19,7 +19,7 @@ import { THREAD_JUMP_KEYBINDING_COMMANDS, type ServerConfigIssue, } from "@marcode/contracts"; -import { Mutable } from "effect/Types"; +import type { Mutable } from "effect/Types"; import { Array, Cache, @@ -44,7 +44,7 @@ import { Stream, } from "effect"; import * as Semaphore from "effect/Semaphore"; -import { ServerConfig } from "./config"; +import { ServerConfig } from "./config.ts"; import { fromLenientJson } from "@marcode/shared/schemaJson"; type WhenToken = diff --git a/apps/server/src/observability/LocalFileTracer.ts b/apps/server/src/observability/LocalFileTracer.ts index cde5a176e88..a3d43ea118c 100644 --- a/apps/server/src/observability/LocalFileTracer.ts +++ b/apps/server/src/observability/LocalFileTracer.ts @@ -1,7 +1,8 @@ import type * as Exit from "effect/Exit"; import { Effect, Option, Tracer } from "effect"; -import { EffectTraceRecord, spanToTraceRecord } from "./TraceRecord.ts"; +import { spanToTraceRecord } from "./TraceRecord.ts"; +import type { EffectTraceRecord } from "./TraceRecord.ts"; import { makeTraceSink, type TraceSink } from "./TraceSink.ts"; export interface LocalFileTracerOptions { @@ -27,12 +28,16 @@ class LocalFileSpan implements Tracer.Span { status: Tracer.SpanStatus; attributes: Map; events: Array<[name: string, startTime: bigint, attributes: Record]>; + private readonly delegate: Tracer.Span; + private readonly push: (record: EffectTraceRecord) => void; constructor( options: Parameters[0], - private readonly delegate: Tracer.Span, - private readonly push: (record: EffectTraceRecord) => void, + delegate: Tracer.Span, + push: (record: EffectTraceRecord) => void, ) { + this.delegate = delegate; + this.push = push; this.name = delegate.name; this.spanId = delegate.spanId; this.traceId = delegate.traceId; diff --git a/apps/server/src/open.test.ts b/apps/server/src/open.test.ts index 92b8be256eb..77c58440cb7 100644 --- a/apps/server/src/open.test.ts +++ b/apps/server/src/open.test.ts @@ -8,7 +8,7 @@ import { launchDetached, resolveAvailableEditors, resolveEditorLaunch, -} from "./open"; +} from "./open.ts"; it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { it.effect("returns commands for command-based editors", () => @@ -42,6 +42,16 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { args: ["/tmp/workspace"], }); + const kiroLaunch = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace", editor: "kiro" }, + "darwin", + { PATH: "" }, + ); + assert.deepEqual(kiroLaunch, { + command: "kiro", + args: ["ide", "/tmp/workspace"], + }); + const vscodeLaunch = yield* resolveEditorLaunch( { cwd: "/tmp/workspace", editor: "vscode" }, "darwin", @@ -122,6 +132,16 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { args: ["--goto", "/tmp/workspace/src/open.ts:71:5"], }); + const kiroLineAndColumn = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "kiro" }, + "darwin", + { PATH: "" }, + ); + assert.deepEqual(kiroLineAndColumn, { + command: "kiro", + args: ["ide", "--goto", "/tmp/workspace/src/open.ts:71:5"], + }); + const vscodeLineAndColumn = yield* resolveEditorLaunch( { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "vscode" }, "darwin", @@ -354,6 +374,7 @@ it.layer(NodeServices.layer)("resolveAvailableEditors", (it) => { const dir = yield* fs.makeTempDirectoryScoped({ prefix: "marcode-editors-" }); yield* fs.writeFileString(path.join(dir, "trae.CMD"), "@echo off\r\n"); + yield* fs.writeFileString(path.join(dir, "kiro.CMD"), "@echo off\r\n"); yield* fs.writeFileString(path.join(dir, "code-insiders.CMD"), "@echo off\r\n"); yield* fs.writeFileString(path.join(dir, "codium.CMD"), "@echo off\r\n"); yield* fs.writeFileString(path.join(dir, "explorer.CMD"), "MZ"); @@ -361,7 +382,7 @@ it.layer(NodeServices.layer)("resolveAvailableEditors", (it) => { PATH: dir, PATHEXT: ".COM;.EXE;.BAT;.CMD", }); - assert.deepEqual(editors, ["trae", "vscode-insiders", "vscodium", "file-manager"]); + assert.deepEqual(editors, ["trae", "kiro", "vscode-insiders", "vscodium", "file-manager"]); }), ); diff --git a/apps/server/src/open.ts b/apps/server/src/open.ts index 58ac962a7f8..2eff9f58790 100644 --- a/apps/server/src/open.ts +++ b/apps/server/src/open.ts @@ -7,10 +7,9 @@ * @module Open */ import { spawn } from "node:child_process"; -import { accessSync, constants, statSync } from "node:fs"; -import { extname, join } from "node:path"; import { EDITORS, OpenError, type EditorId } from "@marcode/contracts"; +import { isCommandAvailable, type CommandAvailabilityOptions } from "@marcode/shared/shell"; import { Context, Effect, Layer } from "effect"; // ============================== @@ -18,6 +17,7 @@ import { Context, Effect, Layer } from "effect"; // ============================== export { OpenError }; +export { isCommandAvailable } from "@marcode/shared/shell"; export interface OpenInEditorInput { readonly cwd: string; @@ -29,11 +29,6 @@ interface EditorLaunch { readonly args: ReadonlyArray; } -interface CommandAvailabilityOptions { - readonly platform?: NodeJS.Platform; - readonly env?: NodeJS.ProcessEnv; -} - const TARGET_WITH_POSITION_PATTERN = /^(.*?):(\d+)(?::(\d+))?$/; function parseTargetPathAndPosition(target: string): { @@ -75,6 +70,14 @@ function resolveCommandEditorArgs( } } +function resolveEditorArgs( + editor: (typeof EDITORS)[number], + target: string, +): ReadonlyArray { + const baseArgs = "baseArgs" in editor ? editor.baseArgs : []; + return [...baseArgs, ...resolveCommandEditorArgs(editor, target)]; +} + function resolveAvailableCommand( commands: ReadonlyArray, options: CommandAvailabilityOptions = {}, @@ -98,111 +101,6 @@ function fileManagerCommandForPlatform(platform: NodeJS.Platform): string { } } -function stripWrappingQuotes(value: string): string { - return value.replace(/^"+|"+$/g, ""); -} - -function resolvePathEnvironmentVariable(env: NodeJS.ProcessEnv): string { - return env.PATH ?? env.Path ?? env.path ?? ""; -} - -function resolveWindowsPathExtensions(env: NodeJS.ProcessEnv): ReadonlyArray { - const rawValue = env.PATHEXT; - const fallback = [".COM", ".EXE", ".BAT", ".CMD"]; - if (!rawValue) return fallback; - - const parsed = rawValue - .split(";") - .map((entry) => entry.trim()) - .filter((entry) => entry.length > 0) - .map((entry) => (entry.startsWith(".") ? entry.toUpperCase() : `.${entry.toUpperCase()}`)); - return parsed.length > 0 ? Array.from(new Set(parsed)) : fallback; -} - -function resolveCommandCandidates( - command: string, - platform: NodeJS.Platform, - windowsPathExtensions: ReadonlyArray, -): ReadonlyArray { - if (platform !== "win32") return [command]; - const extension = extname(command); - const normalizedExtension = extension.toUpperCase(); - - if (extension.length > 0 && windowsPathExtensions.includes(normalizedExtension)) { - const commandWithoutExtension = command.slice(0, -extension.length); - return Array.from( - new Set([ - command, - `${commandWithoutExtension}${normalizedExtension}`, - `${commandWithoutExtension}${normalizedExtension.toLowerCase()}`, - ]), - ); - } - - const candidates: string[] = []; - for (const extension of windowsPathExtensions) { - candidates.push(`${command}${extension}`); - candidates.push(`${command}${extension.toLowerCase()}`); - } - return Array.from(new Set(candidates)); -} - -function isExecutableFile( - filePath: string, - platform: NodeJS.Platform, - windowsPathExtensions: ReadonlyArray, -): boolean { - try { - const stat = statSync(filePath); - if (!stat.isFile()) return false; - if (platform === "win32") { - const extension = extname(filePath); - if (extension.length === 0) return false; - return windowsPathExtensions.includes(extension.toUpperCase()); - } - accessSync(filePath, constants.X_OK); - return true; - } catch { - return false; - } -} - -function resolvePathDelimiter(platform: NodeJS.Platform): string { - return platform === "win32" ? ";" : ":"; -} - -export function isCommandAvailable( - command: string, - options: CommandAvailabilityOptions = {}, -): boolean { - const platform = options.platform ?? process.platform; - const env = options.env ?? process.env; - const windowsPathExtensions = platform === "win32" ? resolveWindowsPathExtensions(env) : []; - const commandCandidates = resolveCommandCandidates(command, platform, windowsPathExtensions); - - if (command.includes("/") || command.includes("\\")) { - return commandCandidates.some((candidate) => - isExecutableFile(candidate, platform, windowsPathExtensions), - ); - } - - const pathValue = resolvePathEnvironmentVariable(env); - if (pathValue.length === 0) return false; - const pathEntries = pathValue - .split(resolvePathDelimiter(platform)) - .map((entry) => stripWrappingQuotes(entry.trim())) - .filter((entry) => entry.length > 0); - - for (const pathEntry of pathEntries) { - for (const candidate of commandCandidates) { - if (isExecutableFile(join(pathEntry, candidate), platform, windowsPathExtensions)) { - return true; - } - } - } - return false; -} - export function resolveAvailableEditors( platform: NodeJS.Platform = process.platform, env: NodeJS.ProcessEnv = process.env, @@ -273,7 +171,7 @@ export const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( resolveAvailableCommand(editorDef.commands, { platform, env }) ?? editorDef.commands[0]; return { command, - args: resolveCommandEditorArgs(editorDef, input.cwd), + args: resolveEditorArgs(editorDef, input.cwd), }; } @@ -293,11 +191,16 @@ export const launchDetached = (launch: EditorLaunch) => yield* Effect.callback((resume) => { let child; try { - child = spawn(launch.command, [...launch.args], { - detached: true, - stdio: "ignore", - shell: process.platform === "win32", - }); + const isWin32 = process.platform === "win32"; + child = spawn( + launch.command, + isWin32 ? launch.args.map((a) => `"${a}"`) : [...launch.args], + { + detached: true, + stdio: "ignore", + shell: isWin32, + }, + ); } catch (error) { return resume( Effect.fail(new OpenError({ message: "failed to spawn detached process", cause: error })), diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index 639000fff91..0c9adb521d0 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -21,8 +21,8 @@ import { ProviderService } from "../../provider/Services/ProviderService.ts"; import { CheckpointReactor, type CheckpointReactorShape } from "../Services/CheckpointReactor.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { RuntimeReceiptBus } from "../Services/RuntimeReceiptBus.ts"; -import { CheckpointStoreError } from "../../checkpointing/Errors.ts"; -import { OrchestrationDispatchError } from "../Errors.ts"; +import type { CheckpointStoreError } from "../../checkpointing/Errors.ts"; +import type { OrchestrationDispatchError } from "../Errors.ts"; import { isGitRepository } from "../../git/Utils.ts"; import { GitStatusBroadcaster } from "../../git/Services/GitStatusBroadcaster.ts"; import { WorkspaceEntries } from "../../workspace/Services/WorkspaceEntries.ts"; diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 91bf6d3ed78..fc9b64c6fca 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -150,10 +150,20 @@ describe("OrchestrationEngine", () => { Effect.die("OrchestrationEngine test should not request the listing snapshot"), getThread: () => Effect.die("OrchestrationEngine test should not request a single thread"), + getShellSnapshot: () => + Effect.succeed({ + snapshotSequence: projectionSnapshot.snapshotSequence, + projects: [], + threads: [], + updatedAt: projectionSnapshot.updatedAt, + }), getCounts: () => Effect.succeed({ projectCount: 1, threadCount: 1 }), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), getThreadCheckpointContext: () => Effect.succeed(Option.none()), + getThreadShellById: () => Effect.succeed(Option.none()), + getThreadDetailById: () => Effect.succeed(Option.none()), }), ), Layer.provide( diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index beb94ebb6d0..a8194c4b49f 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -1499,6 +1499,329 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { }), ); + it.effect("clears stale pending approvals from projected shell summaries", () => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const appendAndProject = (event: Parameters[0]) => + eventStore + .append(event) + .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent))); + + yield* appendAndProject({ + type: "project.created", + eventId: EventId.make("evt-stale-approval-1"), + aggregateKind: "project", + aggregateId: ProjectId.make("project-stale-approval"), + occurredAt: "2026-02-26T12:30:00.000Z", + commandId: CommandId.make("cmd-stale-approval-1"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-stale-approval-1"), + metadata: {}, + payload: { + projectId: ProjectId.make("project-stale-approval"), + title: "Project Stale Approval", + workspaceRoot: "/tmp/project-stale-approval", + defaultModelSelection: null, + scripts: [], + createdAt: "2026-02-26T12:30:00.000Z", + updatedAt: "2026-02-26T12:30:00.000Z", + }, + }); + + yield* appendAndProject({ + type: "thread.created", + eventId: EventId.make("evt-stale-approval-2"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-stale-approval"), + occurredAt: "2026-02-26T12:30:01.000Z", + commandId: CommandId.make("cmd-stale-approval-2"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-stale-approval-2"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-stale-approval"), + projectId: ProjectId.make("project-stale-approval"), + title: "Thread Stale Approval", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + runtimeMode: "approval-required", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: "2026-02-26T12:30:01.000Z", + updatedAt: "2026-02-26T12:30:01.000Z", + }, + }); + + yield* appendAndProject({ + type: "thread.activity-appended", + eventId: EventId.make("evt-stale-approval-3"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-stale-approval"), + occurredAt: "2026-02-26T12:30:02.000Z", + commandId: CommandId.make("cmd-stale-approval-3"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-stale-approval-3"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-stale-approval"), + activity: { + id: EventId.make("activity-stale-approval-requested"), + tone: "approval", + kind: "approval.requested", + summary: "Command approval requested", + payload: { + requestId: "approval-request-stale-1", + requestKind: "command", + }, + turnId: null, + createdAt: "2026-02-26T12:30:02.000Z", + }, + }, + }); + + yield* appendAndProject({ + type: "thread.activity-appended", + eventId: EventId.make("evt-stale-approval-4"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-stale-approval"), + occurredAt: "2026-02-26T12:30:03.000Z", + commandId: CommandId.make("cmd-stale-approval-4"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-stale-approval-4"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-stale-approval"), + activity: { + id: EventId.make("activity-stale-approval-failed"), + tone: "error", + kind: "provider.approval.respond.failed", + summary: "Provider approval response failed", + payload: { + requestId: "approval-request-stale-1", + detail: "Unknown pending permission request: approval-request-stale-1", + }, + turnId: null, + createdAt: "2026-02-26T12:30:03.000Z", + }, + }, + }); + + const approvalRows = yield* sql<{ + readonly requestId: string; + readonly status: string; + readonly resolvedAt: string | null; + }>` + SELECT + request_id AS "requestId", + status, + resolved_at AS "resolvedAt" + FROM projection_pending_approvals + WHERE request_id = 'approval-request-stale-1' + `; + assert.deepEqual(approvalRows, [ + { + requestId: "approval-request-stale-1", + status: "resolved", + resolvedAt: "2026-02-26T12:30:03.000Z", + }, + ]); + + const threadRows = yield* sql<{ + readonly pendingApprovalCount: number; + }>` + SELECT pending_approval_count AS "pendingApprovalCount" + FROM projection_threads + WHERE thread_id = 'thread-stale-approval' + `; + assert.deepEqual(threadRows, [{ pendingApprovalCount: 0 }]); + }), + ); + + it.effect("ignores non-stale provider approval response failures", () => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const appendAndProject = (event: Parameters[0]) => + eventStore + .append(event) + .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent))); + + yield* appendAndProject({ + type: "project.created", + eventId: EventId.make("evt-nonstale-approval-1"), + aggregateKind: "project", + aggregateId: ProjectId.make("project-nonstale-approval"), + occurredAt: "2026-02-26T12:45:00.000Z", + commandId: CommandId.make("cmd-nonstale-approval-1"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-nonstale-approval-1"), + metadata: {}, + payload: { + projectId: ProjectId.make("project-nonstale-approval"), + title: "Project Non-Stale Approval", + workspaceRoot: "/tmp/project-nonstale-approval", + defaultModelSelection: null, + scripts: [], + createdAt: "2026-02-26T12:45:00.000Z", + updatedAt: "2026-02-26T12:45:00.000Z", + }, + }); + + yield* appendAndProject({ + type: "thread.created", + eventId: EventId.make("evt-nonstale-approval-2"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-nonstale-approval"), + occurredAt: "2026-02-26T12:45:01.000Z", + commandId: CommandId.make("cmd-nonstale-approval-2"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-nonstale-approval-2"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-nonstale-approval"), + projectId: ProjectId.make("project-nonstale-approval"), + title: "Thread Non-Stale Approval", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + runtimeMode: "approval-required", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: "2026-02-26T12:45:01.000Z", + updatedAt: "2026-02-26T12:45:01.000Z", + }, + }); + + yield* appendAndProject({ + type: "thread.activity-appended", + eventId: EventId.make("evt-nonstale-approval-3"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-nonstale-approval"), + occurredAt: "2026-02-26T12:45:02.000Z", + commandId: CommandId.make("cmd-nonstale-approval-3"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-nonstale-approval-3"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-nonstale-approval"), + activity: { + id: EventId.make("activity-nonstale-approval-requested"), + tone: "approval", + kind: "approval.requested", + summary: "Command approval requested", + payload: { + requestId: "approval-request-nonstale-existing", + requestKind: "command", + }, + turnId: null, + createdAt: "2026-02-26T12:45:02.000Z", + }, + }, + }); + + yield* appendAndProject({ + type: "thread.activity-appended", + eventId: EventId.make("evt-nonstale-approval-4"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-nonstale-approval"), + occurredAt: "2026-02-26T12:45:03.000Z", + commandId: CommandId.make("cmd-nonstale-approval-4"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-nonstale-approval-4"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-nonstale-approval"), + activity: { + id: EventId.make("activity-nonstale-approval-failed-existing"), + tone: "error", + kind: "provider.approval.respond.failed", + summary: "Provider approval response failed", + payload: { + requestId: "approval-request-nonstale-existing", + detail: "Provider timed out while responding to approval request", + }, + turnId: TurnId.make("turn-nonstale-failure"), + createdAt: "2026-02-26T12:45:03.000Z", + }, + }, + }); + + yield* appendAndProject({ + type: "thread.activity-appended", + eventId: EventId.make("evt-nonstale-approval-5"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-nonstale-approval"), + occurredAt: "2026-02-26T12:45:04.000Z", + commandId: CommandId.make("cmd-nonstale-approval-5"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-nonstale-approval-5"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-nonstale-approval"), + activity: { + id: EventId.make("activity-nonstale-approval-failed-missing"), + tone: "error", + kind: "provider.approval.respond.failed", + summary: "Provider approval response failed", + payload: { + requestId: "approval-request-nonstale-missing", + detail: "Provider timed out while responding to approval request", + }, + turnId: null, + createdAt: "2026-02-26T12:45:04.000Z", + }, + }, + }); + + const approvalRows = yield* sql<{ + readonly requestId: string; + readonly status: string; + readonly turnId: string | null; + readonly createdAt: string; + readonly resolvedAt: string | null; + }>` + SELECT + request_id AS "requestId", + status, + turn_id AS "turnId", + created_at AS "createdAt", + resolved_at AS "resolvedAt" + FROM projection_pending_approvals + WHERE request_id IN ( + 'approval-request-nonstale-existing', + 'approval-request-nonstale-missing' + ) + ORDER BY request_id + `; + assert.deepEqual(approvalRows, [ + { + requestId: "approval-request-nonstale-existing", + status: "pending", + turnId: null, + createdAt: "2026-02-26T12:45:02.000Z", + resolvedAt: null, + }, + ]); + + const threadRows = yield* sql<{ + readonly pendingApprovalCount: number; + }>` + SELECT pending_approval_count AS "pendingApprovalCount" + FROM projection_threads + WHERE thread_id = 'thread-nonstale-approval' + `; + assert.deepEqual(threadRows, [{ pendingApprovalCount: 1 }]); + }), + ); + it.effect("does not fallback-retain messages whose turnId is removed by revert", () => Effect.gen(function* () { const projectionPipeline = yield* OrchestrationProjectionPipeline; diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 7f0db538d9e..1392679e861 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -2,6 +2,7 @@ import { ApprovalRequestId, type ChatAttachment, type OrchestrationEvent, + ThreadId, } from "@marcode/contracts"; import { Effect, FileSystem, Layer, Option, Path, Stream } from "effect"; import * as SqlClient from "effect/unstable/sql/SqlClient"; @@ -89,6 +90,88 @@ function extractActivityRequestId(payload: unknown): ApprovalRequestId | null { return typeof requestId === "string" ? ApprovalRequestId.make(requestId) : null; } +function isStalePendingApprovalFailureDetail(detail: string | null): boolean { + if (detail === null) { + return false; + } + return ( + detail.includes("stale pending approval request") || + detail.includes("unknown pending approval request") || + detail.includes("unknown pending permission request") + ); +} + +function derivePendingUserInputCountFromActivities( + activities: ReadonlyArray, +): number { + const openRequestIds = new Set(); + const ordered = [...activities].toSorted( + (left, right) => + left.createdAt.localeCompare(right.createdAt) || + left.activityId.localeCompare(right.activityId), + ); + + for (const activity of ordered) { + const requestId = extractActivityRequestId(activity.payload); + if (requestId === null) { + continue; + } + const payload = + typeof activity.payload === "object" && activity.payload !== null + ? (activity.payload as Record) + : null; + const detail = typeof payload?.detail === "string" ? payload.detail.toLowerCase() : null; + + if (activity.kind === "user-input.requested") { + openRequestIds.add(requestId); + continue; + } + + if (activity.kind === "user-input.resolved") { + openRequestIds.delete(requestId); + continue; + } + + if ( + activity.kind === "provider.user-input.respond.failed" && + detail !== null && + (detail.includes("stale pending user-input request") || + detail.includes("unknown pending user-input request")) + ) { + openRequestIds.delete(requestId); + } + } + + return openRequestIds.size; +} + +function deriveHasActionableProposedPlan(input: { + readonly latestTurnId: string | null; + readonly proposedPlans: ReadonlyArray; +}): boolean { + const sorted = [...input.proposedPlans].toSorted( + (left, right) => + left.updatedAt.localeCompare(right.updatedAt) || left.planId.localeCompare(right.planId), + ); + + let latestForTurn: ProjectionThreadProposedPlan | null = null; + if (input.latestTurnId !== null) { + for (let index = sorted.length - 1; index >= 0; index -= 1) { + const plan = sorted[index]; + if (plan?.turnId === input.latestTurnId) { + latestForTurn = plan; + break; + } + } + } + if (latestForTurn !== null) { + return latestForTurn.implementedAt === null; + } + + const latestPlan = sorted.at(-1) ?? null; + return latestPlan !== null && latestPlan.implementedAt === null; +} + function retainProjectionMessagesAfterRevert( messages: ReadonlyArray, turns: ReadonlyArray, @@ -436,6 +519,48 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti } }); + const refreshThreadShellSummary = Effect.fn("refreshThreadShellSummary")(function* ( + threadId: ThreadId, + ) { + const existingRow = yield* projectionThreadRepository.getById({ + threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + + const [messages, proposedPlans, activities, pendingApprovals] = yield* Effect.all([ + projectionThreadMessageRepository.listByThreadId({ threadId }), + projectionThreadProposedPlanRepository.listByThreadId({ threadId }), + projectionThreadActivityRepository.listByThreadId({ threadId }), + projectionPendingApprovalRepository.listByThreadId({ threadId }), + ]); + + const latestUserMessageAt = + messages + .filter((message) => message.role === "user") + .map((message) => message.createdAt) + .toSorted() + .at(-1) ?? null; + + const pendingApprovalCount = pendingApprovals.filter( + (approval) => approval.status === "pending", + ).length; + const pendingUserInputCount = derivePendingUserInputCountFromActivities(activities); + const hasActionableProposedPlan = deriveHasActionableProposedPlan({ + latestTurnId: existingRow.value.latestTurnId, + proposedPlans, + }); + + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + latestUserMessageAt, + pendingApprovalCount, + pendingUserInputCount, + hasActionableProposedPlan: hasActionableProposedPlan ? 1 : 0, + }); + }); + const applyThreadsProjection: ProjectorDefinition["apply"] = Effect.fn( "applyThreadsProjection", )(function* (event, attachmentSideEffects) { @@ -455,6 +580,10 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti createdAt: event.payload.createdAt, updatedAt: event.payload.updatedAt, archivedAt: null, + latestUserMessageAt: null, + pendingApprovalCount: 0, + pendingUserInputCount: 0, + hasActionableProposedPlan: 0, deletedAt: null, }); return; @@ -562,7 +691,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti case "thread.message-sent": case "thread.proposed-plan-upserted": - case "thread.activity-appended": { + case "thread.activity-appended": + case "thread.approval-response-requested": + case "thread.user-input-response-requested": { const existingRow = yield* projectionThreadRepository.getById({ threadId: event.payload.threadId, }); @@ -573,6 +704,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ...existingRow.value, updatedAt: event.occurredAt, }); + yield* refreshThreadShellSummary(event.payload.threadId); return; } @@ -588,6 +720,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti latestTurnId: event.payload.session.activeTurnId, updatedAt: event.occurredAt, }); + yield* refreshThreadShellSummary(event.payload.threadId); return; } @@ -603,6 +736,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti latestTurnId: event.payload.turnId, updatedAt: event.occurredAt, }); + yield* refreshThreadShellSummary(event.payload.threadId); return; } @@ -618,6 +752,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti latestTurnId: null, updatedAt: event.occurredAt, }); + yield* refreshThreadShellSummary(event.payload.threadId); return; } @@ -1129,6 +1264,42 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti }); return; } + if (event.payload.activity.kind === "provider.approval.respond.failed") { + const payload = + typeof event.payload.activity.payload === "object" && + event.payload.activity.payload !== null + ? (event.payload.activity.payload as Record) + : null; + const detail = + typeof payload?.detail === "string" ? payload.detail.toLowerCase() : null; + if (isStalePendingApprovalFailureDetail(detail)) { + if (Option.isNone(existingRow)) { + return; + } + if (existingRow.value.status === "resolved") { + return; + } + yield* projectionPendingApprovalRepository.upsert({ + requestId, + threadId: existingRow.value.threadId, + turnId: existingRow.value.turnId, + status: "resolved", + decision: null, + createdAt: existingRow.value.createdAt, + resolvedAt: event.payload.activity.createdAt, + }); + return; + } + return; + } + // Only approval-requested activities should create pending-approval + // rows. Other activity kinds that happen to carry a requestId + // (e.g. user-input.requested / user-input.resolved) must not + // pollute this projection — they have their own accounting via + // derivePendingUserInputCountFromActivities. + if (event.payload.activity.kind !== "approval.requested") { + return; + } if (Option.isSome(existingRow) && existingRow.value.status === "resolved") { return; } diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 779fee490d0..8330bf58b90 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -62,9 +62,15 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { project_id, title, model_selection_json, + runtime_mode, + interaction_mode, branch, worktree_path, latest_turn_id, + latest_user_message_at, + pending_approval_count, + pending_user_input_count, + has_actionable_proposed_plan, created_at, updated_at, deleted_at @@ -74,9 +80,15 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { 'project-1', 'Thread 1', '{"provider":"codex","model":"gpt-5-codex"}', + 'full-access', + 'default', NULL, NULL, 'turn-1', + '2026-02-24T00:00:04.000Z', + 1, + 0, + 0, '2026-02-24T00:00:02.000Z', '2026-02-24T00:00:03.000Z', NULL @@ -343,6 +355,83 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { }, }, ]); + + const shellSnapshot = yield* snapshotQuery.getShellSnapshot(); + assert.equal(shellSnapshot.snapshotSequence, 5); + assert.deepEqual(shellSnapshot.projects, [ + { + id: asProjectId("project-1"), + title: "Project 1", + workspaceRoot: "/tmp/project-1", + repositoryIdentity: null, + defaultModelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + scripts: [ + { + id: "script-1", + name: "Build", + command: "bun run build", + icon: "build", + runOnWorktreeCreate: false, + }, + ], + createdAt: "2026-02-24T00:00:00.000Z", + updatedAt: "2026-02-24T00:00:01.000Z", + }, + ]); + assert.deepEqual(shellSnapshot.threads, [ + { + id: ThreadId.make("thread-1"), + projectId: asProjectId("project-1"), + title: "Thread 1", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + interactionMode: "default", + runtimeMode: "full-access", + branch: null, + worktreePath: null, + latestTurn: { + turnId: asTurnId("turn-1"), + state: "completed", + requestedAt: "2026-02-24T00:00:08.000Z", + startedAt: "2026-02-24T00:00:08.000Z", + completedAt: "2026-02-24T00:00:08.000Z", + assistantMessageId: asMessageId("message-1"), + sourceProposedPlan: { + threadId: ThreadId.make("thread-1"), + planId: "plan-1", + }, + }, + createdAt: "2026-02-24T00:00:02.000Z", + updatedAt: "2026-02-24T00:00:03.000Z", + archivedAt: null, + deletedAt: null, + additionalDirectories: [], + session: { + threadId: ThreadId.make("thread-1"), + status: "running", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: asTurnId("turn-1"), + lastError: null, + updatedAt: "2026-02-24T00:00:07.000Z", + }, + latestUserMessageAt: "2026-02-24T00:00:04.000Z", + hasPendingApprovals: true, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + }, + ]); + + const threadDetail = yield* snapshotQuery.getThreadDetailById(ThreadId.make("thread-1")); + assert.equal(threadDetail._tag, "Some"); + if (threadDetail._tag === "Some") { + assert.deepEqual(threadDetail.value, snapshot.threads[0]); + } }), ); @@ -631,4 +720,309 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { } }), ); + + it.effect("keeps thread detail activity ordering consistent with shell snapshot ordering", () => + Effect.gen(function* () { + const snapshotQuery = yield* ProjectionSnapshotQuery; + const sql = yield* SqlClient.SqlClient; + + yield* sql`DELETE FROM projection_projects`; + yield* sql`DELETE FROM projection_threads`; + yield* sql`DELETE FROM projection_thread_activities`; + yield* sql`DELETE FROM projection_state`; + + yield* sql` + INSERT INTO projection_projects ( + project_id, + title, + workspace_root, + default_model_selection_json, + scripts_json, + created_at, + updated_at, + deleted_at + ) + VALUES ( + 'project-1', + 'Project 1', + '/tmp/project-1', + '{"provider":"codex","model":"gpt-5-codex"}', + '[]', + '2026-04-01T00:00:00.000Z', + '2026-04-01T00:00:01.000Z', + NULL + ) + `; + + yield* sql` + INSERT INTO projection_threads ( + thread_id, + project_id, + title, + model_selection_json, + runtime_mode, + interaction_mode, + branch, + worktree_path, + latest_turn_id, + latest_user_message_at, + pending_approval_count, + pending_user_input_count, + has_actionable_proposed_plan, + created_at, + updated_at, + deleted_at + ) + VALUES ( + 'thread-1', + 'project-1', + 'Thread 1', + '{"provider":"codex","model":"gpt-5-codex"}', + 'full-access', + 'default', + NULL, + NULL, + NULL, + NULL, + 0, + 0, + 0, + '2026-04-01T00:00:02.000Z', + '2026-04-01T00:00:03.000Z', + NULL + ) + `; + + yield* sql` + INSERT INTO projection_thread_activities ( + activity_id, + thread_id, + turn_id, + tone, + kind, + summary, + payload_json, + sequence, + created_at + ) + VALUES + ( + 'activity-unsequenced', + 'thread-1', + NULL, + 'info', + 'runtime.note', + 'unsequenced first', + '{"source":"unsequenced"}', + NULL, + '2026-04-01T00:00:06.000Z' + ), + ( + 'activity-sequence-2', + 'thread-1', + NULL, + 'info', + 'runtime.note', + 'sequence two', + '{"source":"sequence-2"}', + 2, + '2026-04-01T00:00:04.000Z' + ), + ( + 'activity-sequence-1', + 'thread-1', + NULL, + 'info', + 'runtime.note', + 'sequence one', + '{"source":"sequence-1"}', + 1, + '2026-04-01T00:00:05.000Z' + ) + `; + + const snapshot = yield* snapshotQuery.getSnapshot(); + const threadDetail = yield* snapshotQuery.getThreadDetailById(ThreadId.make("thread-1")); + + assert.equal(threadDetail._tag, "Some"); + if (threadDetail._tag === "Some") { + assert.deepEqual(threadDetail.value.activities, snapshot.threads[0]?.activities ?? []); + } + + assert.deepEqual(snapshot.threads[0]?.activities ?? [], [ + { + id: asEventId("activity-unsequenced"), + tone: "info", + kind: "runtime.note", + summary: "unsequenced first", + payload: { source: "unsequenced" }, + turnId: null, + createdAt: "2026-04-01T00:00:06.000Z", + }, + { + id: asEventId("activity-sequence-1"), + tone: "info", + kind: "runtime.note", + summary: "sequence one", + payload: { source: "sequence-1" }, + turnId: null, + sequence: 1, + createdAt: "2026-04-01T00:00:05.000Z", + }, + { + id: asEventId("activity-sequence-2"), + tone: "info", + kind: "runtime.note", + summary: "sequence two", + payload: { source: "sequence-2" }, + turnId: null, + sequence: 2, + createdAt: "2026-04-01T00:00:04.000Z", + }, + ]); + }), + ); + + it.effect("uses projection_threads.latest_turn_id for targeted thread latest turn queries", () => + Effect.gen(function* () { + const snapshotQuery = yield* ProjectionSnapshotQuery; + const sql = yield* SqlClient.SqlClient; + + yield* sql`DELETE FROM projection_projects`; + yield* sql`DELETE FROM projection_threads`; + yield* sql`DELETE FROM projection_turns`; + + yield* sql` + INSERT INTO projection_projects ( + project_id, + title, + workspace_root, + default_model_selection_json, + scripts_json, + created_at, + updated_at, + deleted_at + ) + VALUES ( + 'project-1', + 'Project 1', + '/tmp/project-1', + '{"provider":"codex","model":"gpt-5-codex"}', + '[]', + '2026-04-02T00:00:00.000Z', + '2026-04-02T00:00:01.000Z', + NULL + ) + `; + + yield* sql` + INSERT INTO projection_threads ( + thread_id, + project_id, + title, + model_selection_json, + runtime_mode, + interaction_mode, + branch, + worktree_path, + latest_turn_id, + latest_user_message_at, + pending_approval_count, + pending_user_input_count, + has_actionable_proposed_plan, + created_at, + updated_at, + archived_at, + deleted_at + ) + VALUES ( + 'thread-1', + 'project-1', + 'Thread 1', + '{"provider":"codex","model":"gpt-5-codex"}', + 'full-access', + 'default', + NULL, + NULL, + 'turn-running', + '2026-04-02T00:00:04.000Z', + 0, + 0, + 0, + '2026-04-02T00:00:02.000Z', + '2026-04-02T00:00:03.000Z', + NULL, + NULL + ) + `; + + yield* sql` + INSERT INTO projection_turns ( + thread_id, + turn_id, + pending_message_id, + source_proposed_plan_thread_id, + source_proposed_plan_id, + assistant_message_id, + state, + requested_at, + started_at, + completed_at, + checkpoint_turn_count, + checkpoint_ref, + checkpoint_status, + checkpoint_files_json + ) + VALUES + ( + 'thread-1', + 'turn-completed', + 'message-user-1', + NULL, + NULL, + 'message-assistant-1', + 'completed', + '2026-04-02T00:00:05.000Z', + '2026-04-02T00:00:06.000Z', + '2026-04-02T00:00:20.000Z', + 5, + 'checkpoint-5', + 'ready', + '[]' + ), + ( + 'thread-1', + 'turn-running', + 'message-user-2', + NULL, + NULL, + NULL, + 'running', + '2026-04-02T00:00:30.000Z', + '2026-04-02T00:00:30.000Z', + NULL, + NULL, + NULL, + NULL, + '[]' + ) + `; + + const threadShell = yield* snapshotQuery.getThreadShellById(ThreadId.make("thread-1")); + assert.equal(threadShell._tag, "Some"); + if (threadShell._tag === "Some") { + assert.equal(threadShell.value.latestTurn?.turnId, asTurnId("turn-running")); + assert.equal(threadShell.value.latestTurn?.state, "running"); + assert.equal(threadShell.value.latestTurn?.startedAt, "2026-04-02T00:00:30.000Z"); + } + + const threadDetail = yield* snapshotQuery.getThreadDetailById(ThreadId.make("thread-1")); + assert.equal(threadDetail._tag, "Some"); + if (threadDetail._tag === "Some") { + assert.equal(threadDetail.value.latestTurn?.turnId, asTurnId("turn-running")); + assert.equal(threadDetail.value.latestTurn?.state, "running"); + assert.equal(threadDetail.value.latestTurn?.startedAt, "2026-04-02T00:00:30.000Z"); + } + }), + ); }); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index cf9518f035c..242ce02228e 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -8,17 +8,20 @@ import { OrchestrationListingSnapshot, OrchestrationProposedPlanId, OrchestrationReadModel, + OrchestrationShellSnapshot, + OrchestrationThread, ProjectScript, TurnId, type OrchestrationCheckpointSummary, type OrchestrationLatestTurn, type OrchestrationMessage, + type OrchestrationProjectShell, type OrchestrationProposedPlan, type OrchestrationProject, type OrchestrationSession, - type OrchestrationThread, type OrchestrationThreadActivity, type OrchestrationThreadSummary, + type OrchestrationThreadShell, ModelSelection, ProjectId, ThreadId, @@ -52,6 +55,8 @@ import { const decodeReadModel = Schema.decodeUnknownEffect(OrchestrationReadModel); const decodeListingSnapshot = Schema.decodeUnknownEffect(OrchestrationListingSnapshot); +const decodeShellSnapshot = Schema.decodeUnknownEffect(OrchestrationShellSnapshot); +const decodeThread = Schema.decodeUnknownEffect(OrchestrationThread); const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( Struct.assign({ defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), @@ -163,6 +168,64 @@ function computeSnapshotSequence( return Number.isFinite(minSequence) ? minSequence : 0; } +function mapLatestTurn( + row: Schema.Schema.Type, +): OrchestrationLatestTurn { + return { + turnId: row.turnId, + state: + row.state === "error" + ? "error" + : row.state === "interrupted" + ? "interrupted" + : row.state === "completed" + ? "completed" + : "running", + requestedAt: row.requestedAt, + startedAt: row.startedAt, + completedAt: row.completedAt, + assistantMessageId: row.assistantMessageId, + ...(row.sourceProposedPlanThreadId !== null && row.sourceProposedPlanId !== null + ? { + sourceProposedPlan: { + threadId: row.sourceProposedPlanThreadId, + planId: row.sourceProposedPlanId, + }, + } + : {}), + }; +} + +function mapSessionRow( + row: Schema.Schema.Type, +): OrchestrationSession { + return { + threadId: row.threadId, + status: row.status, + providerName: row.providerName, + runtimeMode: row.runtimeMode, + activeTurnId: row.activeTurnId, + lastError: row.lastError, + updatedAt: row.updatedAt, + }; +} + +function mapProjectShellRow( + row: Schema.Schema.Type, + repositoryIdentity: OrchestrationProject["repositoryIdentity"], +): OrchestrationProjectShell { + return { + id: row.projectId, + title: row.title, + workspaceRoot: row.workspaceRoot, + repositoryIdentity, + defaultModelSelection: row.defaultModelSelection, + scripts: row.scripts, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { return (cause: unknown): ProjectionRepositoryError => Schema.isSchemaError(cause) @@ -214,6 +277,10 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", + latest_user_message_at AS "latestUserMessageAt", + pending_approval_count AS "pendingApprovalCount", + pending_user_input_count AS "pendingUserInputCount", + has_actionable_proposed_plan AS "hasActionableProposedPlan", deleted_at AS "deletedAt" FROM projection_threads ORDER BY created_at ASC, thread_id ASC @@ -392,6 +459,27 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const getActiveProjectRowById = SqlSchema.findOneOption({ + Request: ProjectIdLookupInput, + Result: ProjectionProjectLookupRowSchema, + execute: ({ projectId }) => + sql` + SELECT + project_id AS "projectId", + title, + workspace_root AS "workspaceRoot", + default_model_selection_json AS "defaultModelSelection", + scripts_json AS "scripts", + created_at AS "createdAt", + updated_at AS "updatedAt", + deleted_at AS "deletedAt" + FROM projection_projects + WHERE project_id = ${projectId} + AND deleted_at IS NULL + LIMIT 1 + `, + }); + const getFirstActiveThreadIdByProject = SqlSchema.findOneOption({ Request: ProjectIdLookupInput, Result: ProjectionThreadIdLookupRowSchema, @@ -426,47 +514,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); - const listCheckpointRowsByThread = SqlSchema.findAll({ - Request: ThreadIdLookupInput, - Result: ProjectionCheckpointDbRowSchema, - execute: ({ threadId }) => - sql` - SELECT - thread_id AS "threadId", - turn_id AS "turnId", - checkpoint_turn_count AS "checkpointTurnCount", - checkpoint_ref AS "checkpointRef", - checkpoint_status AS "status", - checkpoint_files_json AS "files", - assistant_message_id AS "assistantMessageId", - completed_at AS "completedAt" - FROM projection_turns - WHERE thread_id = ${threadId} - AND checkpoint_turn_count IS NOT NULL - ORDER BY checkpoint_turn_count ASC - `, - }); - - const LatestUserMessageAtRowSchema = Schema.Struct({ - threadId: ProjectionThread.fields.threadId, - latestUserMessageAt: IsoDateTime, - }); - - const listLatestUserMessageAtRows = SqlSchema.findAll({ - Request: Schema.Void, - Result: LatestUserMessageAtRowSchema, - execute: () => - sql` - SELECT - thread_id AS "threadId", - MAX(created_at) AS "latestUserMessageAt" - FROM projection_thread_messages - WHERE role = 'user' - GROUP BY thread_id - `, - }); - - const getThreadRowById = SqlSchema.findOneOption({ + const getActiveThreadRowById = SqlSchema.findOneOption({ Request: ThreadIdLookupInput, Result: ProjectionThreadDbRowSchema, execute: ({ threadId }) => @@ -485,9 +533,14 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", + latest_user_message_at AS "latestUserMessageAt", + pending_approval_count AS "pendingApprovalCount", + pending_user_input_count AS "pendingUserInputCount", + has_actionable_proposed_plan AS "hasActionableProposedPlan", deleted_at AS "deletedAt" FROM projection_threads WHERE thread_id = ${threadId} + AND deleted_at IS NULL LIMIT 1 `, }); @@ -558,6 +611,116 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const getThreadSessionRowByThread = SqlSchema.findOneOption({ + Request: ThreadIdLookupInput, + Result: ProjectionThreadSessionDbRowSchema, + execute: ({ threadId }) => + sql` + SELECT + thread_id AS "threadId", + status, + provider_name AS "providerName", + runtime_mode AS "runtimeMode", + active_turn_id AS "activeTurnId", + last_error AS "lastError", + updated_at AS "updatedAt" + FROM projection_thread_sessions + WHERE thread_id = ${threadId} + LIMIT 1 + `, + }); + + const getLatestTurnRowByThread = SqlSchema.findOneOption({ + Request: ThreadIdLookupInput, + Result: ProjectionLatestTurnDbRowSchema, + execute: ({ threadId }) => + sql` + SELECT + turns.thread_id AS "threadId", + turns.turn_id AS "turnId", + turns.state, + turns.requested_at AS "requestedAt", + turns.started_at AS "startedAt", + turns.completed_at AS "completedAt", + turns.assistant_message_id AS "assistantMessageId", + turns.source_proposed_plan_thread_id AS "sourceProposedPlanThreadId", + turns.source_proposed_plan_id AS "sourceProposedPlanId" + FROM projection_threads threads + JOIN projection_turns turns + ON turns.thread_id = threads.thread_id + AND turns.turn_id = threads.latest_turn_id + WHERE threads.thread_id = ${threadId} + AND threads.deleted_at IS NULL + LIMIT 1 + `, + }); + + const listCheckpointRowsByThread = SqlSchema.findAll({ + Request: ThreadIdLookupInput, + Result: ProjectionCheckpointDbRowSchema, + execute: ({ threadId }) => + sql` + SELECT + thread_id AS "threadId", + turn_id AS "turnId", + checkpoint_turn_count AS "checkpointTurnCount", + checkpoint_ref AS "checkpointRef", + checkpoint_status AS "status", + checkpoint_files_json AS "files", + assistant_message_id AS "assistantMessageId", + completed_at AS "completedAt" + FROM projection_turns + WHERE thread_id = ${threadId} + AND checkpoint_turn_count IS NOT NULL + ORDER BY checkpoint_turn_count ASC + `, + }); + + const LatestUserMessageAtRowSchema = Schema.Struct({ + threadId: ProjectionThread.fields.threadId, + latestUserMessageAt: IsoDateTime, + }); + + const listLatestUserMessageAtRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: LatestUserMessageAtRowSchema, + execute: () => + sql` + SELECT + thread_id AS "threadId", + MAX(created_at) AS "latestUserMessageAt" + FROM projection_thread_messages + WHERE role = 'user' + GROUP BY thread_id + `, + }); + + const getThreadRowById = SqlSchema.findOneOption({ + Request: ThreadIdLookupInput, + Result: ProjectionThreadDbRowSchema, + execute: ({ threadId }) => + sql` + SELECT + thread_id AS "threadId", + project_id AS "projectId", + title, + model_selection_json AS "modelSelection", + runtime_mode AS "runtimeMode", + interaction_mode AS "interactionMode", + branch, + worktree_path AS "worktreePath", + additional_directories_json AS "additionalDirectories", + latest_turn_id AS "latestTurnId", + created_at AS "createdAt", + updated_at AS "updatedAt", + archived_at AS "archivedAt", + deleted_at AS "deletedAt" + FROM projection_threads + WHERE thread_id = ${threadId} + LIMIT 1 + `, + }); + const getThreadSessionByThread = SqlSchema.findOneOption({ Request: ThreadIdLookupInput, Result: ProjectionThreadSessionDbRowSchema, @@ -894,6 +1057,147 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { }), ); + const getShellSnapshot: ProjectionSnapshotQueryShape["getShellSnapshot"] = () => + sql + .withTransaction( + Effect.all([ + listProjectRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getShellSnapshot:listProjects:query", + "ProjectionSnapshotQuery.getShellSnapshot:listProjects:decodeRows", + ), + ), + ), + listThreadRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getShellSnapshot:listThreads:query", + "ProjectionSnapshotQuery.getShellSnapshot:listThreads:decodeRows", + ), + ), + ), + listThreadSessionRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getShellSnapshot:listThreadSessions:query", + "ProjectionSnapshotQuery.getShellSnapshot:listThreadSessions:decodeRows", + ), + ), + ), + listLatestTurnRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getShellSnapshot:listLatestTurns:query", + "ProjectionSnapshotQuery.getShellSnapshot:listLatestTurns:decodeRows", + ), + ), + ), + listProjectionStateRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getShellSnapshot:listProjectionState:query", + "ProjectionSnapshotQuery.getShellSnapshot:listProjectionState:decodeRows", + ), + ), + ), + ]), + ) + .pipe( + Effect.flatMap(([projectRows, threadRows, sessionRows, latestTurnRows, stateRows]) => + Effect.gen(function* () { + let updatedAt: string | null = null; + for (const row of projectRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + for (const row of threadRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + for (const row of sessionRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + for (const row of latestTurnRows) { + updatedAt = maxIso(updatedAt, row.requestedAt); + if (row.startedAt !== null) { + updatedAt = maxIso(updatedAt, row.startedAt); + } + if (row.completedAt !== null) { + updatedAt = maxIso(updatedAt, row.completedAt); + } + } + for (const row of stateRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + + const repositoryIdentities = new Map( + yield* Effect.forEach( + projectRows, + (row) => + repositoryIdentityResolver + .resolve(row.workspaceRoot) + .pipe(Effect.map((identity) => [row.projectId, identity] as const)), + { concurrency: repositoryIdentityResolutionConcurrency }, + ), + ); + const latestTurnByThread = new Map( + latestTurnRows.map((row) => [row.threadId, mapLatestTurn(row)] as const), + ); + const sessionByThread = new Map( + sessionRows.map((row) => [row.threadId, mapSessionRow(row)] as const), + ); + + const snapshot = { + snapshotSequence: computeSnapshotSequence(stateRows), + projects: projectRows + .filter((row) => row.deletedAt === null) + .map((row) => + mapProjectShellRow(row, repositoryIdentities.get(row.projectId) ?? null), + ), + threads: threadRows + .filter((row) => row.deletedAt === null) + .map( + (row): OrchestrationThreadShell => ({ + id: row.threadId, + projectId: row.projectId, + title: row.title, + modelSelection: row.modelSelection, + runtimeMode: row.runtimeMode, + interactionMode: row.interactionMode, + branch: row.branch, + worktreePath: row.worktreePath, + additionalDirectories: row.additionalDirectories, + latestTurn: latestTurnByThread.get(row.threadId) ?? null, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + archivedAt: row.archivedAt, + deletedAt: row.deletedAt, + session: sessionByThread.get(row.threadId) ?? null, + latestUserMessageAt: row.latestUserMessageAt, + hasPendingApprovals: row.pendingApprovalCount > 0, + hasPendingUserInput: row.pendingUserInputCount > 0, + hasActionableProposedPlan: row.hasActionableProposedPlan > 0, + }), + ), + updatedAt: updatedAt ?? new Date(0).toISOString(), + }; + + return yield* decodeShellSnapshot(snapshot).pipe( + Effect.mapError( + toPersistenceDecodeError( + "ProjectionSnapshotQuery.getShellSnapshot:decodeShellSnapshot", + ), + ), + ); + }), + ), + Effect.mapError((error) => { + if (isPersistenceError(error)) { + return error; + } + return toPersistenceSqlError("ProjectionSnapshotQuery.getShellSnapshot:query")(error); + }), + ); + const getCounts: ProjectionSnapshotQueryShape["getCounts"] = () => readProjectionCounts(undefined).pipe( Effect.mapError( @@ -941,6 +1245,27 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ); + const getProjectShellById: ProjectionSnapshotQueryShape["getProjectShellById"] = (projectId) => + getActiveProjectRowById({ projectId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getProjectShellById:query", + "ProjectionSnapshotQuery.getProjectShellById:decodeRow", + ), + ), + Effect.flatMap((option) => + Option.isNone(option) + ? Effect.succeed(Option.none()) + : repositoryIdentityResolver + .resolve(option.value.workspaceRoot) + .pipe( + Effect.map((repositoryIdentity) => + Option.some(mapProjectShellRow(option.value, repositoryIdentity)), + ), + ), + ), + ); + const getFirstActiveThreadIdByProjectId: ProjectionSnapshotQueryShape["getFirstActiveThreadIdByProjectId"] = (projectId) => getFirstActiveThreadIdByProject({ projectId }).pipe( @@ -1378,14 +1703,221 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { }), ); + const getThreadShellById: ProjectionSnapshotQueryShape["getThreadShellById"] = (threadId) => + Effect.gen(function* () { + const [threadRow, latestTurnRow, sessionRow] = yield* Effect.all([ + getActiveThreadRowById({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadShellById:getThread:query", + "ProjectionSnapshotQuery.getThreadShellById:getThread:decodeRow", + ), + ), + ), + getLatestTurnRowByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadShellById:getLatestTurn:query", + "ProjectionSnapshotQuery.getThreadShellById:getLatestTurn:decodeRow", + ), + ), + ), + getThreadSessionRowByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadShellById:getSession:query", + "ProjectionSnapshotQuery.getThreadShellById:getSession:decodeRow", + ), + ), + ), + ]); + + if (Option.isNone(threadRow)) { + return Option.none(); + } + + return Option.some({ + id: threadRow.value.threadId, + projectId: threadRow.value.projectId, + title: threadRow.value.title, + modelSelection: threadRow.value.modelSelection, + runtimeMode: threadRow.value.runtimeMode, + interactionMode: threadRow.value.interactionMode, + branch: threadRow.value.branch, + worktreePath: threadRow.value.worktreePath, + additionalDirectories: threadRow.value.additionalDirectories, + latestTurn: Option.isSome(latestTurnRow) ? mapLatestTurn(latestTurnRow.value) : null, + createdAt: threadRow.value.createdAt, + updatedAt: threadRow.value.updatedAt, + archivedAt: threadRow.value.archivedAt, + deletedAt: threadRow.value.deletedAt, + session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null, + latestUserMessageAt: threadRow.value.latestUserMessageAt, + hasPendingApprovals: threadRow.value.pendingApprovalCount > 0, + hasPendingUserInput: threadRow.value.pendingUserInputCount > 0, + hasActionableProposedPlan: threadRow.value.hasActionableProposedPlan > 0, + } satisfies OrchestrationThreadShell); + }); + + const getThreadDetailById: ProjectionSnapshotQueryShape["getThreadDetailById"] = (threadId) => + Effect.gen(function* () { + const [ + threadRow, + messageRows, + proposedPlanRows, + activityRows, + checkpointRows, + latestTurnRow, + sessionRow, + ] = yield* Effect.all([ + getActiveThreadRowById({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:getThread:query", + "ProjectionSnapshotQuery.getThreadDetailById:getThread:decodeRow", + ), + ), + ), + listThreadMessageRowsByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:listMessages:query", + "ProjectionSnapshotQuery.getThreadDetailById:listMessages:decodeRows", + ), + ), + ), + listThreadProposedPlanRowsByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:listPlans:query", + "ProjectionSnapshotQuery.getThreadDetailById:listPlans:decodeRows", + ), + ), + ), + listThreadActivityRowsByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:listActivities:query", + "ProjectionSnapshotQuery.getThreadDetailById:listActivities:decodeRows", + ), + ), + ), + listCheckpointRowsByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:listCheckpoints:query", + "ProjectionSnapshotQuery.getThreadDetailById:listCheckpoints:decodeRows", + ), + ), + ), + getLatestTurnRowByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:getLatestTurn:query", + "ProjectionSnapshotQuery.getThreadDetailById:getLatestTurn:decodeRow", + ), + ), + ), + getThreadSessionRowByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:getSession:query", + "ProjectionSnapshotQuery.getThreadDetailById:getSession:decodeRow", + ), + ), + ), + ]); + + if (Option.isNone(threadRow)) { + return Option.none(); + } + + const thread = { + id: threadRow.value.threadId, + projectId: threadRow.value.projectId, + title: threadRow.value.title, + modelSelection: threadRow.value.modelSelection, + runtimeMode: threadRow.value.runtimeMode, + interactionMode: threadRow.value.interactionMode, + branch: threadRow.value.branch, + worktreePath: threadRow.value.worktreePath, + latestTurn: Option.isSome(latestTurnRow) ? mapLatestTurn(latestTurnRow.value) : null, + createdAt: threadRow.value.createdAt, + updatedAt: threadRow.value.updatedAt, + archivedAt: threadRow.value.archivedAt, + deletedAt: null, + messages: messageRows.map((row) => { + const message = { + id: row.messageId, + role: row.role, + text: row.text, + turnId: row.turnId, + streaming: row.isStreaming === 1, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; + if (row.attachments !== null) { + return Object.assign(message, { attachments: row.attachments }); + } + return message; + }), + proposedPlans: proposedPlanRows.map((row) => ({ + id: row.planId, + turnId: row.turnId, + planMarkdown: row.planMarkdown, + implementedAt: row.implementedAt, + implementationThreadId: row.implementationThreadId, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + })), + activities: activityRows.map((row) => { + const activity = { + id: row.activityId, + tone: row.tone, + kind: row.kind, + summary: row.summary, + payload: row.payload, + turnId: row.turnId, + createdAt: row.createdAt, + }; + if (row.sequence !== null) { + return Object.assign(activity, { sequence: row.sequence }); + } + return activity; + }), + checkpoints: checkpointRows.map((row) => ({ + turnId: row.turnId, + checkpointTurnCount: row.checkpointTurnCount, + checkpointRef: row.checkpointRef, + status: row.status, + files: row.files, + assistantMessageId: row.assistantMessageId, + completedAt: row.completedAt, + })), + session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null, + }; + + return Option.some( + yield* decodeThread(thread).pipe( + Effect.mapError( + toPersistenceDecodeError("ProjectionSnapshotQuery.getThreadDetailById:decodeThread"), + ), + ), + ); + }); + return { getSnapshot, getListingSnapshot, getThread, + getShellSnapshot, getCounts, getActiveProjectByWorkspaceRoot, + getProjectShellById, getFirstActiveThreadIdByProjectId, getThreadCheckpointContext, + getThreadShellById, + getThreadDetailById, } satisfies ProjectionSnapshotQueryShape; }); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 28e8ae0d1c5..d0b3575e40b 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -27,6 +27,10 @@ import { type ProviderServiceShape, } from "../../provider/Services/ProviderService.ts"; import { GitCore, type GitCoreShape } from "../../git/Services/GitCore.ts"; +import { + GitStatusBroadcaster, + type GitStatusBroadcasterShape, +} from "../../git/Services/GitStatusBroadcaster.ts"; import { TextGeneration, type TextGenerationShape } from "../../git/Services/TextGeneration.ts"; import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; @@ -177,6 +181,24 @@ describe("ProviderCommandReactor", () => { : "renamed-branch", }), ); + const refreshStatus = vi.fn((_: string) => + Effect.succeed({ + isRepo: true, + hasOriginRemote: true, + isDefaultBranch: false, + branch: "renamed-branch", + hasWorkingTreeChanges: false, + workingTree: { + files: [], + insertions: 0, + deletions: 0, + }, + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, + }), + ); const generateBranchName = vi.fn((_) => Effect.fail( new TextGenerationError({ @@ -225,6 +247,15 @@ describe("ProviderCommandReactor", () => { Layer.provideMerge(orchestrationLayer), Layer.provideMerge(Layer.succeed(ProviderService, service)), Layer.provideMerge(Layer.succeed(GitCore, { renameBranch } as unknown as GitCoreShape)), + Layer.provideMerge( + Layer.succeed(GitStatusBroadcaster, { + getStatus: () => Effect.die("getStatus should not be called in this test"), + refreshLocalStatus: () => + Effect.die("refreshLocalStatus should not be called in this test"), + refreshStatus, + streamStatus: () => Stream.die("streamStatus should not be called in this test"), + } satisfies GitStatusBroadcasterShape), + ), Layer.provideMerge( Layer.mock(TextGeneration, { generateBranchName, @@ -279,6 +310,7 @@ describe("ProviderCommandReactor", () => { respondToUserInput, stopSession, renameBranch, + refreshStatus, generateBranchName, generateThreadTitle, stateDir, @@ -513,9 +545,11 @@ describe("ProviderCommandReactor", () => { ); await waitFor(() => harness.generateBranchName.mock.calls.length === 1); + await waitFor(() => harness.refreshStatus.mock.calls.length === 1); expect(harness.generateBranchName.mock.calls[0]?.[0]).toMatchObject({ message: "Add a safer reconnect backoff.", }); + expect(harness.refreshStatus.mock.calls[0]?.[0]).toBe("/tmp/provider-project-worktree"); }); it("forwards codex model options through session start and turn send", async () => { @@ -1241,6 +1275,61 @@ describe("ProviderCommandReactor", () => { }); }); + it("starts a fresh session when only projected session state exists", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-set-stale"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("thread-1"), + status: "ready", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + createdAt: now, + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-stale"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-stale"), + role: "user", + text: "resume codex", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 1); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + + expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ + threadId: ThreadId.make("thread-1"), + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + runtimeMode: "approval-required", + }); + expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ + threadId: ThreadId.make("thread-1"), + }); + }); + it("reacts to thread.approval.respond by forwarding provider approval response", async () => { const harness = await createHarness(); const now = new Date().toISOString(); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 365854a76d6..7ec1ce4be78 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -11,6 +11,7 @@ import { type RuntimeMode, type TurnId, } from "@marcode/contracts"; +import { isTemporaryWorktreeBranch, WORKTREE_BRANCH_PREFIX } from "@marcode/shared/git"; import { Cache, Cause, Duration, Effect, Equal, Layer, Option, Schema, Stream } from "effect"; import { makeDrainableWorker } from "@marcode/shared/DrainableWorker"; @@ -18,8 +19,10 @@ import { existsSync } from "node:fs"; import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; import { GitCore } from "../../git/Services/GitCore.ts"; +import { GitStatusBroadcaster } from "../../git/Services/GitStatusBroadcaster.ts"; import { increment, orchestrationEventsProcessedTotal } from "../../observability/Metrics.ts"; -import { ProviderAdapterRequestError, ProviderServiceError } from "../../provider/Errors.ts"; +import { ProviderAdapterRequestError } from "../../provider/Errors.ts"; +import type { ProviderServiceError } from "../../provider/Errors.ts"; import { TextGeneration } from "../../git/Services/TextGeneration.ts"; import { ProviderService } from "../../provider/Services/ProviderService.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; @@ -74,8 +77,6 @@ const serverCommandId = (tag: string): CommandId => const HANDLED_TURN_START_KEY_MAX = 10_000; const HANDLED_TURN_START_KEY_TTL = Duration.minutes(30); const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; -const WORKTREE_BRANCH_PREFIX = "marcode"; -const TEMP_WORKTREE_BRANCH_PATTERN = new RegExp(`^${WORKTREE_BRANCH_PREFIX}\\/[0-9a-f]{8}$`); const DEFAULT_THREAD_TITLE = "New thread"; function canReplaceThreadTitle(currentTitle: string, titleSeed?: string): boolean { @@ -121,10 +122,6 @@ function stalePendingRequestDetail( return `Stale pending ${requestKind} request: ${requestId}. Provider callback state does not survive app restarts or recovered sessions. Restart the turn to continue.`; } -function isTemporaryWorktreeBranch(branch: string): boolean { - return TEMP_WORKTREE_BRANCH_PATTERN.test(branch.trim().toLowerCase()); -} - function buildGeneratedWorktreeBranchName(raw: string): string { const normalized = raw .trim() @@ -152,6 +149,7 @@ const make = Effect.gen(function* () { const orchestrationEngine = yield* OrchestrationEngineService; const providerService = yield* ProviderService; const git = yield* GitCore; + const gitStatusBroadcaster = yield* GitStatusBroadcaster; const textGeneration = yield* TextGeneration; const serverSettingsService = yield* ServerSettingsService; const handledTurnStartKeys = yield* Cache.make({ @@ -318,14 +316,14 @@ const make = Effect.gen(function* () { createdAt, }); + const activeSession = yield* resolveActiveSession(threadId); const existingSessionThreadId = - thread.session && thread.session.status !== "stopped" ? thread.id : null; + thread.session && thread.session.status !== "stopped" && activeSession ? thread.id : null; if (existingSessionThreadId) { const runtimeModeChanged = thread.runtimeMode !== thread.session?.runtimeMode; const providerChanged = requestedModelSelection !== undefined && requestedModelSelection.provider !== currentProvider; - const activeSession = yield* resolveActiveSession(existingSessionThreadId); const sessionModelSwitch = currentProvider === undefined ? "in-session" @@ -493,6 +491,7 @@ const make = Effect.gen(function* () { branch: renamed.branch, worktreePath: cwd, }); + yield* gitStatusBroadcaster.refreshStatus(cwd).pipe(Effect.ignoreCause({ log: true })); }).pipe( Effect.catchCause((cause) => Effect.logWarning("provider command reactor failed to generate or rename worktree branch", { diff --git a/apps/server/src/orchestration/Normalizer.ts b/apps/server/src/orchestration/Normalizer.ts index 853bbeee026..86d6cebf625 100644 --- a/apps/server/src/orchestration/Normalizer.ts +++ b/apps/server/src/orchestration/Normalizer.ts @@ -6,10 +6,10 @@ import { PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, } from "@marcode/contracts"; -import { createAttachmentId, resolveAttachmentPath } from "../attachmentStore"; -import { ServerConfig } from "../config"; -import { parseBase64DataUrl } from "../imageMime"; -import { WorkspacePaths } from "../workspace/Services/WorkspacePaths"; +import { createAttachmentId, resolveAttachmentPath } from "../attachmentStore.ts"; +import { ServerConfig } from "../config.ts"; +import { parseBase64DataUrl } from "../imageMime.ts"; +import { WorkspacePaths } from "../workspace/Services/WorkspacePaths.ts"; export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => Effect.gen(function* () { @@ -28,10 +28,31 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => ), ); + const normalizeProjectWorkspaceRootForCreate = ( + workspaceRoot: string, + createIfMissing: boolean | undefined, + ) => + workspacePaths + .normalizeWorkspaceRoot(workspaceRoot, { + createIfMissing: createIfMissing === true, + }) + .pipe( + Effect.mapError( + (cause) => + new OrchestrationDispatchCommandError({ + message: cause.message, + }), + ), + ); + if (command.type === "project.create") { return { ...command, - workspaceRoot: yield* normalizeProjectWorkspaceRoot(command.workspaceRoot), + workspaceRoot: yield* normalizeProjectWorkspaceRootForCreate( + command.workspaceRoot, + command.createWorkspaceRootIfMissing, + ), + createWorkspaceRootIfMissing: command.createWorkspaceRootIfMissing === true, } satisfies OrchestrationCommand; } diff --git a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts index 4d464c58fe2..2a9f84a5b39 100644 --- a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts @@ -10,8 +10,11 @@ import type { OrchestrationCheckpointSummary, OrchestrationListingSnapshot, OrchestrationProject, + OrchestrationProjectShell, OrchestrationReadModel, + OrchestrationShellSnapshot, OrchestrationThread, + OrchestrationThreadShell, ProjectId, ThreadId, } from "@marcode/contracts"; @@ -55,6 +58,20 @@ export interface ProjectionSnapshotQueryShape { threadId: ThreadId, ) => Effect.Effect, ProjectionRepositoryError>; + /** + * Read the latest orchestration shell snapshot. + * + * Returns only projects and thread shell summaries so clients can bootstrap + * lightweight navigation state without hydrating every thread body. + */ + readonly getShellSnapshot: () => Effect.Effect< + OrchestrationShellSnapshot, + ProjectionRepositoryError + >; + + /** + * Read aggregate projection counts without hydrating the full read model. + */ readonly getCounts: () => Effect.Effect; /** @@ -64,6 +81,13 @@ export interface ProjectionSnapshotQueryShape { workspaceRoot: string, ) => Effect.Effect, ProjectionRepositoryError>; + /** + * Read a single active project shell row by id. + */ + readonly getProjectShellById: ( + projectId: ProjectId, + ) => Effect.Effect, ProjectionRepositoryError>; + /** * Read the earliest active thread for a project. */ @@ -77,6 +101,20 @@ export interface ProjectionSnapshotQueryShape { readonly getThreadCheckpointContext: ( threadId: ThreadId, ) => Effect.Effect, ProjectionRepositoryError>; + + /** + * Read a single active thread shell row by id. + */ + readonly getThreadShellById: ( + threadId: ThreadId, + ) => Effect.Effect, ProjectionRepositoryError>; + + /** + * Read a single active thread detail snapshot by id. + */ + readonly getThreadDetailById: ( + threadId: ThreadId, + ) => Effect.Effect, ProjectionRepositoryError>; } /** diff --git a/apps/server/src/os-jank.test.ts b/apps/server/src/os-jank.test.ts index ca03ab58682..c49a4120a54 100644 --- a/apps/server/src/os-jank.test.ts +++ b/apps/server/src/os-jank.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it, vi } from "vitest"; -import { fixPath } from "./os-jank"; +import { fixPath } from "./os-jank.ts"; describe("fixPath", () => { it("hydrates PATH on linux using the resolved login shell", () => { const env: NodeJS.ProcessEnv = { SHELL: "/bin/zsh", - PATH: "/usr/bin", + PATH: "/Users/test/.local/bin:/usr/bin", }; const readPath = vi.fn(() => "/opt/homebrew/bin:/usr/bin"); @@ -17,10 +17,156 @@ describe("fixPath", () => { }); expect(readPath).toHaveBeenCalledWith("/bin/zsh"); + expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin:/Users/test/.local/bin"); + }); + + it("falls back to launchctl PATH on macOS when shell probing fails", () => { + const env: NodeJS.ProcessEnv = { + SHELL: "/opt/homebrew/bin/nu", + PATH: "/usr/bin", + }; + const readPath = vi + .fn() + .mockImplementationOnce(() => { + throw new Error("unknown flag"); + }) + .mockImplementationOnce(() => undefined); + const readLaunchctlPath = vi.fn(() => "/opt/homebrew/bin:/usr/bin"); + const logWarning = vi.fn(); + + fixPath({ + env, + platform: "darwin", + readPath, + readLaunchctlPath, + userShell: "/bin/zsh", + logWarning, + }); + + expect(readPath).toHaveBeenNthCalledWith(1, "/opt/homebrew/bin/nu"); + expect(readPath).toHaveBeenNthCalledWith(2, "/bin/zsh"); + expect(readLaunchctlPath).toHaveBeenCalledTimes(1); + expect(logWarning).toHaveBeenCalledWith( + "Failed to read PATH from login shell /opt/homebrew/bin/nu.", + expect.any(Error), + ); expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin"); }); - it("does nothing outside macOS and linux even when SHELL is set", () => { + it("repairs PATH on Windows by merging PowerShell PATH with inherited PATH", () => { + const env: NodeJS.ProcessEnv = { + PATH: "C:\\Windows\\System32", + APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", + LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local", + USERPROFILE: "C:\\Users\\testuser", + }; + const readWindowsEnvironment = vi.fn(() => ({ + PATH: "C:\\Custom\\Bin;C:\\Windows\\System32", + })); + const isWindowsCommandAvailable = vi.fn(() => true); + + fixPath({ + env, + platform: "win32", + readWindowsEnvironment, + isWindowsCommandAvailable, + }); + + expect(readWindowsEnvironment).toHaveBeenCalledWith(["PATH"], { loadProfile: false }); + expect(env.PATH).toBe( + [ + "C:\\Users\\testuser\\AppData\\Roaming\\npm", + "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", + "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", + "C:\\Users\\testuser\\AppData\\Local\\pnpm", + "C:\\Users\\testuser\\.bun\\bin", + "C:\\Users\\testuser\\scoop\\shims", + "C:\\Custom\\Bin", + "C:\\Windows\\System32", + ].join(";"), + ); + }); + + it("applies profile-derived fnm variables on Windows when node is missing", () => { + const env: NodeJS.ProcessEnv = { + PATH: "C:\\Windows\\System32", + APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", + LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local", + USERPROFILE: "C:\\Users\\testuser", + }; + const readWindowsEnvironment = vi.fn( + (_names: ReadonlyArray, options?: { loadProfile?: boolean }) => + options?.loadProfile + ? { + PATH: "C:\\Profile\\Node;C:\\Windows\\System32", + FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm", + FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123", + } + : { PATH: "C:\\Custom\\Bin;C:\\Windows\\System32" }, + ); + const isWindowsCommandAvailable = vi.fn().mockReturnValueOnce(false).mockReturnValueOnce(true); + + fixPath({ + env, + platform: "win32", + readWindowsEnvironment, + isWindowsCommandAvailable, + }); + + expect(env.PATH).toBe( + [ + "C:\\Profile\\Node", + "C:\\Windows\\System32", + "C:\\Users\\testuser\\AppData\\Roaming\\npm", + "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", + "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", + "C:\\Users\\testuser\\AppData\\Local\\pnpm", + "C:\\Users\\testuser\\.bun\\bin", + "C:\\Users\\testuser\\scoop\\shims", + "C:\\Custom\\Bin", + ].join(";"), + ); + expect(env.FNM_DIR).toBe("C:\\Users\\testuser\\AppData\\Roaming\\fnm"); + expect(env.FNM_MULTISHELL_PATH).toBe( + "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123", + ); + }); + + it("preserves baseline PATH on Windows when the profile probe fails", () => { + const env: NodeJS.ProcessEnv = { + PATH: "C:\\Windows\\System32", + APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", + USERPROFILE: "C:\\Users\\testuser", + }; + const readWindowsEnvironment = vi.fn( + (_names: ReadonlyArray, options?: { loadProfile?: boolean }) => { + if (options?.loadProfile) { + throw new Error("profile load failed"); + } + return { PATH: "C:\\Custom\\Bin;C:\\Windows\\System32" }; + }, + ); + const isWindowsCommandAvailable = vi.fn(() => false); + + fixPath({ + env, + platform: "win32", + readWindowsEnvironment, + isWindowsCommandAvailable, + }); + + expect(env.PATH).toBe( + [ + "C:\\Users\\testuser\\AppData\\Roaming\\npm", + "C:\\Users\\testuser\\.bun\\bin", + "C:\\Users\\testuser\\scoop\\shims", + "C:\\Custom\\Bin", + "C:\\Windows\\System32", + ].join(";"), + ); + }); + + it("does nothing on unsupported platforms", () => { const env: NodeJS.ProcessEnv = { SHELL: "C:/Program Files/Git/bin/bash.exe", PATH: "C:\\Windows\\System32", @@ -29,7 +175,7 @@ describe("fixPath", () => { fixPath({ env, - platform: "win32", + platform: "freebsd", readPath, }); diff --git a/apps/server/src/os-jank.ts b/apps/server/src/os-jank.ts index e5e6333e4dc..608d96c5466 100644 --- a/apps/server/src/os-jank.ts +++ b/apps/server/src/os-jank.ts @@ -1,28 +1,83 @@ import * as OS from "node:os"; import { Effect, Path } from "effect"; -import { readPathFromLoginShell, resolveLoginShell } from "@marcode/shared/shell"; +import { + readPathFromLoginShell, + readEnvironmentFromWindowsShell, + resolveWindowsEnvironment, + type CommandAvailabilityOptions, + type WindowsShellEnvironmentReader, + listLoginShellCandidates, + mergePathEntries, + readPathFromLaunchctl, +} from "@marcode/shared/shell"; + +type WindowsCommandAvailabilityChecker = ( + command: string, + options?: CommandAvailabilityOptions, +) => boolean; + +function logPathHydrationWarning(message: string, error?: unknown): void { + console.warn(`[server] ${message}`, error instanceof Error ? error.message : (error ?? "")); +} export function fixPath( options: { env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform; readPath?: typeof readPathFromLoginShell; + readWindowsEnvironment?: WindowsShellEnvironmentReader; + isWindowsCommandAvailable?: WindowsCommandAvailabilityChecker; + readLaunchctlPath?: typeof readPathFromLaunchctl; + userShell?: string; + logWarning?: (message: string, error?: unknown) => void; } = {}, ): void { const platform = options.platform ?? process.platform; - if (platform !== "darwin" && platform !== "linux") return; - const env = options.env ?? process.env; + const logWarning = options.logWarning ?? logPathHydrationWarning; + const readPath = options.readPath ?? readPathFromLoginShell; try { - const shell = resolveLoginShell(platform, env.SHELL); - if (!shell) return; - const result = (options.readPath ?? readPathFromLoginShell)(shell); - if (result) { - env.PATH = result; + if (platform === "win32") { + const repairedEnvironment = resolveWindowsEnvironment(env, { + readEnvironment: options.readWindowsEnvironment ?? readEnvironmentFromWindowsShell, + ...(options.isWindowsCommandAvailable + ? { commandAvailable: options.isWindowsCommandAvailable } + : {}), + }); + for (const [key, value] of Object.entries(repairedEnvironment)) { + if (value !== undefined) { + env[key] = value; + } + } + return; + } + + if (platform !== "darwin" && platform !== "linux") return; + + let shellPath: string | undefined; + for (const shell of listLoginShellCandidates(platform, env.SHELL, options.userShell)) { + try { + shellPath = readPath(shell); + } catch (error) { + logWarning(`Failed to read PATH from login shell ${shell}.`, error); + } + + if (shellPath) { + break; + } + } + + const launchctlPath = + platform === "darwin" && !shellPath + ? (options.readLaunchctlPath ?? readPathFromLaunchctl)() + : undefined; + const mergedPath = mergePathEntries(shellPath ?? launchctlPath, env.PATH, platform); + if (mergedPath) { + env.PATH = mergedPath; } - } catch { - // Silently ignore — keep default PATH + } catch (error) { + logWarning("Failed to hydrate PATH from the user environment.", error); } } diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index 5f8d10868c0..84ff4687c88 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -90,6 +90,10 @@ projectionRepositoriesLayer("Projection repositories", (it) => { createdAt: "2026-03-24T00:00:00.000Z", updatedAt: "2026-03-24T00:00:00.000Z", archivedAt: null, + latestUserMessageAt: null, + pendingApprovalCount: 0, + pendingUserInputCount: 0, + hasActionableProposedPlan: 0, deletedAt: null, }); diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index f3ded552beb..938104a1194 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -42,6 +42,10 @@ const makeProjectionThreadRepository = Effect.gen(function* () { created_at, updated_at, archived_at, + latest_user_message_at, + pending_approval_count, + pending_user_input_count, + has_actionable_proposed_plan, deleted_at ) VALUES ( @@ -58,6 +62,10 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.createdAt}, ${row.updatedAt}, ${row.archivedAt}, + ${row.latestUserMessageAt}, + ${row.pendingApprovalCount}, + ${row.pendingUserInputCount}, + ${row.hasActionableProposedPlan}, ${row.deletedAt} ) ON CONFLICT (thread_id) @@ -74,6 +82,10 @@ const makeProjectionThreadRepository = Effect.gen(function* () { created_at = excluded.created_at, updated_at = excluded.updated_at, archived_at = excluded.archived_at, + latest_user_message_at = excluded.latest_user_message_at, + pending_approval_count = excluded.pending_approval_count, + pending_user_input_count = excluded.pending_user_input_count, + has_actionable_proposed_plan = excluded.has_actionable_proposed_plan, deleted_at = excluded.deleted_at `, }); @@ -97,6 +109,10 @@ const makeProjectionThreadRepository = Effect.gen(function* () { created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", + latest_user_message_at AS "latestUserMessageAt", + pending_approval_count AS "pendingApprovalCount", + pending_user_input_count AS "pendingUserInputCount", + has_actionable_proposed_plan AS "hasActionableProposedPlan", deleted_at AS "deletedAt" FROM projection_threads WHERE thread_id = ${threadId} @@ -122,6 +138,10 @@ const makeProjectionThreadRepository = Effect.gen(function* () { created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", + latest_user_message_at AS "latestUserMessageAt", + pending_approval_count AS "pendingApprovalCount", + pending_user_input_count AS "pendingUserInputCount", + has_actionable_proposed_plan AS "hasActionableProposedPlan", deleted_at AS "deletedAt" FROM projection_threads WHERE project_id = ${projectId} diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index c5bfd794793..d6b974d3011 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -38,6 +38,9 @@ import Migration0023 from "./Migrations/023_ProjectionSnapshotLookupIndexes.ts"; import Migration0024 from "./Migrations/024_AuthAccessManagement.ts"; import Migration0025 from "./Migrations/025_AuthSessionClientMetadata.ts"; import Migration0026 from "./Migrations/026_AuthSessionLastConnectedAt.ts"; +import Migration0027 from "./Migrations/027_ProjectionThreadShellSummary.ts"; +import Migration0028 from "./Migrations/028_BackfillProjectionThreadShellSummary.ts"; +import Migration0029 from "./Migrations/029_CleanupInvalidProjectionPendingApprovals.ts"; /** * Migration loader with all migrations defined inline. @@ -75,6 +78,9 @@ export const migrationEntries = [ [24, "AuthAccessManagement", Migration0024], [25, "AuthSessionClientMetadata", Migration0025], [26, "AuthSessionLastConnectedAt", Migration0026], + [27, "ProjectionThreadShellSummary", Migration0027], + [28, "BackfillProjectionThreadShellSummary", Migration0028], + [29, "CleanupInvalidProjectionPendingApprovals", Migration0029], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/027_ProjectionThreadShellSummary.ts b/apps/server/src/persistence/Migrations/027_ProjectionThreadShellSummary.ts new file mode 100644 index 00000000000..759f87e8ad7 --- /dev/null +++ b/apps/server/src/persistence/Migrations/027_ProjectionThreadShellSummary.ts @@ -0,0 +1,26 @@ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as Effect from "effect/Effect"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN latest_user_message_at TEXT + `.pipe(Effect.catch(() => Effect.void)); + + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN pending_approval_count INTEGER NOT NULL DEFAULT 0 + `.pipe(Effect.catch(() => Effect.void)); + + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN pending_user_input_count INTEGER NOT NULL DEFAULT 0 + `.pipe(Effect.catch(() => Effect.void)); + + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN has_actionable_proposed_plan INTEGER NOT NULL DEFAULT 0 + `.pipe(Effect.catch(() => Effect.void)); +}); diff --git a/apps/server/src/persistence/Migrations/028_BackfillProjectionThreadShellSummary.test.ts b/apps/server/src/persistence/Migrations/028_BackfillProjectionThreadShellSummary.test.ts new file mode 100644 index 00000000000..d74bc438e1d --- /dev/null +++ b/apps/server/src/persistence/Migrations/028_BackfillProjectionThreadShellSummary.test.ts @@ -0,0 +1,218 @@ +import { assert, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("028_BackfillProjectionThreadShellSummary", (it) => { + it.effect("backfills thread shell summary fields and clears stale projected approvals", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 27 }); + + yield* sql` + INSERT INTO projection_threads ( + thread_id, + project_id, + title, + model_selection_json, + runtime_mode, + interaction_mode, + branch, + worktree_path, + latest_turn_id, + created_at, + updated_at, + archived_at, + latest_user_message_at, + pending_approval_count, + pending_user_input_count, + has_actionable_proposed_plan, + deleted_at + ) + VALUES ( + 'thread-1', + 'project-1', + 'Thread 1', + '{"provider":"codex","model":"gpt-5-codex"}', + 'approval-required', + 'plan', + NULL, + NULL, + 'turn-1', + '2026-02-24T00:00:00.000Z', + '2026-02-24T00:00:00.000Z', + NULL, + NULL, + 0, + 0, + 0, + NULL + ) + `; + + yield* sql` + INSERT INTO projection_thread_messages ( + message_id, + thread_id, + turn_id, + role, + text, + attachments_json, + is_streaming, + created_at, + updated_at + ) + VALUES ( + 'message-user-1', + 'thread-1', + 'turn-1', + 'user', + 'Need help', + NULL, + 0, + '2026-02-24T00:01:00.000Z', + '2026-02-24T00:01:00.000Z' + ) + `; + + yield* sql` + INSERT INTO projection_thread_activities ( + activity_id, + thread_id, + turn_id, + tone, + kind, + summary, + payload_json, + sequence, + created_at + ) + VALUES + ( + 'activity-approval-requested', + 'thread-1', + 'turn-1', + 'approval', + 'approval.requested', + 'Command approval requested', + '{"requestId":"approval-1","requestKind":"command"}', + NULL, + '2026-02-24T00:02:00.000Z' + ), + ( + 'activity-approval-stale', + 'thread-1', + 'turn-1', + 'error', + 'provider.approval.respond.failed', + 'Provider approval response failed', + '{"requestId":"approval-1","detail":"Unknown pending permission request: approval-1"}', + NULL, + '2026-02-24T00:03:00.000Z' + ), + ( + 'activity-user-input-requested', + 'thread-1', + 'turn-1', + 'info', + 'user-input.requested', + 'User input requested', + '{"requestId":"input-1","questions":[{"id":"area","header":"Area","question":"Which repo area should I inspect next?","options":[{"label":"Server","description":"Server orchestration."}]}]}', + NULL, + '2026-02-24T00:04:00.000Z' + ) + `; + + yield* sql` + INSERT INTO projection_thread_proposed_plans ( + plan_id, + thread_id, + turn_id, + plan_markdown, + implemented_at, + implementation_thread_id, + created_at, + updated_at + ) + VALUES ( + 'plan-1', + 'thread-1', + 'turn-1', + '# Do the thing', + NULL, + NULL, + '2026-02-24T00:05:00.000Z', + '2026-02-24T00:05:00.000Z' + ) + `; + + yield* sql` + INSERT INTO projection_pending_approvals ( + request_id, + thread_id, + turn_id, + status, + decision, + created_at, + resolved_at + ) + VALUES ( + 'approval-1', + 'thread-1', + 'turn-1', + 'pending', + NULL, + '2026-02-24T00:02:00.000Z', + NULL + ) + `; + + yield* runMigrations({ toMigrationInclusive: 28 }); + + const threadRows = yield* sql<{ + readonly latestUserMessageAt: string | null; + readonly pendingApprovalCount: number; + readonly pendingUserInputCount: number; + readonly hasActionableProposedPlan: number; + }>` + SELECT + latest_user_message_at AS "latestUserMessageAt", + pending_approval_count AS "pendingApprovalCount", + pending_user_input_count AS "pendingUserInputCount", + has_actionable_proposed_plan AS "hasActionableProposedPlan" + FROM projection_threads + WHERE thread_id = 'thread-1' + `; + assert.deepStrictEqual(threadRows, [ + { + latestUserMessageAt: "2026-02-24T00:01:00.000Z", + pendingApprovalCount: 0, + pendingUserInputCount: 1, + hasActionableProposedPlan: 1, + }, + ]); + + const approvalRows = yield* sql<{ + readonly status: string; + readonly resolvedAt: string | null; + }>` + SELECT + status, + resolved_at AS "resolvedAt" + FROM projection_pending_approvals + WHERE request_id = 'approval-1' + `; + assert.deepStrictEqual(approvalRows, [ + { + status: "resolved", + resolvedAt: "2026-02-24T00:03:00.000Z", + }, + ]); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/028_BackfillProjectionThreadShellSummary.ts b/apps/server/src/persistence/Migrations/028_BackfillProjectionThreadShellSummary.ts new file mode 100644 index 00000000000..549906dfb03 --- /dev/null +++ b/apps/server/src/persistence/Migrations/028_BackfillProjectionThreadShellSummary.ts @@ -0,0 +1,277 @@ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as Effect from "effect/Effect"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + INSERT OR IGNORE INTO projection_pending_approvals ( + request_id, + thread_id, + turn_id, + status, + decision, + created_at, + resolved_at + ) + SELECT + requested.request_id, + requested.thread_id, + requested.turn_id, + 'pending', + NULL, + requested.created_at, + NULL + FROM ( + SELECT + json_extract(payload_json, '$.requestId') AS request_id, + thread_id, + turn_id, + created_at, + ROW_NUMBER() OVER ( + PARTITION BY json_extract(payload_json, '$.requestId') + ORDER BY created_at ASC, activity_id ASC + ) AS row_number + FROM projection_thread_activities + WHERE kind = 'approval.requested' + AND json_extract(payload_json, '$.requestId') IS NOT NULL + ) AS requested + WHERE requested.row_number = 1 + `; + + yield* sql` + WITH latest_resolutions AS ( + SELECT + resolved.request_id, + resolved.resolved_at, + resolved.decision + FROM ( + SELECT + json_extract(payload_json, '$.requestId') AS request_id, + created_at AS resolved_at, + CASE + WHEN json_extract(payload_json, '$.decision') IN ( + 'accept', + 'acceptForSession', + 'decline', + 'cancel' + ) + THEN json_extract(payload_json, '$.decision') + ELSE NULL + END AS decision, + ROW_NUMBER() OVER ( + PARTITION BY json_extract(payload_json, '$.requestId') + ORDER BY created_at DESC, activity_id DESC + ) AS row_number + FROM projection_thread_activities + WHERE kind = 'approval.resolved' + AND json_extract(payload_json, '$.requestId') IS NOT NULL + ) AS resolved + WHERE resolved.row_number = 1 + ) + UPDATE projection_pending_approvals + SET + status = 'resolved', + decision = ( + SELECT latest_resolutions.decision + FROM latest_resolutions + WHERE latest_resolutions.request_id = projection_pending_approvals.request_id + ), + resolved_at = ( + SELECT latest_resolutions.resolved_at + FROM latest_resolutions + WHERE latest_resolutions.request_id = projection_pending_approvals.request_id + ) + WHERE EXISTS ( + SELECT 1 + FROM latest_resolutions + WHERE latest_resolutions.request_id = projection_pending_approvals.request_id + ) + `; + + yield* sql` + WITH latest_response_events AS ( + SELECT + response.request_id, + response.resolved_at, + response.decision + FROM ( + SELECT + json_extract(payload_json, '$.requestId') AS request_id, + occurred_at AS resolved_at, + CASE + WHEN json_extract(payload_json, '$.decision') IN ( + 'accept', + 'acceptForSession', + 'decline', + 'cancel' + ) + THEN json_extract(payload_json, '$.decision') + ELSE NULL + END AS decision, + ROW_NUMBER() OVER ( + PARTITION BY json_extract(payload_json, '$.requestId') + ORDER BY occurred_at DESC, sequence DESC + ) AS row_number + FROM orchestration_events + WHERE event_type = 'thread.approval-response-requested' + AND json_extract(payload_json, '$.requestId') IS NOT NULL + ) AS response + WHERE response.row_number = 1 + ) + UPDATE projection_pending_approvals + SET + status = 'resolved', + decision = ( + SELECT latest_response_events.decision + FROM latest_response_events + WHERE latest_response_events.request_id = projection_pending_approvals.request_id + ), + resolved_at = ( + SELECT latest_response_events.resolved_at + FROM latest_response_events + WHERE latest_response_events.request_id = projection_pending_approvals.request_id + ) + WHERE EXISTS ( + SELECT 1 + FROM latest_response_events + WHERE latest_response_events.request_id = projection_pending_approvals.request_id + ) + `; + + yield* sql` + WITH latest_stale_failures AS ( + SELECT + failure.request_id, + failure.resolved_at + FROM ( + SELECT + json_extract(payload_json, '$.requestId') AS request_id, + created_at AS resolved_at, + ROW_NUMBER() OVER ( + PARTITION BY json_extract(payload_json, '$.requestId') + ORDER BY created_at DESC, activity_id DESC + ) AS row_number + FROM projection_thread_activities + WHERE kind = 'provider.approval.respond.failed' + AND json_extract(payload_json, '$.requestId') IS NOT NULL + AND ( + lower(COALESCE(json_extract(payload_json, '$.detail'), '')) + LIKE '%stale pending approval request%' + OR lower(COALESCE(json_extract(payload_json, '$.detail'), '')) + LIKE '%unknown pending approval request%' + OR lower(COALESCE(json_extract(payload_json, '$.detail'), '')) + LIKE '%unknown pending permission request%' + ) + ) AS failure + WHERE failure.row_number = 1 + ) + UPDATE projection_pending_approvals + SET + status = 'resolved', + decision = NULL, + resolved_at = ( + SELECT latest_stale_failures.resolved_at + FROM latest_stale_failures + WHERE latest_stale_failures.request_id = projection_pending_approvals.request_id + ) + WHERE status = 'pending' + AND EXISTS ( + SELECT 1 + FROM latest_stale_failures + WHERE latest_stale_failures.request_id = projection_pending_approvals.request_id + ) + `; + + yield* sql` + UPDATE projection_threads + SET + latest_user_message_at = ( + SELECT MAX(message.created_at) + FROM projection_thread_messages AS message + WHERE message.thread_id = projection_threads.thread_id + AND message.role = 'user' + ), + pending_approval_count = COALESCE(( + SELECT COUNT(*) + FROM projection_pending_approvals + WHERE projection_pending_approvals.thread_id = projection_threads.thread_id + AND projection_pending_approvals.status = 'pending' + ), 0), + pending_user_input_count = COALESCE(( + WITH latest_user_input_states AS ( + SELECT + latest.request_id, + latest.kind, + latest.detail + FROM ( + SELECT + json_extract(activity.payload_json, '$.requestId') AS request_id, + activity.kind, + lower(COALESCE(json_extract(activity.payload_json, '$.detail'), '')) AS detail, + ROW_NUMBER() OVER ( + PARTITION BY json_extract(activity.payload_json, '$.requestId') + ORDER BY activity.created_at DESC, activity.activity_id DESC + ) AS row_number + FROM projection_thread_activities AS activity + WHERE activity.thread_id = projection_threads.thread_id + AND json_extract(activity.payload_json, '$.requestId') IS NOT NULL + AND activity.kind IN ( + 'user-input.requested', + 'user-input.resolved', + 'provider.user-input.respond.failed' + ) + ) AS latest + WHERE latest.row_number = 1 + ) + SELECT COUNT(*) + FROM latest_user_input_states + WHERE latest_user_input_states.kind = 'user-input.requested' + OR ( + latest_user_input_states.kind = 'provider.user-input.respond.failed' + AND latest_user_input_states.detail NOT LIKE '%stale pending user-input request%' + AND latest_user_input_states.detail NOT LIKE '%unknown pending user-input request%' + ) + ), 0), + has_actionable_proposed_plan = COALESCE(( + SELECT CASE + WHEN projection_threads.latest_turn_id IS NOT NULL + AND EXISTS ( + SELECT 1 + FROM projection_thread_proposed_plans AS latest_turn_plan_exists + WHERE latest_turn_plan_exists.thread_id = projection_threads.thread_id + AND latest_turn_plan_exists.turn_id = projection_threads.latest_turn_id + ) + THEN CASE + WHEN ( + SELECT latest_turn_plan.implemented_at + FROM projection_thread_proposed_plans AS latest_turn_plan + WHERE latest_turn_plan.thread_id = projection_threads.thread_id + AND latest_turn_plan.turn_id = projection_threads.latest_turn_id + ORDER BY latest_turn_plan.updated_at DESC, latest_turn_plan.plan_id DESC + LIMIT 1 + ) IS NULL + THEN 1 + ELSE 0 + END + WHEN EXISTS ( + SELECT 1 + FROM projection_thread_proposed_plans AS any_plan + WHERE any_plan.thread_id = projection_threads.thread_id + ) + THEN CASE + WHEN ( + SELECT latest_plan.implemented_at + FROM projection_thread_proposed_plans AS latest_plan + WHERE latest_plan.thread_id = projection_threads.thread_id + ORDER BY latest_plan.updated_at DESC, latest_plan.plan_id DESC + LIMIT 1 + ) IS NULL + THEN 1 + ELSE 0 + END + ELSE 0 + END + ), 0) + `; +}); diff --git a/apps/server/src/persistence/Migrations/029_CleanupInvalidProjectionPendingApprovals.test.ts b/apps/server/src/persistence/Migrations/029_CleanupInvalidProjectionPendingApprovals.test.ts new file mode 100644 index 00000000000..c91caa35880 --- /dev/null +++ b/apps/server/src/persistence/Migrations/029_CleanupInvalidProjectionPendingApprovals.test.ts @@ -0,0 +1,196 @@ +import { assert, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("029_CleanupInvalidProjectionPendingApprovals", (it) => { + it.effect("removes pending-approval rows that do not come from approval requests", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 28 }); + + yield* sql` + INSERT INTO projection_threads ( + thread_id, + project_id, + title, + model_selection_json, + runtime_mode, + interaction_mode, + branch, + worktree_path, + latest_turn_id, + created_at, + updated_at, + archived_at, + latest_user_message_at, + pending_approval_count, + pending_user_input_count, + has_actionable_proposed_plan, + deleted_at + ) + VALUES + ( + 'thread-valid', + 'project-1', + 'Valid thread', + '{"provider":"codex","model":"gpt-5-codex"}', + 'approval-required', + 'default', + NULL, + NULL, + 'turn-valid', + '2026-04-13T00:00:00.000Z', + '2026-04-13T00:00:00.000Z', + NULL, + NULL, + 2, + 0, + 0, + NULL + ), + ( + 'thread-invalid', + 'project-1', + 'Invalid thread', + '{"provider":"codex","model":"gpt-5-codex"}', + 'approval-required', + 'default', + NULL, + NULL, + 'turn-invalid', + '2026-04-13T00:00:00.000Z', + '2026-04-13T00:00:00.000Z', + NULL, + NULL, + 1, + 0, + 0, + NULL + ) + `; + + yield* sql` + INSERT INTO projection_thread_activities ( + activity_id, + thread_id, + turn_id, + tone, + kind, + summary, + payload_json, + sequence, + created_at + ) + VALUES + ( + 'activity-approval-requested', + 'thread-valid', + 'turn-valid', + 'approval', + 'approval.requested', + 'Command approval requested', + '{"requestId":"approval-valid","requestKind":"command"}', + NULL, + '2026-04-13T00:01:00.000Z' + ), + ( + 'activity-user-input-requested', + 'thread-invalid', + 'turn-invalid', + 'info', + 'user-input.requested', + 'User input requested', + '{"requestId":"input-invalid","questions":[{"id":"scope","header":"Scope","question":"What should I inspect?","options":[{"label":"Server","description":"Inspect server code."}]}]}', + NULL, + '2026-04-13T00:02:00.000Z' + ) + `; + + yield* sql` + INSERT INTO projection_pending_approvals ( + request_id, + thread_id, + turn_id, + status, + decision, + created_at, + resolved_at + ) + VALUES + ( + 'approval-valid', + 'thread-valid', + 'turn-valid', + 'pending', + NULL, + '2026-04-13T00:01:00.000Z', + NULL + ), + ( + 'input-invalid', + 'thread-invalid', + 'turn-invalid', + 'pending', + NULL, + '2026-04-13T00:02:00.000Z', + NULL + ), + ( + 'input-invalid-resolved', + 'thread-valid', + 'turn-valid', + 'resolved', + NULL, + '2026-04-13T00:03:00.000Z', + '2026-04-13T00:04:00.000Z' + ) + `; + + yield* runMigrations({ toMigrationInclusive: 29 }); + + const approvalRows = yield* sql<{ + readonly requestId: string; + readonly status: string; + }>` + SELECT + request_id AS "requestId", + status + FROM projection_pending_approvals + ORDER BY request_id ASC + `; + assert.deepStrictEqual(approvalRows, [ + { + requestId: "approval-valid", + status: "pending", + }, + ]); + + const threadCounts = yield* sql<{ + readonly threadId: string; + readonly pendingApprovalCount: number; + }>` + SELECT + thread_id AS "threadId", + pending_approval_count AS "pendingApprovalCount" + FROM projection_threads + ORDER BY thread_id ASC + `; + assert.deepStrictEqual(threadCounts, [ + { + threadId: "thread-invalid", + pendingApprovalCount: 0, + }, + { + threadId: "thread-valid", + pendingApprovalCount: 1, + }, + ]); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/029_CleanupInvalidProjectionPendingApprovals.ts b/apps/server/src/persistence/Migrations/029_CleanupInvalidProjectionPendingApprovals.ts new file mode 100644 index 00000000000..33a6512c750 --- /dev/null +++ b/apps/server/src/persistence/Migrations/029_CleanupInvalidProjectionPendingApprovals.ts @@ -0,0 +1,27 @@ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as Effect from "effect/Effect"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + DELETE FROM projection_pending_approvals + WHERE NOT EXISTS ( + SELECT 1 + FROM projection_thread_activities AS activity + WHERE activity.kind = 'approval.requested' + AND json_extract(activity.payload_json, '$.requestId') + = projection_pending_approvals.request_id + ) + `; + + yield* sql` + UPDATE projection_threads + SET pending_approval_count = COALESCE(( + SELECT COUNT(*) + FROM projection_pending_approvals + WHERE projection_pending_approvals.thread_id = projection_threads.thread_id + AND projection_pending_approvals.status = 'pending' + ), 0) + `; +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index 5c78de8b260..1dedeae40ff 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -9,6 +9,7 @@ import { IsoDateTime, ModelSelection, + NonNegativeInt, ProjectId, ProviderInteractionMode, RuntimeMode, @@ -34,6 +35,10 @@ export const ProjectionThread = Schema.Struct({ createdAt: IsoDateTime, updatedAt: IsoDateTime, archivedAt: Schema.NullOr(IsoDateTime), + latestUserMessageAt: Schema.NullOr(IsoDateTime), + pendingApprovalCount: NonNegativeInt, + pendingUserInputCount: NonNegativeInt, + hasActionableProposedPlan: NonNegativeInt, deletedAt: Schema.NullOr(IsoDateTime), }); export type ProjectionThread = typeof ProjectionThread.Type; diff --git a/apps/server/src/processRunner.test.ts b/apps/server/src/processRunner.test.ts index dd909116d4d..15ad4daf09b 100644 --- a/apps/server/src/processRunner.test.ts +++ b/apps/server/src/processRunner.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { runProcess } from "./processRunner"; +import { runProcess } from "./processRunner.ts"; describe("runProcess", () => { it("fails when output exceeds max buffer in default mode", async () => { diff --git a/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts b/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts index 276106b68c3..115b10bb695 100644 --- a/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts +++ b/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts @@ -1,3 +1,5 @@ +import { realpathSync } from "node:fs"; + import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; import { Duration, Effect, FileSystem, Layer } from "effect"; @@ -10,6 +12,10 @@ import { RepositoryIdentityResolverLive, } from "./RepositoryIdentityResolver.ts"; +const normalizePathSeparators = (value: string) => value.replaceAll("\\", "/"); +const normalizeResolvedPath = (value: string) => + normalizePathSeparators(realpathSync.native(value)); + const git = (cwd: string, args: ReadonlyArray) => Effect.promise(() => runProcess("git", ["-C", cwd, ...args])); @@ -41,6 +47,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { expect(identity).not.toBeNull(); expect(identity?.canonicalKey).toBe("github.com/marcodehq/marcode"); + expect(normalizeResolvedPath(identity?.rootPath ?? "")).toBe(normalizeResolvedPath(cwd)); expect(identity?.displayName).toBe("marcodehq/marcode"); expect(identity?.provider).toBe("github"); expect(identity?.owner).toBe("marcodehq"); @@ -48,6 +55,27 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { }).pipe(Effect.provide(RepositoryIdentityResolverLive)), ); + it.effect("returns the git top-level root path when resolving from a nested workspace", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const repoRoot = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-repository-identity-nested-root-test-", + }); + const nestedWorkspace = `${repoRoot}/packages/web`; + + yield* fileSystem.makeDirectory(nestedWorkspace, { recursive: true }); + yield* git(repoRoot, ["init"]); + yield* git(repoRoot, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]); + + const resolver = yield* RepositoryIdentityResolver; + const identity = yield* resolver.resolve(nestedWorkspace); + + expect(identity).not.toBeNull(); + expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code"); + expect(normalizeResolvedPath(identity?.rootPath ?? "")).toBe(normalizeResolvedPath(repoRoot)); + }).pipe(Effect.provide(RepositoryIdentityResolverLive)), + ); + it.effect("returns null for non-git folders and repos without remotes", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -112,7 +140,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { ); it.effect( - "refreshes cached null identities after the negative TTL when a remote is configured later", + "keeps null identities cached across repeated resolves until the negative TTL expires", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -128,8 +156,10 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { yield* git(cwd, ["remote", "add", "origin", "git@github.com:MarCodeHQ/marcode.git"]); - const cachedIdentity = yield* resolver.resolve(cwd); - expect(cachedIdentity).toBeNull(); + for (const _attempt of [1, 2, 3]) { + const cachedIdentity = yield* resolver.resolve(cwd); + expect(cachedIdentity).toBeNull(); + } yield* TestClock.adjust(Duration.millis(120)); diff --git a/apps/server/src/project/Layers/RepositoryIdentityResolver.ts b/apps/server/src/project/Layers/RepositoryIdentityResolver.ts index ca44ef94377..04ae4af07a1 100644 --- a/apps/server/src/project/Layers/RepositoryIdentityResolver.ts +++ b/apps/server/src/project/Layers/RepositoryIdentityResolver.ts @@ -42,6 +42,7 @@ function pickPrimaryRemote( function buildRepositoryIdentity(input: { readonly remoteName: string; readonly remoteUrl: string; + readonly rootPath: string; }): RepositoryIdentity { const canonicalKey = normalizeGitRemoteUrl(input.remoteUrl); const hostingProvider = detectGitHostingProviderFromRemoteUrl(input.remoteUrl); @@ -57,6 +58,7 @@ function buildRepositoryIdentity(input: { remoteName: input.remoteName, remoteUrl: input.remoteUrl, }, + rootPath: input.rootPath, ...(repositoryPath ? { displayName: repositoryPath } : {}), ...(hostingProvider ? { provider: hostingProvider.kind } : {}), ...(owner ? { owner } : {}), @@ -66,7 +68,7 @@ function buildRepositoryIdentity(input: { const DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY = 512; const DEFAULT_POSITIVE_CACHE_TTL = Duration.minutes(1); -const DEFAULT_NEGATIVE_CACHE_TTL = Duration.seconds(10); +const DEFAULT_NEGATIVE_CACHE_TTL = Duration.minutes(1); interface RepositoryIdentityResolverOptions { readonly cacheCapacity?: number; @@ -108,7 +110,7 @@ async function resolveRepositoryIdentityFromCacheKey( } const remote = pickPrimaryRemote(parseRemoteFetchUrls(remoteResult.stdout)); - return remote ? buildRepositoryIdentity(remote) : null; + return remote ? buildRepositoryIdentity({ ...remote, rootPath: cacheKey }) : null; } catch { return null; } diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 9f31eea8885..25ee65753f6 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -353,7 +353,7 @@ describe("ClaudeAdapterLive", () => { ); }); - it.effect("defaults Claude Opus 4.7 sessions to high effort", () => { + it.effect("maps the Claude Opus 4.7 default effort to the SDK-supported max value", () => { const harness = makeHarness(); return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; @@ -368,14 +368,14 @@ describe("ClaudeAdapterLive", () => { }); const createInput = harness.getLastCreateQueryInput(); - assert.equal(createInput?.options.effort, "high"); + assert.equal(createInput?.options.effort, "max"); }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), Effect.provide(harness.layer), ); }); - it.effect("forwards xhigh effort for Claude Opus 4.7", () => { + it.effect("maps xhigh effort for Claude Opus 4.7 to the SDK-supported max value", () => { const harness = makeHarness(); return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; @@ -393,7 +393,7 @@ describe("ClaudeAdapterLive", () => { }); const createInput = harness.getLastCreateQueryInput(); - assert.equal(createInput?.options.effort, "xhigh"); + assert.equal(createInput?.options.effort, "max"); }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), Effect.provide(harness.layer), @@ -1015,6 +1015,97 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("falls back to a default plan step label for blank TodoWrite content", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 10).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + runtimeMode: "full-access", + }); + + const turn = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "hello", + attachments: [], + }); + + harness.query.emit({ + type: "stream_event", + session_id: "sdk-session-todo-plan", + uuid: "stream-todo-start", + parent_tool_use_id: null, + event: { + type: "content_block_start", + index: 1, + content_block: { + type: "tool_use", + id: "tool-todo-1", + name: "TodoWrite", + input: {}, + }, + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "stream_event", + session_id: "sdk-session-todo-plan", + uuid: "stream-todo-input", + parent_tool_use_id: null, + event: { + type: "content_block_delta", + index: 1, + delta: { + type: "input_json_delta", + partial_json: + '{"todos":[{"content":" ","status":"in_progress"},{"content":"Ship it","status":"completed"}]}', + }, + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "stream_event", + session_id: "sdk-session-todo-plan", + uuid: "stream-todo-stop", + parent_tool_use_id: null, + event: { + type: "content_block_stop", + index: 1, + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "sdk-session-todo-plan", + uuid: "result-todo-plan", + } as unknown as SDKMessage); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + const planUpdated = runtimeEvents.find((event) => event.type === "turn.plan.updated"); + assert.equal(planUpdated?.type, "turn.plan.updated"); + if (planUpdated?.type === "turn.plan.updated") { + assert.equal(String(planUpdated.turnId), String(turn.turnId)); + assert.deepEqual(planUpdated.payload.plan, [ + { step: "Task", status: "inProgress" }, + { step: "Ship it", status: "completed" }, + ]); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("classifies Claude Task tool invocations as collaboration agent work", () => { const harness = makeHarness(); return Effect.gen(function* () { @@ -1533,6 +1624,71 @@ describe("ClaudeAdapterLive", () => { }, ); + it.effect("closes the previous session before replacing an existing thread session", () => { + const queries: FakeClaudeQuery[] = []; + const layer = makeClaudeAdapterLive({ + createQuery: () => { + const query = new FakeClaudeQuery(); + queries.push(query); + return query; + }, + }).pipe( + Layer.provideMerge(ServerConfig.layerTest("/tmp/claude-adapter-test", "/tmp")), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(NodeServices.layer), + ); + + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 6).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + const firstSession = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + runtimeMode: "full-access", + }); + + const secondSession = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + runtimeMode: "full-access", + resumeCursor: firstSession.resumeCursor, + }); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + const activeSessions = yield* adapter.listSessions(); + + assert.equal(queries.length, 2); + assert.equal(queries[0]?.closeCalls, 1); + assert.equal(queries[1]?.closeCalls, 0); + assert.equal(yield* adapter.hasSession(THREAD_ID), true); + assert.equal(activeSessions.length, 1); + assert.deepEqual(activeSessions[0]?.resumeCursor, secondSession.resumeCursor); + assert.deepEqual( + runtimeEvents.map((event) => event.type), + [ + "session.started", + "session.configured", + "session.state.changed", + "session.started", + "session.configured", + "session.state.changed", + ], + ); + assert.equal( + runtimeEvents.some((event) => event.type === "session.exited"), + false, + ); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(layer), + ); + }); + it.effect("stopSession does not throw into the SDK prompt consumer", () => { // The SDK consumes user messages via `for await (... of prompt)`. // Stopping a session must end that loop cleanly — not throw an error. @@ -1770,6 +1926,150 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("clamps oversized Claude usage to the reported context window", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 7).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ + threadId: THREAD_ID, + input: "hello", + attachments: [], + }); + + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + duration_ms: 1234, + duration_api_ms: 1200, + num_turns: 1, + result: "done", + stop_reason: "end_turn", + session_id: "sdk-session-result-usage-clamped", + usage: { + total_tokens: 535000, + }, + modelUsage: { + "claude-opus-4-6": { + contextWindow: 200000, + maxOutputTokens: 64000, + }, + }, + } as unknown as SDKMessage); + harness.query.finish(); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + const usageEvent = runtimeEvents.find((event) => event.type === "thread.token-usage.updated"); + assert.equal(usageEvent?.type, "thread.token-usage.updated"); + if (usageEvent?.type === "thread.token-usage.updated") { + assert.deepEqual(usageEvent.payload, { + usage: { + usedTokens: 200000, + lastUsedTokens: 200000, + totalProcessedTokens: 535000, + maxTokens: 200000, + }, + }); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect( + "preserves oversized Claude result totals after task progress snapshots are recorded", + () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 9).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ + threadId: THREAD_ID, + input: "hello", + attachments: [], + }); + + harness.query.emit({ + type: "system", + subtype: "task_progress", + task_id: "task-usage-clamped", + description: "Thinking through the patch", + usage: { + total_tokens: 190000, + }, + session_id: "sdk-session-task-usage-clamped", + uuid: "task-usage-progress-clamped", + } as unknown as SDKMessage); + + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + duration_ms: 1234, + duration_api_ms: 1200, + num_turns: 1, + result: "done", + stop_reason: "end_turn", + session_id: "sdk-session-result-usage-clamped-after-progress", + usage: { + total_tokens: 535000, + }, + modelUsage: { + "claude-opus-4-6": { + contextWindow: 200000, + maxOutputTokens: 64000, + }, + }, + } as unknown as SDKMessage); + harness.query.finish(); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + const usageEvents = runtimeEvents.filter( + (event) => event.type === "thread.token-usage.updated", + ); + const finalUsageEvent = usageEvents.at(-1); + assert.equal(finalUsageEvent?.type, "thread.token-usage.updated"); + if (finalUsageEvent?.type === "thread.token-usage.updated") { + assert.deepEqual(finalUsageEvent.payload, { + usage: { + usedTokens: 190000, + lastUsedTokens: 190000, + totalProcessedTokens: 535000, + maxTokens: 200000, + }, + }); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }, + ); + it.effect( "emits completion only after turn result when assistant frames arrive before deltas", () => { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 26a797fb299..3d3cc820b46 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -17,8 +17,9 @@ import { type SDKResultMessage, type SettingSource, type SDKUserMessage, - ModelUsage, + type ModelUsage, } from "@anthropic-ai/claude-agent-sdk"; +import { parseCliArgs } from "@marcode/shared/cliArgs"; import { ApprovalRequestId, type CanonicalItemType, @@ -85,6 +86,7 @@ type ClaudeToolResultStreamKind = Extract< RuntimeContentStreamKind, "command_output" | "file_change_output" >; +type ClaudeSdkEffort = NonNullable; type PromptQueueItem = | { @@ -236,11 +238,17 @@ function normalizeClaudeStreamMessages(cause: Cause.Cause): ReadonlyArray function getEffectiveClaudeAgentEffort( effort: ClaudeAgentEffort | null | undefined, -): Exclude | null { +): ClaudeSdkEffort | null { if (!effort) { return null; } - return effort === "ultrathink" ? null : effort; + if (effort === "ultrathink") { + return null; + } + if (effort === "xhigh") { + return "max"; + } + return effort; } function isClaudeInterruptedMessage(message: string): boolean { @@ -289,24 +297,14 @@ function asRuntimeItemId(value: string): RuntimeItemId { return RuntimeItemId.make(value); } -function maxClaudeContextWindowFromModelUsage(modelUsage: unknown): number | undefined { - if (!modelUsage || typeof modelUsage !== "object") { - return undefined; - } +function maxClaudeContextWindowFromModelUsage( + modelUsage: Record | undefined, +): number | undefined { + if (!modelUsage) return undefined; let maxContextWindow: number | undefined; - for (const value of Object.values(modelUsage as Record)) { - if (!value || typeof value !== "object") { - continue; - } - const contextWindow = (value as { contextWindow?: unknown }).contextWindow; - if ( - typeof contextWindow !== "number" || - !Number.isFinite(contextWindow) || - contextWindow <= 0 - ) { - continue; - } + for (const value of Object.values(modelUsage)) { + const contextWindow = value.contextWindow; maxContextWindow = Math.max(maxContextWindow ?? 0, contextWindow); } @@ -520,6 +518,37 @@ function classifyRequestType(toolName: string): CanonicalRequestType { : "dynamic_tool_call"; } +function isTodoTool(toolName: string): boolean { + return toolName.toLowerCase().includes("todowrite"); +} + +type PlanStep = { + step: string; + status: "pending" | "inProgress" | "completed"; +}; + +function extractPlanStepsFromTodoInput(input: Record): PlanStep[] | null { + // TodoWrite format: { todos: [{ content, status, activeForm? }] } + const todos = input.todos; + if (!Array.isArray(todos) || todos.length === 0) { + return null; + } + return todos + .filter((t): t is Record => t !== null && typeof t === "object") + .map((todo) => ({ + step: + typeof todo.content === "string" && todo.content.trim().length > 0 + ? todo.content.trim() + : "Task", + status: + todo.status === "completed" + ? "completed" + : todo.status === "in_progress" + ? "inProgress" + : "pending", + })); +} + function summarizeToolRequest(toolName: string, input: Record): string { const commandValue = input.command ?? input.cmd; const command = typeof commandValue === "string" ? commandValue : undefined; @@ -530,6 +559,20 @@ function summarizeToolRequest(toolName: string, input: Record): const friendly = friendlyToolSummary(toolName, input); if (friendly) return friendly; + // For agent/subagent tools, prefer human-readable description or prompt over raw JSON + const itemType = classifyToolItemType(toolName); + if (itemType === "collab_agent_tool_call") { + const description = + typeof input.description === "string" ? input.description.trim() : undefined; + const prompt = typeof input.prompt === "string" ? input.prompt.trim() : undefined; + const subagentType = + typeof input.subagent_type === "string" ? input.subagent_type.trim() : undefined; + const label = description || (prompt ? prompt.slice(0, 200) : undefined); + if (label) { + return subagentType ? `${subagentType}: ${label}` : label; + } + } + const serialized = JSON.stringify(input); if (serialized.length <= 400) { return `${toolName}: ${serialized}`; @@ -1077,7 +1120,11 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ((input: { readonly prompt: AsyncIterable; readonly options: ClaudeQueryOptions; - }) => query({ prompt: input.prompt, options: input.options }) as ClaudeQueryRuntime); + }) => + query({ + prompt: input.prompt, + options: input.options, + }) as ClaudeQueryRuntime); const sessions = new Map(); const runtimeEventQueue = yield* Queue.unbounded(); @@ -1116,7 +1163,11 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(typeof message.session_id === "string" ? { providerThreadId: message.session_id } : {}), - ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + ...(context.turnState + ? { + turnId: asCanonicalTurnId(context.turnState.turnId), + } + : {}), ...(itemId ? { itemId: ProviderItemId.make(itemId) } : {}), payload: message, }, @@ -1514,8 +1565,6 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( errorMessage?: string, result?: SDKResultMessage, ) { - const resultUsage = - result?.usage && typeof result.usage === "object" ? { ...result.usage } : undefined; const resultContextWindow = maxClaudeContextWindowFromModelUsage(result?.modelUsage); if (resultContextWindow !== undefined) { context.lastKnownContextWindow = resultContextWindow; @@ -1527,9 +1576,11 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( // Instead, use the last known context-window-accurate usage from task_progress // events and treat the accumulated total as totalProcessedTokens. const accumulatedSnapshot = normalizeClaudeTokenUsage( - resultUsage, + result?.usage, resultContextWindow ?? context.lastKnownContextWindow, ); + const accumulatedTotalProcessedTokens = + accumulatedSnapshot?.totalProcessedTokens ?? accumulatedSnapshot?.usedTokens; const lastGoodUsage = context.lastKnownTokenUsage; const maxTokens = resultContextWindow ?? context.lastKnownContextWindow; const usageSnapshot: ThreadTokenUsageSnapshot | undefined = lastGoodUsage @@ -1538,8 +1589,12 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(typeof maxTokens === "number" && Number.isFinite(maxTokens) && maxTokens > 0 ? { maxTokens } : {}), - ...(accumulatedSnapshot && accumulatedSnapshot.usedTokens > lastGoodUsage.usedTokens - ? { totalProcessedTokens: accumulatedSnapshot.usedTokens } + ...(typeof accumulatedTotalProcessedTokens === "number" && + Number.isFinite(accumulatedTotalProcessedTokens) && + accumulatedTotalProcessedTokens > lastGoodUsage.usedTokens + ? { + totalProcessedTokens: accumulatedTotalProcessedTokens, + } : {}), } : accumulatedSnapshot; @@ -1603,7 +1658,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( input: tool.input, }, }, - providerRefs: nativeProviderRefs(context, { providerItemId: tool.itemId }), + providerRefs: nativeProviderRefs(context, { + providerItemId: tool.itemId, + }), raw: { source: "claude.sdk.message", method: "claude/result", @@ -1726,7 +1783,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( threadId: context.session.threadId, turnId: context.turnState.turnId, ...(assistantBlockEntry?.block - ? { itemId: asRuntimeItemId(assistantBlockEntry.block.itemId) } + ? { + itemId: asRuntimeItemId(assistantBlockEntry.block.itemId), + } : {}), payload: { streamKind, @@ -1785,7 +1844,11 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( provider: PROVIDER, createdAt: stamp.createdAt, threadId: context.session.threadId, - ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + ...(context.turnState + ? { + turnId: asCanonicalTurnId(context.turnState.turnId), + } + : {}), itemId: asRuntimeItemId(nextTool.itemId), payload: { itemType: nextTool.itemType, @@ -1797,13 +1860,39 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( input: nextTool.input, }, }, - providerRefs: nativeProviderRefs(context, { providerItemId: nextTool.itemId }), + providerRefs: nativeProviderRefs(context, { + providerItemId: nextTool.itemId, + }), raw: { source: "claude.sdk.message", method: "claude/stream_event/content_block_delta/input_json_delta", payload: message, }, }); + + // Emit plan update when TodoWrite input is parsed + if (parsedInput && isTodoTool(nextTool.toolName)) { + const planSteps = extractPlanStepsFromTodoInput(parsedInput); + if (planSteps && planSteps.length > 0) { + const planStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "turn.plan.updated", + eventId: planStamp.eventId, + provider: PROVIDER, + createdAt: planStamp.createdAt, + threadId: context.session.threadId, + ...(context.turnState + ? { + turnId: asCanonicalTurnId(context.turnState.turnId), + } + : {}), + payload: { + plan: planSteps, + }, + providerRefs: nativeProviderRefs(context), + }); + } + } } return; } @@ -1876,7 +1965,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( input: toolInput, }, }, - providerRefs: nativeProviderRefs(context, { providerItemId: tool.itemId }), + providerRefs: nativeProviderRefs(context, { + providerItemId: tool.itemId, + }), raw: { source: "claude.sdk.message", method: "claude/stream_event/content_block_start", @@ -1956,7 +2047,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(tool.detail ? { detail: tool.detail } : {}), data: toolData, }, - providerRefs: nativeProviderRefs(context, { providerItemId: tool.itemId }), + providerRefs: nativeProviderRefs(context, { + providerItemId: tool.itemId, + }), raw: { source: "claude.sdk.message", method: "claude/user", @@ -1979,7 +2072,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( streamKind, delta: toolResult.text, }, - providerRefs: nativeProviderRefs(context, { providerItemId: tool.itemId }), + providerRefs: nativeProviderRefs(context, { + providerItemId: tool.itemId, + }), raw: { source: "claude.sdk.message", method: "claude/user", @@ -2005,7 +2100,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(completedDetail ? { detail: completedDetail } : {}), data: toolData, }, - providerRefs: nativeProviderRefs(context, { providerItemId: tool.itemId }), + providerRefs: nativeProviderRefs(context, { + providerItemId: tool.itemId, + }), raw: { source: "claude.sdk.message", method: "claude/user", @@ -2390,7 +2487,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( payload: { summary: message.summary, ...(message.preceding_tool_use_ids.length > 0 - ? { precedingToolUseIds: message.preceding_tool_use_ids } + ? { + precedingToolUseIds: message.preceding_tool_use_ids, + } : {}), }, }); @@ -2608,6 +2707,27 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }); } + const existingContext = sessions.get(input.threadId); + if (existingContext) { + yield* Effect.logWarning("claude.session.replacing", { + threadId: input.threadId, + existingSessionStatus: existingContext.session.status, + reason: "startSession called with existing active session", + }); + yield* stopSessionInternal(existingContext, { + emitExitEvent: false, + }).pipe( + // Replacement cleanup is best-effort: never block the new session on + // either typed failures or unexpected defects from tearing down the old one. + Effect.catchCause((cause) => + Effect.logWarning("claude.session.replace.stop-failed", { + threadId: input.threadId, + cause, + }), + ), + ); + } + const startedAt = yield* nowIso; const resumeState = readClaudeResumeState(input.resumeCursor); const threadId = input.threadId; @@ -2643,7 +2763,10 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const handleAskUserQuestion = Effect.fn("handleAskUserQuestion")(function* ( context: ClaudeSessionContext, toolInput: Record, - callbackOptions: { readonly signal: AbortSignal; readonly toolUseID?: string }, + callbackOptions: { + readonly signal: AbortSignal; + readonly toolUseID?: string; + }, ) { const requestId = ApprovalRequestId.make(yield* Random.nextUUIDv4); @@ -2679,7 +2802,11 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( provider: PROVIDER, createdAt: requestedStamp.createdAt, threadId: context.session.threadId, - ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + ...(context.turnState + ? { + turnId: asCanonicalTurnId(context.turnState.turnId), + } + : {}), requestId: asRuntimeRequestId(requestId), payload: { questions }, providerRefs: nativeProviderRefs(context, { @@ -2688,7 +2815,10 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( raw: { source: "claude.sdk.permission", method: "canUseTool/AskUserQuestion", - payload: { toolName: "AskUserQuestion", input: toolInput }, + payload: { + toolName: "AskUserQuestion", + input: toolInput, + }, }, }); @@ -2703,7 +2833,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( pendingUserInputs.delete(requestId); runFork(Deferred.succeed(answersDeferred, {} as ProviderUserInputAnswers)); }; - callbackOptions.signal.addEventListener("abort", onAbort, { once: true }); + callbackOptions.signal.addEventListener("abort", onAbort, { + once: true, + }); // Block until the user provides answers. const answers = yield* Deferred.await(answersDeferred); @@ -2717,7 +2849,11 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( provider: PROVIDER, createdAt: resolvedStamp.createdAt, threadId: context.session.threadId, - ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + ...(context.turnState + ? { + turnId: asCanonicalTurnId(context.turnState.turnId), + } + : {}), requestId: asRuntimeRequestId(requestId), payload: { answers }, providerRefs: nativeProviderRefs(context, { @@ -2887,7 +3023,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( behavior: "allow", updatedInput: toolInput, ...(decision === "acceptForSession" && pendingApproval.suggestions - ? { updatedPermissions: [...pendingApproval.suggestions] } + ? { + updatedPermissions: [...pendingApproval.suggestions], + } : {}), } satisfies PermissionResult; } @@ -2917,6 +3055,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ), ); const claudeBinaryPath = claudeSettings.binaryPath; + const extraArgs = parseCliArgs(claudeSettings.launchArgs).flags; const modelSelection = input.modelSelection?.provider === "claudeAgent" ? input.modelSelection : undefined; const caps = getClaudeModelCapabilities(modelSelection?.model); @@ -2962,6 +3101,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const uniqueDirs = [...new Set(dirs)]; return uniqueDirs.length > 0 ? { additionalDirectories: uniqueDirs } : {}; })(), + ...(Object.keys(extraArgs).length > 0 ? { extraArgs } : {}), }; const queryRuntime = yield* Effect.try({ diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index a08ac1fb88c..6928f221757 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -25,11 +25,11 @@ import { providerModelsFromSettings, spawnAndCollect, type CommandResult, -} from "../providerSnapshot"; -import { compareCliVersions } from "../cliVersion"; -import { makeManagedServerProvider } from "../makeManagedServerProvider"; -import { ClaudeProvider } from "../Services/ClaudeProvider"; -import { ServerSettingsService } from "../../serverSettings"; +} from "../providerSnapshot.ts"; +import { compareCliVersions } from "../cliVersion.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { ClaudeProvider } from "../Services/ClaudeProvider.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; import { ServerSettingsError } from "@marcode/contracts"; const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = { @@ -51,8 +51,8 @@ const BUILT_IN_MODELS: ReadonlyArray = [ reasoningEffortLevels: [ { value: "low", label: "Low" }, { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - { value: "xhigh", label: "Extra High" }, + { value: "high", label: "High" }, + { value: "xhigh", label: "Extra High", isDefault: true }, { value: "max", label: "Max" }, { value: "ultrathink", label: "Ultrathink" }, ], @@ -716,6 +716,46 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( }); }); +const makePendingClaudeProvider = (claudeSettings: ClaudeSettings): ServerProvider => { + const checkedAt = new Date().toISOString(); + const models = providerModelsFromSettings( + BUILT_IN_MODELS, + PROVIDER, + claudeSettings.customModels, + DEFAULT_CLAUDE_MODEL_CAPABILITIES, + ); + + if (!claudeSettings.enabled) { + return buildServerProvider({ + provider: PROVIDER, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Claude is disabled in T3 Code settings.", + }, + }); + } + + return buildServerProvider({ + provider: PROVIDER, + enabled: true, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Claude provider status has not been checked in this session yet.", + }, + }); +}; + export const ClaudeProviderLive = Layer.effect( ClaudeProvider, Effect.gen(function* () { @@ -751,6 +791,7 @@ export const ClaudeProviderLive = Layer.effect( Stream.map((settings) => settings.providers.claudeAgent), ), haveSettingsChanged: (previous, next) => !Equal.equals(previous, next), + initialSnapshot: makePendingClaudeProvider, checkProvider, }); }), diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index d622ea60e0e..99836cee3a0 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -146,6 +146,7 @@ const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory getBinding: () => Effect.succeed(Option.none()), remove: () => Effect.void, listThreadIds: () => Effect.succeed([]), + listBindings: () => Effect.succeed([]), }); const validationManager = new FakeCodexManager(); diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index b04473a5159..38619a6130a 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -32,22 +32,22 @@ import { providerModelsFromSettings, spawnAndCollect, type CommandResult, -} from "../providerSnapshot"; -import { makeManagedServerProvider } from "../makeManagedServerProvider"; +} from "../providerSnapshot.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import { formatCodexCliUpgradeMessage, isCodexCliVersionSupported, parseCodexCliVersion, -} from "../codexCliVersion"; +} from "../codexCliVersion.ts"; import { adjustCodexModelsForAccount, codexAuthSubLabel, codexAuthSubType, type CodexAccountSnapshot, -} from "../codexAccount"; -import { probeCodexDiscovery } from "../codexAppServer"; -import { CodexProvider } from "../Services/CodexProvider"; -import { ServerSettingsService } from "../../serverSettings"; +} from "../codexAccount.ts"; +import { probeCodexDiscovery } from "../codexAppServer.ts"; +import { CodexProvider } from "../Services/CodexProvider.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; import { ServerSettingsError } from "@marcode/contracts"; const DEFAULT_CODEX_MODEL_CAPABILITIES: ModelCapabilities = { @@ -552,6 +552,46 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu }); }); +const makePendingCodexProvider = (codexSettings: CodexSettings): ServerProvider => { + const checkedAt = new Date().toISOString(); + const models = providerModelsFromSettings( + BUILT_IN_MODELS, + PROVIDER, + codexSettings.customModels, + DEFAULT_CODEX_MODEL_CAPABILITIES, + ); + + if (!codexSettings.enabled) { + return buildServerProvider({ + provider: PROVIDER, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Codex is disabled in T3 Code settings.", + }, + }); + } + + return buildServerProvider({ + provider: PROVIDER, + enabled: true, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Codex provider status has not been checked in this session yet.", + }, + }); +}; + export const CodexProviderLive = Layer.effect( CodexProvider, Effect.gen(function* () { @@ -602,6 +642,7 @@ export const CodexProviderLive = Layer.effect( Stream.map((settings) => settings.providers.codex), ), haveSettingsChanged: (previous, next) => !Equal.equals(previous, next), + initialSnapshot: makePendingCodexProvider, checkProvider, }); }), diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts index 775396bd094..522018e3068 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts @@ -4,8 +4,10 @@ import { assertFailure } from "@effect/vitest/utils"; import { Effect, Layer, Stream } from "effect"; -import { ClaudeAdapter, ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; -import { CodexAdapter, CodexAdapterShape } from "../Services/CodexAdapter.ts"; +import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts"; +import type { ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; +import { CodexAdapter } from "../Services/CodexAdapter.ts"; +import type { CodexAdapterShape } from "../Services/CodexAdapter.ts"; import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts"; import { ProviderUnsupportedError } from "../Errors.ts"; diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 946664956eb..66e0542f5c0 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -28,11 +28,12 @@ import { hasCustomModelProvider, parseAuthStatusFromOutput, readCodexConfigModelProvider, -} from "./CodexProvider"; -import { checkClaudeProviderStatus, parseClaudeAuthStatusFromOutput } from "./ClaudeProvider"; -import { haveProvidersChanged, ProviderRegistryLive } from "./ProviderRegistry"; -import { ServerSettingsService, type ServerSettingsShape } from "../../serverSettings"; -import { ProviderRegistry } from "../Services/ProviderRegistry"; +} from "./CodexProvider.ts"; +import { checkClaudeProviderStatus, parseClaudeAuthStatusFromOutput } from "./ClaudeProvider.ts"; +import { haveProvidersChanged, ProviderRegistryLive } from "./ProviderRegistry.ts"; +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService, type ServerSettingsShape } from "../../serverSettings.ts"; +import { ProviderRegistry } from "../Services/ProviderRegistry.ts"; // ── Test helpers ──────────────────────────────────────────────────── @@ -562,6 +563,61 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( assert.strictEqual(haveProvidersChanged(providers, [...providers]), false); }); + it.effect("does not probe provider health during registry startup", () => + Effect.gen(function* () { + let spawnCount = 0; + const serverSettings = yield* makeMutableServerSettingsService(); + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const providerRegistryLayer = ProviderRegistryLive.pipe( + Layer.provideMerge(Layer.succeed(ServerSettingsService, serverSettings)), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-provider-registry-", + }), + ), + Layer.provideMerge( + mockCommandSpawnerLayer((command, args) => { + spawnCount += 1; + const joined = args.join(" "); + if (joined === "--version") { + if (command === "codex") { + return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; + } + return { stdout: "claude 1.0.0\n", stderr: "", code: 0 }; + } + if (joined === "login status") { + return { stdout: "Logged in\n", stderr: "", code: 0 }; + } + if (joined === "auth status") { + return { stdout: '{"authenticated":true}\n', stderr: "", code: 0 }; + } + throw new Error(`Unexpected args: ${command} ${joined}`); + }), + ), + ); + const runtimeServices = yield* Layer.build( + Layer.mergeAll( + Layer.succeed(ServerSettingsService, serverSettings), + providerRegistryLayer, + ), + ).pipe(Scope.provide(scope)); + + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry; + assert.deepStrictEqual(yield* registry.getProviders, []); + assert.strictEqual(spawnCount, 0); + + const refreshed = yield* registry.refresh("codex"); + assert.strictEqual(spawnCount > 0, true); + assert.strictEqual( + refreshed.find((provider) => provider.provider === "codex")?.status, + "ready", + ); + }).pipe(Effect.provide(runtimeServices)); + }), + ); + it.effect("reruns codex health when codex provider settings change", () => Effect.gen(function* () { const serverSettings = yield* makeMutableServerSettingsService(); @@ -569,6 +625,11 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); const providerRegistryLayer = ProviderRegistryLive.pipe( Layer.provideMerge(Layer.succeed(ServerSettingsService, serverSettings)), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-provider-registry-", + }), + ), Layer.provideMerge( mockCommandSpawnerLayer((command, args) => { const joined = args.join(" "); @@ -596,8 +657,11 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( const registry = yield* ProviderRegistry; const initial = yield* registry.getProviders; + assert.deepStrictEqual(initial, []); + + const refreshed = yield* registry.refresh("codex"); assert.strictEqual( - initial.find((status) => status.provider === "codex")?.status, + refreshed.find((status) => status.provider === "codex")?.status, "ready", ); @@ -925,7 +989,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( } assert.deepStrictEqual( opus47.capabilities.reasoningEffortLevels.find((level) => level.isDefault), - { value: "high", label: "High", isDefault: true }, + { value: "xhigh", label: "Extra High", isDefault: true }, ); }).pipe( Effect.provide( diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 9e82fb2fce4..70d92589c93 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -4,15 +4,24 @@ * @module ProviderRegistryLive */ import type { ProviderKind, ServerProvider } from "@marcode/contracts"; -import { Effect, Equal, Layer, PubSub, Ref, Stream } from "effect"; +import { Effect, Equal, FileSystem, Layer, Path, PubSub, Ref, Stream } from "effect"; -import { ClaudeProviderLive } from "./ClaudeProvider"; -import { CodexProviderLive } from "./CodexProvider"; -import type { ClaudeProviderShape } from "../Services/ClaudeProvider"; -import { ClaudeProvider } from "../Services/ClaudeProvider"; -import type { CodexProviderShape } from "../Services/CodexProvider"; -import { CodexProvider } from "../Services/CodexProvider"; -import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry"; +import { ServerConfig } from "../../config.ts"; +import { ClaudeProviderLive } from "./ClaudeProvider.ts"; +import { CodexProviderLive } from "./CodexProvider.ts"; +import type { ClaudeProviderShape } from "../Services/ClaudeProvider.ts"; +import { ClaudeProvider } from "../Services/ClaudeProvider.ts"; +import type { CodexProviderShape } from "../Services/CodexProvider.ts"; +import { CodexProvider } from "../Services/CodexProvider.ts"; +import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry.ts"; +import { + hydrateCachedProvider, + PROVIDER_CACHE_IDS, + orderProviderSnapshots, + readProviderStatusCache, + resolveProviderStatusCachePath, + writeProviderStatusCache, +} from "../providerStatusCache.ts"; const loadProviders = ( codexProvider: CodexProviderShape, @@ -32,61 +41,152 @@ export const ProviderRegistryLive = Layer.effect( Effect.gen(function* () { const codexProvider = yield* CodexProvider; const claudeProvider = yield* ClaudeProvider; + const config = yield* ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const changesPubSub = yield* Effect.acquireRelease( PubSub.unbounded>(), PubSub.shutdown, ); - const providersRef = yield* Ref.make>( - yield* loadProviders(codexProvider, claudeProvider), + const fallbackProviders = yield* loadProviders(codexProvider, claudeProvider); + const cachePathByProvider = new Map( + PROVIDER_CACHE_IDS.map( + (provider) => + [ + provider, + resolveProviderStatusCachePath({ + cacheDir: config.providerStatusCacheDir, + provider, + }), + ] as const, + ), + ); + const fallbackByProvider = new Map( + fallbackProviders.map((provider) => [provider.provider, provider] as const), ); + const cachedProviders = yield* Effect.forEach( + PROVIDER_CACHE_IDS, + (provider) => { + const filePath = cachePathByProvider.get(provider)!; + const fallbackProvider = fallbackByProvider.get(provider)!; + return readProviderStatusCache(filePath).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.map((cachedProvider) => + cachedProvider === undefined + ? undefined + : hydrateCachedProvider({ + cachedProvider, + fallbackProvider, + }), + ), + ); + }, + { concurrency: "unbounded" }, + ).pipe( + Effect.map((providers) => + orderProviderSnapshots( + providers.filter((provider): provider is ServerProvider => provider !== undefined), + ), + ), + ); + const providersRef = yield* Ref.make>(cachedProviders); + + const persistProvider = (provider: ServerProvider) => + writeProviderStatusCache({ + filePath: cachePathByProvider.get(provider.provider)!, + provider, + }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.tapError(Effect.logError), + Effect.ignore, + ); + + const upsertProviders = Effect.fn("upsertProviders")(function* ( + nextProviders: ReadonlyArray, + options?: { + readonly publish?: boolean; + }, + ) { + const [previousProviders, providers] = yield* Ref.modify( + providersRef, + (previousProviders) => { + const mergedProviders = new Map( + previousProviders.map((provider) => [provider.provider, provider] as const), + ); + + for (const provider of nextProviders) { + mergedProviders.set(provider.provider, provider); + } - const syncProviders = Effect.fn("syncProviders")(function* (options?: { - readonly publish?: boolean; - }) { - const previousProviders = yield* Ref.get(providersRef); - const providers = yield* loadProviders(codexProvider, claudeProvider); - yield* Ref.set(providersRef, providers); + const providers = orderProviderSnapshots([...mergedProviders.values()]); + return [[previousProviders, providers] as const, providers]; + }, + ); - if (options?.publish !== false && haveProvidersChanged(previousProviders, providers)) { - yield* PubSub.publish(changesPubSub, providers); + if (haveProvidersChanged(previousProviders, providers)) { + yield* Effect.forEach(nextProviders, persistProvider, { + concurrency: "unbounded", + discard: true, + }); + if (options?.publish !== false) { + yield* PubSub.publish(changesPubSub, providers); + } } return providers; }); - yield* Stream.runForEach(codexProvider.streamChanges, () => syncProviders()).pipe( - Effect.forkScoped, - ); - yield* Stream.runForEach(claudeProvider.streamChanges, () => syncProviders()).pipe( - Effect.forkScoped, - ); + const syncProvider = Effect.fn("syncProvider")(function* ( + provider: ServerProvider, + options?: { + readonly publish?: boolean; + }, + ) { + return yield* upsertProviders([provider], options); + }); const refresh = Effect.fn("refresh")(function* (provider?: ProviderKind) { switch (provider) { case "codex": - yield* codexProvider.refresh; - break; + return yield* codexProvider.refresh.pipe( + Effect.flatMap((nextProvider) => syncProvider(nextProvider)), + ); case "claudeAgent": - yield* claudeProvider.refresh; - break; + return yield* claudeProvider.refresh.pipe( + Effect.flatMap((nextProvider) => syncProvider(nextProvider)), + ); default: - yield* Effect.all([codexProvider.refresh, claudeProvider.refresh], { - concurrency: "unbounded", - }); - break; + return yield* Effect.all( + [ + codexProvider.refresh.pipe( + Effect.flatMap((nextProvider) => syncProvider(nextProvider)), + ), + claudeProvider.refresh.pipe( + Effect.flatMap((nextProvider) => syncProvider(nextProvider)), + ), + ], + { + concurrency: "unbounded", + discard: true, + }, + ).pipe(Effect.andThen(Ref.get(providersRef))); } - return yield* syncProviders(); }); + yield* Stream.runForEach(codexProvider.streamChanges, (provider) => + syncProvider(provider), + ).pipe(Effect.forkScoped); + yield* Stream.runForEach(claudeProvider.streamChanges, (provider) => + syncProvider(provider), + ).pipe(Effect.forkScoped); + return { - getProviders: syncProviders({ publish: false }).pipe( - Effect.tapError(Effect.logError), - Effect.orElseSucceed(() => []), - ), + getProviders: Ref.get(providersRef), refresh: (provider?: ProviderKind) => refresh(provider).pipe( Effect.tapError(Effect.logError), - Effect.orElseSucceed(() => []), + Effect.orElseSucceed(() => [] as ReadonlyArray), ), get streamChanges() { return Stream.fromPubSub(changesPubSub); diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 0980bca024a..3713f595ccd 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -648,6 +648,43 @@ routing.layer("ProviderServiceLive routing", (it) => { }), ); + it.effect("stops stale sessions in other providers after a successful replacement start", () => + Effect.gen(function* () { + const provider = yield* ProviderService; + const threadId = asThreadId("thread-provider-replacement"); + + const codexSession = yield* provider.startSession(threadId, { + provider: "codex", + threadId, + cwd: "/tmp/project-provider-replacement", + runtimeMode: "full-access", + }); + + routing.codex.stopSession.mockClear(); + routing.claude.stopSession.mockClear(); + + const claudeSession = yield* provider.startSession(threadId, { + provider: "claudeAgent", + threadId, + cwd: "/tmp/project-provider-replacement", + runtimeMode: "full-access", + }); + + assert.equal(codexSession.provider, "codex"); + assert.equal(claudeSession.provider, "claudeAgent"); + assert.deepEqual(routing.codex.stopSession.mock.calls, [[threadId]]); + assert.equal(routing.claude.stopSession.mock.calls.length, 0); + + const sessions = yield* provider.listSessions(); + assert.deepEqual( + sessions + .filter((session) => session.threadId === threadId) + .map((session) => session.provider), + ["claudeAgent"], + ); + }), + ); + it.effect("recovers stale sessions for sendTurn using persisted cwd", () => Effect.gen(function* () { const provider = yield* ProviderService; diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 1ec920caef7..64ad70414d9 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -285,6 +285,35 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( return { adapter: recovered.adapter, threadId: input.threadId, isActive: true } as const; }); + const stopStaleSessionsForThread = Effect.fn("stopStaleSessionsForThread")(function* (input: { + readonly threadId: ThreadId; + readonly currentProvider: ProviderSession["provider"]; + }) { + yield* Effect.forEach( + adapters, + (adapter) => + adapter.provider === input.currentProvider + ? Effect.void + : Effect.gen(function* () { + const hasSession = yield* adapter.hasSession(input.threadId); + if (!hasSession) { + return; + } + + yield* adapter.stopSession(input.threadId).pipe( + Effect.catchCause((cause) => + Effect.logWarning("provider.session.stop-stale-failed", { + threadId: input.threadId, + provider: adapter.provider, + cause, + }), + ), + ); + }), + { discard: true }, + ); + }); + const startSession: ProviderServiceShape["startSession"] = Effect.fn("startSession")( function* (threadId, rawInput) { const parsed = yield* decodeInputOrValidationError({ @@ -339,6 +368,10 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ); } + yield* stopStaleSessionsForThread({ + threadId, + currentProvider: adapter.provider, + }); yield* upsertSessionBinding(session, threadId, { modelSelection: input.modelSelection, }); diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts index 5ef6987bf48..d5ecba81149 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts @@ -133,6 +133,78 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL } })); + it("lists persisted bindings with metadata in oldest-first order", () => + Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + const runtimeRepository = yield* ProviderSessionRuntimeRepository; + + const olderThreadId = ThreadId.make("thread-runtime-older"); + const newerThreadId = ThreadId.make("thread-runtime-newer"); + + yield* runtimeRepository.upsert({ + threadId: newerThreadId, + providerName: "codex", + adapterKey: "codex", + runtimeMode: "full-access", + status: "running", + lastSeenAt: "2026-04-14T12:05:00.000Z", + resumeCursor: { + opaque: "resume-newer", + }, + runtimePayload: { + cwd: "/tmp/newer", + }, + }); + + yield* runtimeRepository.upsert({ + threadId: olderThreadId, + providerName: "claudeAgent", + adapterKey: "claudeAgent", + runtimeMode: "approval-required", + status: "starting", + lastSeenAt: "2026-04-14T12:00:00.000Z", + resumeCursor: { + opaque: "resume-older", + }, + runtimePayload: { + cwd: "/tmp/older", + }, + }); + + const bindings = yield* directory.listBindings(); + + assert.deepEqual(bindings, [ + { + threadId: olderThreadId, + provider: "claudeAgent", + adapterKey: "claudeAgent", + runtimeMode: "approval-required", + status: "starting", + lastSeenAt: "2026-04-14T12:00:00.000Z", + resumeCursor: { + opaque: "resume-older", + }, + runtimePayload: { + cwd: "/tmp/older", + }, + }, + { + threadId: newerThreadId, + provider: "codex", + adapterKey: "codex", + runtimeMode: "full-access", + status: "running", + lastSeenAt: "2026-04-14T12:05:00.000Z", + resumeCursor: { + opaque: "resume-newer", + }, + runtimePayload: { + cwd: "/tmp/newer", + }, + }, + ]); + })); + it("resets adapterKey to the new provider when provider changes without an explicit adapter key", () => Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts index 32ce38be260..5e9b866c6fe 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts @@ -1,11 +1,13 @@ import { type ProviderKind, type ThreadId } from "@marcode/contracts"; import { Effect, Layer, Option } from "effect"; +import type { ProviderSessionRuntime } from "../../persistence/Services/ProviderSessionRuntime.ts"; import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts"; import { ProviderSessionDirectoryPersistenceError, ProviderValidationError } from "../Errors.ts"; import { ProviderSessionDirectory, type ProviderRuntimeBinding, + type ProviderRuntimeBindingWithMetadata, type ProviderSessionDirectoryShape, } from "../Services/ProviderSessionDirectory.ts"; @@ -50,6 +52,27 @@ function mergeRuntimePayload( return next; } +function toRuntimeBinding( + runtime: ProviderSessionRuntime, + operation: string, +): Effect.Effect { + return decodeProviderKind(runtime.providerName, operation).pipe( + Effect.map( + (provider) => + ({ + threadId: runtime.threadId, + provider, + adapterKey: runtime.adapterKey, + runtimeMode: runtime.runtimeMode, + status: runtime.status, + resumeCursor: runtime.resumeCursor, + runtimePayload: runtime.runtimePayload, + lastSeenAt: runtime.lastSeenAt, + }) satisfies ProviderRuntimeBindingWithMetadata, + ), + ); +} + const makeProviderSessionDirectory = Effect.gen(function* () { const repository = yield* ProviderSessionRuntimeRepository; @@ -60,18 +83,8 @@ const makeProviderSessionDirectory = Effect.gen(function* () { Option.match(runtime, { onNone: () => Effect.succeed(Option.none()), onSome: (value) => - decodeProviderKind(value.providerName, "ProviderSessionDirectory.getBinding").pipe( - Effect.map((provider) => - Option.some({ - threadId: value.threadId, - provider, - adapterKey: value.adapterKey, - runtimeMode: value.runtimeMode, - status: value.status, - resumeCursor: value.resumeCursor, - runtimePayload: value.runtimePayload, - }), - ), + toRuntimeBinding(value, "ProviderSessionDirectory.getBinding").pipe( + Effect.map((binding) => Option.some(binding)), ), }), ), @@ -145,12 +158,25 @@ const makeProviderSessionDirectory = Effect.gen(function* () { Effect.map((rows) => rows.map((row) => row.threadId)), ); + const listBindings: ProviderSessionDirectoryShape["listBindings"] = () => + repository.list().pipe( + Effect.mapError(toPersistenceError("ProviderSessionDirectory.listBindings:list")), + Effect.flatMap((rows) => + Effect.forEach( + rows, + (row) => toRuntimeBinding(row, "ProviderSessionDirectory.listBindings"), + { concurrency: "unbounded" }, + ), + ), + ); + return { upsert, getProvider, getBinding, remove, listThreadIds, + listBindings, } satisfies ProviderSessionDirectoryShape; }); diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts new file mode 100644 index 00000000000..03ff43ef554 --- /dev/null +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -0,0 +1,524 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { ProjectId, ThreadId, TurnId } from "@marcode/contracts"; +import { Effect, Exit, Layer, ManagedRuntime, Option, Scope, Stream } from "effect"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + OrchestrationEngineService, + type OrchestrationEngineShape, +} from "../../orchestration/Services/OrchestrationEngine.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { ProviderSessionRuntimeRepositoryLive } from "../../persistence/Layers/ProviderSessionRuntime.ts"; +import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts"; +import { ProviderValidationError } from "../Errors.ts"; +import { ProviderSessionReaper } from "../Services/ProviderSessionReaper.ts"; +import { ProviderService, type ProviderServiceShape } from "../Services/ProviderService.ts"; +import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts"; +import { makeProviderSessionReaperLive } from "./ProviderSessionReaper.ts"; + +const defaultModelSelection = { + provider: "codex", + model: "gpt-5-codex", +} as const; + +async function waitFor( + predicate: () => boolean | Promise, + timeoutMs = 2_000, +): Promise { + const deadline = Date.now() + timeoutMs; + const poll = async (): Promise => { + if (await predicate()) { + return; + } + if (Date.now() >= deadline) { + throw new Error("Timed out waiting for expectation."); + } + await new Promise((resolve) => setTimeout(resolve, 10)); + return poll(); + }; + + return poll(); +} + +const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never; + +function makeReadModel( + threads: ReadonlyArray<{ + readonly id: ThreadId; + readonly session: { + readonly threadId: ThreadId; + readonly status: "starting" | "running" | "ready" | "interrupted" | "stopped" | "error"; + readonly providerName: "codex" | "claudeAgent"; + readonly runtimeMode: "approval-required" | "full-access" | "auto-accept-edits"; + readonly activeTurnId: TurnId | null; + readonly lastError: string | null; + readonly updatedAt: string; + } | null; + }>, +) { + const now = new Date().toISOString(); + const projectId = ProjectId.make("project-provider-session-reaper"); + + return { + snapshotSequence: 0, + updatedAt: now, + projects: [ + { + id: projectId, + title: "Provider Reaper Project", + workspaceRoot: "/tmp/provider-reaper-project", + defaultModelSelection, + scripts: [], + jiraBoard: null, + createdAt: now, + updatedAt: now, + deletedAt: null, + }, + ], + threads: threads.map((thread) => ({ + id: thread.id, + projectId, + title: `Thread ${thread.id}`, + modelSelection: defaultModelSelection, + interactionMode: "default" as const, + runtimeMode: "full-access" as const, + branch: null, + worktreePath: null, + additionalDirectories: [], + createdAt: now, + updatedAt: now, + archivedAt: null, + latestTurn: null, + messages: [], + session: thread.session, + activities: [], + proposedPlans: [], + checkpoints: [], + deletedAt: null, + })), + }; +} + +describe("ProviderSessionReaper", () => { + let runtime: ManagedRuntime.ManagedRuntime< + ProviderSessionReaper | ProviderSessionRuntimeRepository, + unknown + > | null = null; + let scope: Scope.Closeable | null = null; + + afterEach(async () => { + if (scope) { + await Effect.runPromise(Scope.close(scope, Exit.void)); + } + scope = null; + if (runtime) { + await runtime.dispose(); + } + runtime = null; + }); + + async function createHarness(input: { + readonly readModel: ReturnType; + readonly stopSessionImplementation?: (input: { + readonly threadId: ThreadId; + }) => ReturnType; + }) { + const stoppedThreadIds = new Set(); + const stopSession = vi.fn( + (request) => + (input.stopSessionImplementation + ? input.stopSessionImplementation(request) + : Effect.sync(() => { + stoppedThreadIds.add(request.threadId); + })) as ReturnType, + ); + + const providerService: ProviderServiceShape = { + startSession: () => unsupported(), + sendTurn: () => unsupported(), + interruptTurn: () => unsupported(), + respondToRequest: () => unsupported(), + respondToUserInput: () => unsupported(), + stopSession, + listSessions: () => Effect.succeed([]), + getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }), + rollbackConversation: () => unsupported(), + streamEvents: Stream.empty, + }; + + const orchestrationEngine: OrchestrationEngineShape = { + getReadModel: () => Effect.succeed(input.readModel), + readEvents: () => Stream.empty, + dispatch: () => unsupported(), + streamDomainEvents: Stream.empty, + }; + + const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( + Layer.provide(SqlitePersistenceMemory), + ); + const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe( + Layer.provide(runtimeRepositoryLayer), + ); + const layer = makeProviderSessionReaperLive({ + inactivityThresholdMs: 1_000, + sweepIntervalMs: 60_000, + }).pipe( + Layer.provideMerge(providerSessionDirectoryLayer), + Layer.provideMerge(runtimeRepositoryLayer), + Layer.provideMerge(Layer.succeed(ProviderService, providerService)), + Layer.provideMerge(Layer.succeed(OrchestrationEngineService, orchestrationEngine)), + Layer.provideMerge(NodeServices.layer), + ); + + runtime = ManagedRuntime.make(layer); + return { stopSession, stoppedThreadIds }; + } + + it("reaps stale persisted sessions without active turns", async () => { + const threadId = ThreadId.make("thread-reaper-stale"); + const now = new Date().toISOString(); + const harness = await createHarness({ + readModel: makeReadModel([ + { + id: threadId, + session: { + threadId, + status: "ready", + providerName: "claudeAgent", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + }, + ]), + }); + const repository = await runtime!.runPromise(Effect.service(ProviderSessionRuntimeRepository)); + + await runtime!.runPromise( + repository.upsert({ + threadId, + providerName: "claudeAgent", + adapterKey: "claudeAgent", + runtimeMode: "full-access", + status: "running", + lastSeenAt: "2026-04-14T00:00:00.000Z", + resumeCursor: { + opaque: "resume-stale", + }, + runtimePayload: null, + }), + ); + + const reaper = await runtime!.runPromise(Effect.service(ProviderSessionReaper)); + scope = await Effect.runPromise(Scope.make("sequential")); + await Effect.runPromise(reaper.start().pipe(Scope.provide(scope))); + + await waitFor(() => harness.stopSession.mock.calls.length === 1); + + expect(harness.stopSession.mock.calls[0]?.[0]).toEqual({ threadId }); + expect(harness.stoppedThreadIds.has(threadId)).toBe(true); + }); + + it("skips stale sessions when the thread still has an active turn", async () => { + const threadId = ThreadId.make("thread-reaper-active-turn"); + const turnId = TurnId.make("turn-reaper-active"); + const now = new Date().toISOString(); + const harness = await createHarness({ + readModel: makeReadModel([ + { + id: threadId, + session: { + threadId, + status: "running", + providerName: "claudeAgent", + runtimeMode: "full-access", + activeTurnId: turnId, + lastError: null, + updatedAt: now, + }, + }, + ]), + }); + const repository = await runtime!.runPromise(Effect.service(ProviderSessionRuntimeRepository)); + + await runtime!.runPromise( + repository.upsert({ + threadId, + providerName: "claudeAgent", + adapterKey: "claudeAgent", + runtimeMode: "full-access", + status: "running", + lastSeenAt: "2026-04-14T00:00:00.000Z", + resumeCursor: { + opaque: "resume-active-turn", + }, + runtimePayload: null, + }), + ); + + const reaper = await runtime!.runPromise(Effect.service(ProviderSessionReaper)); + scope = await Effect.runPromise(Scope.make("sequential")); + await Effect.runPromise(reaper.start().pipe(Scope.provide(scope))); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(harness.stopSession).not.toHaveBeenCalled(); + const remaining = await runtime!.runPromise(repository.getByThreadId({ threadId })); + expect(Option.isSome(remaining)).toBe(true); + }); + + it("does not reap sessions that are still within the inactivity threshold", async () => { + const threadId = ThreadId.make("thread-reaper-fresh"); + const now = new Date().toISOString(); + const harness = await createHarness({ + readModel: makeReadModel([ + { + id: threadId, + session: { + threadId, + status: "ready", + providerName: "claudeAgent", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + }, + ]), + }); + const repository = await runtime!.runPromise(Effect.service(ProviderSessionRuntimeRepository)); + + await runtime!.runPromise( + repository.upsert({ + threadId, + providerName: "claudeAgent", + adapterKey: "claudeAgent", + runtimeMode: "full-access", + status: "running", + lastSeenAt: now, + resumeCursor: { + opaque: "resume-fresh", + }, + runtimePayload: null, + }), + ); + + const reaper = await runtime!.runPromise(Effect.service(ProviderSessionReaper)); + scope = await Effect.runPromise(Scope.make("sequential")); + await Effect.runPromise(reaper.start().pipe(Scope.provide(scope))); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(harness.stopSession).not.toHaveBeenCalled(); + const remaining = await runtime!.runPromise(repository.getByThreadId({ threadId })); + expect(Option.isSome(remaining)).toBe(true); + }); + + it("skips persisted sessions that are already marked stopped", async () => { + const threadId = ThreadId.make("thread-reaper-stopped"); + const now = new Date().toISOString(); + const harness = await createHarness({ + readModel: makeReadModel([ + { + id: threadId, + session: { + threadId, + status: "stopped", + providerName: "claudeAgent", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + }, + ]), + }); + const repository = await runtime!.runPromise(Effect.service(ProviderSessionRuntimeRepository)); + + await runtime!.runPromise( + repository.upsert({ + threadId, + providerName: "claudeAgent", + adapterKey: "claudeAgent", + runtimeMode: "full-access", + status: "stopped", + lastSeenAt: "2026-04-14T00:00:00.000Z", + resumeCursor: { + opaque: "resume-stopped", + }, + runtimePayload: null, + }), + ); + + const reaper = await runtime!.runPromise(Effect.service(ProviderSessionReaper)); + scope = await Effect.runPromise(Scope.make("sequential")); + await Effect.runPromise(reaper.start().pipe(Scope.provide(scope))); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(harness.stopSession).not.toHaveBeenCalled(); + const remaining = await runtime!.runPromise(repository.getByThreadId({ threadId })); + expect(Option.isSome(remaining)).toBe(true); + }); + + it("continues reaping other sessions when one stop attempt fails", async () => { + const failedThreadId = ThreadId.make("thread-reaper-stop-failure"); + const reapedThreadId = ThreadId.make("thread-reaper-stop-success"); + const now = new Date().toISOString(); + const harness = await createHarness({ + readModel: makeReadModel([ + { + id: failedThreadId, + session: { + threadId: failedThreadId, + status: "ready", + providerName: "claudeAgent", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + }, + { + id: reapedThreadId, + session: { + threadId: reapedThreadId, + status: "ready", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + }, + ]), + stopSessionImplementation: (request) => + request.threadId === failedThreadId + ? Effect.fail( + new ProviderValidationError({ + operation: "ProviderSessionReaper.test", + issue: "simulated stop failure", + }), + ) + : Effect.void, + }); + const repository = await runtime!.runPromise(Effect.service(ProviderSessionRuntimeRepository)); + + await runtime!.runPromise( + repository.upsert({ + threadId: failedThreadId, + providerName: "claudeAgent", + adapterKey: "claudeAgent", + runtimeMode: "full-access", + status: "running", + lastSeenAt: "2026-04-14T00:00:00.000Z", + resumeCursor: { + opaque: "resume-failure", + }, + runtimePayload: null, + }), + ); + await runtime!.runPromise( + repository.upsert({ + threadId: reapedThreadId, + providerName: "codex", + adapterKey: "codex", + runtimeMode: "full-access", + status: "running", + lastSeenAt: "2026-04-14T00:01:00.000Z", + resumeCursor: { + opaque: "resume-success", + }, + runtimePayload: null, + }), + ); + + const reaper = await runtime!.runPromise(Effect.service(ProviderSessionReaper)); + scope = await Effect.runPromise(Scope.make("sequential")); + await Effect.runPromise(reaper.start().pipe(Scope.provide(scope))); + + await waitFor(() => harness.stopSession.mock.calls.length === 2); + + expect(harness.stopSession.mock.calls.map(([request]) => request.threadId)).toEqual([ + failedThreadId, + reapedThreadId, + ]); + }); + + it("continues reaping other sessions when one stop attempt defects", async () => { + const defectThreadId = ThreadId.make("thread-reaper-stop-defect"); + const reapedThreadId = ThreadId.make("thread-reaper-stop-after-defect"); + const now = new Date().toISOString(); + const harness = await createHarness({ + readModel: makeReadModel([ + { + id: defectThreadId, + session: { + threadId: defectThreadId, + status: "ready", + providerName: "claudeAgent", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + }, + { + id: reapedThreadId, + session: { + threadId: reapedThreadId, + status: "ready", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + }, + ]), + stopSessionImplementation: (request) => + request.threadId === defectThreadId + ? Effect.die(new Error("simulated stop defect")) + : Effect.void, + }); + const repository = await runtime!.runPromise(Effect.service(ProviderSessionRuntimeRepository)); + + await runtime!.runPromise( + repository.upsert({ + threadId: defectThreadId, + providerName: "claudeAgent", + adapterKey: "claudeAgent", + runtimeMode: "full-access", + status: "running", + lastSeenAt: "2026-04-14T00:00:00.000Z", + resumeCursor: { + opaque: "resume-defect", + }, + runtimePayload: null, + }), + ); + await runtime!.runPromise( + repository.upsert({ + threadId: reapedThreadId, + providerName: "codex", + adapterKey: "codex", + runtimeMode: "full-access", + status: "running", + lastSeenAt: "2026-04-14T00:01:00.000Z", + resumeCursor: { + opaque: "resume-after-defect", + }, + runtimePayload: null, + }), + ); + + const reaper = await runtime!.runPromise(Effect.service(ProviderSessionReaper)); + scope = await Effect.runPromise(Scope.make("sequential")); + await Effect.runPromise(reaper.start().pipe(Scope.provide(scope))); + + await waitFor(() => harness.stopSession.mock.calls.length === 2); + + expect(harness.stopSession.mock.calls.map(([request]) => request.threadId)).toEqual([ + defectThreadId, + reapedThreadId, + ]); + }); +}); diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.ts new file mode 100644 index 00000000000..aa31c8c7d7a --- /dev/null +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.ts @@ -0,0 +1,133 @@ +import { Duration, Effect, Layer, Schedule } from "effect"; + +import { OrchestrationEngineService } from "../../orchestration/Services/OrchestrationEngine.ts"; +import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; +import { + ProviderSessionReaper, + type ProviderSessionReaperShape, +} from "../Services/ProviderSessionReaper.ts"; +import { ProviderService } from "../Services/ProviderService.ts"; + +const DEFAULT_INACTIVITY_THRESHOLD_MS = 30 * 60 * 1000; +const DEFAULT_SWEEP_INTERVAL_MS = 5 * 60 * 1000; + +export interface ProviderSessionReaperLiveOptions { + readonly inactivityThresholdMs?: number; + readonly sweepIntervalMs?: number; +} + +const makeProviderSessionReaper = (options?: ProviderSessionReaperLiveOptions) => + Effect.gen(function* () { + const providerService = yield* ProviderService; + const directory = yield* ProviderSessionDirectory; + const orchestrationEngine = yield* OrchestrationEngineService; + + const inactivityThresholdMs = Math.max( + 1, + options?.inactivityThresholdMs ?? DEFAULT_INACTIVITY_THRESHOLD_MS, + ); + const sweepIntervalMs = Math.max(1, options?.sweepIntervalMs ?? DEFAULT_SWEEP_INTERVAL_MS); + + const sweep = Effect.gen(function* () { + const readModel = yield* orchestrationEngine.getReadModel(); + const threadsById = new Map(readModel.threads.map((thread) => [thread.id, thread] as const)); + const bindings = yield* directory.listBindings(); + const now = Date.now(); + let reapedCount = 0; + + for (const binding of bindings) { + if (binding.status === "stopped") { + continue; + } + + const lastSeenMs = Date.parse(binding.lastSeenAt); + if (Number.isNaN(lastSeenMs)) { + yield* Effect.logWarning("provider.session.reaper.invalid-last-seen", { + threadId: binding.threadId, + provider: binding.provider, + lastSeenAt: binding.lastSeenAt, + }); + continue; + } + + const idleDurationMs = now - lastSeenMs; + if (idleDurationMs < inactivityThresholdMs) { + continue; + } + + const thread = threadsById.get(binding.threadId); + if (thread?.session?.activeTurnId != null) { + yield* Effect.logDebug("provider.session.reaper.skipped-active-turn", { + threadId: binding.threadId, + activeTurnId: thread.session.activeTurnId, + idleDurationMs, + }); + continue; + } + + const reaped = yield* providerService.stopSession({ threadId: binding.threadId }).pipe( + Effect.tap(() => + Effect.logInfo("provider.session.reaped", { + threadId: binding.threadId, + provider: binding.provider, + idleDurationMs, + reason: "inactivity_threshold", + }), + ), + Effect.as(true), + Effect.catchCause((cause) => + Effect.logWarning("provider.session.reaper.stop-failed", { + threadId: binding.threadId, + provider: binding.provider, + idleDurationMs, + cause, + }).pipe(Effect.as(false)), + ), + ); + + if (reaped) { + reapedCount += 1; + } + } + + if (reapedCount > 0) { + yield* Effect.logInfo("provider.session.reaper.sweep-complete", { + reapedCount, + totalBindings: bindings.length, + }); + } + }); + + const start: ProviderSessionReaperShape["start"] = () => + Effect.gen(function* () { + yield* Effect.forkScoped( + sweep.pipe( + Effect.catch((error: unknown) => + Effect.logWarning("provider.session.reaper.sweep-failed", { + error, + }), + ), + Effect.catchDefect((defect: unknown) => + Effect.logWarning("provider.session.reaper.sweep-defect", { + defect, + }), + ), + Effect.repeat(Schedule.spaced(Duration.millis(sweepIntervalMs))), + ), + ); + + yield* Effect.logInfo("provider.session.reaper.started", { + inactivityThresholdMs, + sweepIntervalMs, + }); + }); + + return { + start, + } satisfies ProviderSessionReaperShape; + }); + +export const makeProviderSessionReaperLive = (options?: ProviderSessionReaperLiveOptions) => + Layer.effect(ProviderSessionReaper, makeProviderSessionReaper(options)); + +export const ProviderSessionReaperLive = makeProviderSessionReaperLive(); diff --git a/apps/server/src/provider/Services/ClaudeProvider.ts b/apps/server/src/provider/Services/ClaudeProvider.ts index 954f401b429..8c1ed69ad43 100644 --- a/apps/server/src/provider/Services/ClaudeProvider.ts +++ b/apps/server/src/provider/Services/ClaudeProvider.ts @@ -1,6 +1,6 @@ import { Context } from "effect"; -import type { ServerProviderShape } from "./ServerProvider"; +import type { ServerProviderShape } from "./ServerProvider.ts"; export interface ClaudeProviderShape extends ServerProviderShape {} diff --git a/apps/server/src/provider/Services/CodexProvider.ts b/apps/server/src/provider/Services/CodexProvider.ts index c11da48e1da..0cb9be22b16 100644 --- a/apps/server/src/provider/Services/CodexProvider.ts +++ b/apps/server/src/provider/Services/CodexProvider.ts @@ -1,6 +1,6 @@ import { Context } from "effect"; -import type { ServerProviderShape } from "./ServerProvider"; +import type { ServerProviderShape } from "./ServerProvider.ts"; export interface CodexProviderShape extends ServerProviderShape {} diff --git a/apps/server/src/provider/Services/ProviderSessionDirectory.ts b/apps/server/src/provider/Services/ProviderSessionDirectory.ts index 22235022e65..ab86a18a548 100644 --- a/apps/server/src/provider/Services/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Services/ProviderSessionDirectory.ts @@ -22,6 +22,10 @@ export interface ProviderRuntimeBinding { readonly runtimeMode?: RuntimeMode; } +export interface ProviderRuntimeBindingWithMetadata extends ProviderRuntimeBinding { + readonly lastSeenAt: string; +} + export type ProviderSessionDirectoryReadError = ProviderSessionDirectoryPersistenceError; export type ProviderSessionDirectoryWriteError = @@ -49,6 +53,11 @@ export interface ProviderSessionDirectoryShape { ReadonlyArray, ProviderSessionDirectoryPersistenceError >; + + readonly listBindings: () => Effect.Effect< + ReadonlyArray, + ProviderSessionDirectoryPersistenceError + >; } export class ProviderSessionDirectory extends Context.Service< diff --git a/apps/server/src/provider/Services/ProviderSessionReaper.ts b/apps/server/src/provider/Services/ProviderSessionReaper.ts new file mode 100644 index 00000000000..b13b6f7e0c7 --- /dev/null +++ b/apps/server/src/provider/Services/ProviderSessionReaper.ts @@ -0,0 +1,14 @@ +import { Context } from "effect"; +import type { Effect, Scope } from "effect"; + +export interface ProviderSessionReaperShape { + /** + * Start the background provider session reaper within the provided scope. + */ + readonly start: () => Effect.Effect; +} + +export class ProviderSessionReaper extends Context.Service< + ProviderSessionReaper, + ProviderSessionReaperShape +>()("t3/provider/Services/ProviderSessionReaper") {} diff --git a/apps/server/src/provider/cliVersion.test.ts b/apps/server/src/provider/cliVersion.test.ts index a9c1721c4e8..ffb42cf5ccd 100644 --- a/apps/server/src/provider/cliVersion.test.ts +++ b/apps/server/src/provider/cliVersion.test.ts @@ -1,6 +1,6 @@ import { assert, describe, it } from "@effect/vitest"; -import { compareCliVersions, normalizeCliVersion } from "./cliVersion"; +import { compareCliVersions, normalizeCliVersion } from "./cliVersion.ts"; describe("cliVersion", () => { it("normalizes versions with a missing patch segment", () => { diff --git a/apps/server/src/provider/codexAppServer.ts b/apps/server/src/provider/codexAppServer.ts index 6434a7c1fe7..a858dfc0fc4 100644 --- a/apps/server/src/provider/codexAppServer.ts +++ b/apps/server/src/provider/codexAppServer.ts @@ -1,7 +1,7 @@ import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "node:child_process"; import readline from "node:readline"; import type { ServerProviderSkill } from "@marcode/contracts"; -import { readCodexAccountSnapshot, type CodexAccountSnapshot } from "./codexAccount"; +import { readCodexAccountSnapshot, type CodexAccountSnapshot } from "./codexAccount.ts"; interface JsonRpcProbeResponse { readonly id?: unknown; diff --git a/apps/server/src/provider/codexCliVersion.ts b/apps/server/src/provider/codexCliVersion.ts index c5914ad8f33..585b71c617d 100644 --- a/apps/server/src/provider/codexCliVersion.ts +++ b/apps/server/src/provider/codexCliVersion.ts @@ -1,4 +1,4 @@ -import { compareCliVersions, normalizeCliVersion } from "./cliVersion"; +import { compareCliVersions, normalizeCliVersion } from "./cliVersion.ts"; const CODEX_VERSION_PATTERN = /\bv?(\d+\.\d+(?:\.\d+)?(?:-[0-9A-Za-z.-]+)?)\b/; diff --git a/apps/server/src/provider/makeManagedServerProvider.ts b/apps/server/src/provider/makeManagedServerProvider.ts index de0dc73a863..f34bdf65e0f 100644 --- a/apps/server/src/provider/makeManagedServerProvider.ts +++ b/apps/server/src/provider/makeManagedServerProvider.ts @@ -2,7 +2,7 @@ import type { ServerProvider } from "@marcode/contracts"; import { Duration, Effect, PubSub, Ref, Scope, Stream } from "effect"; import * as Semaphore from "effect/Semaphore"; -import type { ServerProviderShape } from "./Services/ServerProvider"; +import type { ServerProviderShape } from "./Services/ServerProvider.ts"; import { ServerSettingsError } from "@marcode/contracts"; export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")(function* < @@ -11,6 +11,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( readonly getSettings: Effect.Effect; readonly streamSettings: Stream.Stream; readonly haveSettingsChanged: (previous: Settings, next: Settings) => boolean; + readonly initialSnapshot: (settings: Settings) => ServerProvider; readonly checkProvider: Effect.Effect; readonly refreshInterval?: Duration.Input; }): Effect.fn.Return { @@ -20,7 +21,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( PubSub.shutdown, ); const initialSettings = yield* input.getSettings; - const initialSnapshot = yield* input.checkProvider; + const initialSnapshot = input.initialSnapshot(initialSettings); const snapshotRef = yield* Ref.make(initialSnapshot); const settingsRef = yield* Ref.make(initialSettings); diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index e72187e9163..2856b6c61f7 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -10,7 +10,7 @@ import type { import { Effect, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { normalizeModelSlug } from "@marcode/shared/model"; -import { isWindowsCommandNotFound } from "../processRunner"; +import { isWindowsCommandNotFound } from "../processRunner.ts"; export const DEFAULT_TIMEOUT_MS = 4_000; diff --git a/apps/server/src/provider/providerStatusCache.test.ts b/apps/server/src/provider/providerStatusCache.test.ts new file mode 100644 index 00000000000..d07926c1aab --- /dev/null +++ b/apps/server/src/provider/providerStatusCache.test.ts @@ -0,0 +1,136 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import type { ServerProvider } from "@marcode/contracts"; +import { assert, it } from "@effect/vitest"; +import { Effect, FileSystem } from "effect"; + +import { + hydrateCachedProvider, + readProviderStatusCache, + resolveProviderStatusCachePath, + writeProviderStatusCache, +} from "./providerStatusCache.ts"; + +const makeProvider = ( + provider: ServerProvider["provider"], + overrides?: Partial, +): ServerProvider => ({ + provider, + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-04-11T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + ...overrides, +}); + +it.layer(NodeServices.layer)("providerStatusCache", (it) => { + 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"); + const claudeProvider = makeProvider("claudeAgent", { + status: "warning", + auth: { status: "unknown" }, + }); + const codexPath = resolveProviderStatusCachePath({ + cacheDir: tempDir, + provider: "codex", + }); + const claudePath = resolveProviderStatusCachePath({ + cacheDir: tempDir, + provider: "claudeAgent", + }); + + yield* writeProviderStatusCache({ + filePath: codexPath, + provider: codexProvider, + }); + yield* writeProviderStatusCache({ + filePath: claudePath, + provider: claudeProvider, + }); + + assert.deepStrictEqual(yield* readProviderStatusCache(codexPath), codexProvider); + assert.deepStrictEqual(yield* readProviderStatusCache(claudePath), claudeProvider); + }), + ); + + it("hydrates cached provider status onto current settings-derived models", () => { + const cachedCodex = makeProvider("codex", { + checkedAt: "2026-04-10T12:00:00.000Z", + models: [], + message: "Cached message", + skills: [ + { + name: "github:gh-fix-ci", + path: "/tmp/skills/gh-fix-ci/SKILL.md", + enabled: true, + displayName: "CI Debug", + }, + ], + }); + const fallbackCodex = makeProvider("codex", { + models: [ + { + slug: "gpt-5.4", + name: "GPT-5.4", + isCustom: false, + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }, + }, + ], + message: "Pending refresh", + }); + + assert.deepStrictEqual( + hydrateCachedProvider({ + cachedProvider: cachedCodex, + fallbackProvider: fallbackCodex, + }), + { + ...fallbackCodex, + installed: cachedCodex.installed, + version: cachedCodex.version, + status: cachedCodex.status, + auth: cachedCodex.auth, + checkedAt: cachedCodex.checkedAt, + slashCommands: cachedCodex.slashCommands, + skills: cachedCodex.skills, + message: cachedCodex.message, + }, + ); + }); + + it("ignores stale cached enabled state when the provider is now disabled", () => { + const cachedCodex = makeProvider("codex", { + checkedAt: "2026-04-10T12:00:00.000Z", + message: "Cached ready status", + }); + const disabledFallback = makeProvider("codex", { + enabled: false, + installed: false, + version: null, + status: "disabled", + auth: { status: "unknown" }, + message: "Codex is disabled in T3 Code settings.", + }); + + assert.deepStrictEqual( + hydrateCachedProvider({ + cachedProvider: cachedCodex, + fallbackProvider: disabledFallback, + }), + disabledFallback, + ); + }); +}); diff --git a/apps/server/src/provider/providerStatusCache.ts b/apps/server/src/provider/providerStatusCache.ts new file mode 100644 index 00000000000..655f3ec1cd9 --- /dev/null +++ b/apps/server/src/provider/providerStatusCache.ts @@ -0,0 +1,105 @@ +import * as nodePath from "node:path"; +import { type ServerProvider, ServerProvider as ServerProviderSchema } from "@marcode/contracts"; +import { Cause, Effect, FileSystem, Path, Schema } from "effect"; + +export const PROVIDER_CACHE_IDS = ["codex", "claudeAgent"] as const satisfies ReadonlyArray< + ServerProvider["provider"] +>; + +const decodeProviderStatusCache = Schema.decodeUnknownEffect( + Schema.fromJsonString(ServerProviderSchema), +); + +const providerOrderRank = (provider: ServerProvider["provider"]): number => { + const rank = PROVIDER_CACHE_IDS.indexOf(provider); + return rank === -1 ? Number.MAX_SAFE_INTEGER : rank; +}; + +export const orderProviderSnapshots = ( + providers: ReadonlyArray, +): ReadonlyArray => + [...providers].toSorted( + (left, right) => providerOrderRank(left.provider) - providerOrderRank(right.provider), + ); + +export const hydrateCachedProvider = (input: { + readonly cachedProvider: ServerProvider; + readonly fallbackProvider: ServerProvider; +}): ServerProvider => { + if ( + !input.fallbackProvider.enabled || + input.cachedProvider.enabled !== input.fallbackProvider.enabled + ) { + return input.fallbackProvider; + } + + const { message: _fallbackMessage, ...fallbackWithoutMessage } = input.fallbackProvider; + const hydratedProvider: ServerProvider = { + ...fallbackWithoutMessage, + installed: input.cachedProvider.installed, + version: input.cachedProvider.version, + status: input.cachedProvider.status, + auth: input.cachedProvider.auth, + checkedAt: input.cachedProvider.checkedAt, + slashCommands: input.cachedProvider.slashCommands, + skills: input.cachedProvider.skills, + }; + + return input.cachedProvider.message + ? { ...hydratedProvider, message: input.cachedProvider.message } + : hydratedProvider; +}; + +export const resolveProviderStatusCachePath = (input: { + readonly cacheDir: string; + readonly provider: ServerProvider["provider"]; +}) => nodePath.join(input.cacheDir, `${input.provider}.json`); + +export const readProviderStatusCache = (filePath: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const exists = yield* fs.exists(filePath).pipe(Effect.orElseSucceed(() => false)); + if (!exists) { + return undefined; + } + + const raw = yield* fs.readFileString(filePath).pipe(Effect.orElseSucceed(() => "")); + const trimmed = raw.trim(); + if (trimmed.length === 0) { + return undefined; + } + + return yield* decodeProviderStatusCache(trimmed).pipe( + Effect.matchCauseEffect({ + onFailure: (cause) => + Effect.logWarning("failed to parse provider status cache, ignoring", { + path: filePath, + issues: Cause.pretty(cause), + }).pipe(Effect.as(undefined)), + onSuccess: Effect.succeed, + }), + ); + }); + +export const writeProviderStatusCache = (input: { + readonly filePath: string; + readonly provider: ServerProvider; +}) => { + const tempPath = `${input.filePath}.${process.pid}.${Date.now()}.tmp`; + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const encoded = `${JSON.stringify(input.provider, null, 2)}\n`; + + yield* fs.makeDirectory(path.dirname(input.filePath), { recursive: true }); + yield* fs.writeFileString(tempPath, encoded); + yield* fs.rename(tempPath, input.filePath); + }).pipe( + Effect.ensuring( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.remove(tempPath, { force: true }).pipe(Effect.ignore({ log: true })); + }), + ), + ); +}; diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 142b0f31a14..ba26c0b9108 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -11,6 +11,7 @@ import { KeybindingRule, MessageId, OpenError, + type OrchestrationThreadShell, TerminalNotRunningError, type OrchestrationCommand, type OrchestrationEvent, @@ -58,6 +59,10 @@ import { import { GitCore, type GitCoreShape } from "./git/Services/GitCore.ts"; import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts"; import { GitStatusBroadcasterLive } from "./git/Layers/GitStatusBroadcaster.ts"; +import { + GitStatusBroadcaster, + type GitStatusBroadcasterShape, +} from "./git/Services/GitStatusBroadcaster.ts"; import { Keybindings, type KeybindingsShape } from "./keybindings.ts"; import { Open, type OpenShape } from "./open.ts"; import { @@ -69,8 +74,8 @@ import { ProjectionSnapshotQuery, type ProjectionSnapshotQueryShape, } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; -import { PersistenceSqlError } from "./persistence/Errors.ts"; import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts"; +import { PersistenceSqlError } from "./persistence/Errors.ts"; import { ProviderRegistry, type ProviderRegistryShape, @@ -168,6 +173,34 @@ const makeDefaultOrchestrationReadModel = () => { }; }; +const makeDefaultOrchestrationThreadShell = ( + overrides: Partial = {}, +): OrchestrationThreadShell => { + const now = new Date().toISOString(); + return { + id: defaultThreadId, + projectId: defaultProjectId, + title: "Default Thread", + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + additionalDirectories: [], + latestTurn: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + deletedAt: null, + session: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + ...overrides, + }; +}; + const workspaceAndProjectServicesLayer = Layer.mergeAll( WorkspacePathsLive, WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive)), @@ -299,6 +332,7 @@ const buildAppUnderTest = (options?: { open?: Partial; gitCore?: Partial; gitManager?: Partial; + gitStatusBroadcaster?: Partial; projectSetupScriptRunner?: Partial; terminalManager?: Partial; orchestrationEngine?: Partial; @@ -348,10 +382,37 @@ const buildAppUnderTest = (options?: { ...options?.config, }; const layerConfig = Layer.succeed(ServerConfig, config); + const gitCoreLayer = Layer.mock(GitCore)({ + isInsideWorkTree: () => Effect.succeed(false), + listWorkspaceFiles: () => + Effect.succeed({ + paths: [], + truncated: false, + }), + filterIgnoredPaths: (_cwd, relativePaths) => Effect.succeed(relativePaths), + ...options?.layers?.gitCore, + }); const gitManagerLayer = Layer.mock(GitManager)({ ...options?.layers?.gitManager, }); - const gitStatusBroadcasterLayer = GitStatusBroadcasterLive.pipe(Layer.provide(gitManagerLayer)); + const workspaceEntriesLayer = WorkspaceEntriesLive.pipe( + Layer.provide(WorkspacePathsLive), + Layer.provideMerge(gitCoreLayer), + ); + const workspaceAndProjectServicesLayer = Layer.mergeAll( + WorkspacePathsLive, + workspaceEntriesLayer, + WorkspaceFileSystemLive.pipe( + Layer.provide(WorkspacePathsLive), + Layer.provide(workspaceEntriesLayer), + ), + ProjectFaviconResolverLive, + ); + const gitStatusBroadcasterLayer = options?.layers?.gitStatusBroadcaster + ? Layer.mock(GitStatusBroadcaster)({ + ...options.layers.gitStatusBroadcaster, + }) + : GitStatusBroadcasterLive.pipe(Layer.provide(gitManagerLayer)); const servedRoutesLayer = HttpRouter.serve(makeRoutesLayer, { disableListenLog: true, @@ -390,11 +451,7 @@ const buildAppUnderTest = (options?: { ...options?.layers?.open, }), ), - Layer.provide( - Layer.mock(GitCore)({ - ...options?.layers?.gitCore, - }), - ), + Layer.provide(gitCoreLayer), Layer.provide(gitManagerLayer), Layer.provideMerge(gitStatusBroadcasterLayer), Layer.provide( @@ -428,6 +485,16 @@ const buildAppUnderTest = (options?: { threads: [], }), getThread: () => Effect.succeed(Option.none()), + getShellSnapshot: () => + Effect.succeed({ + snapshotSequence: 0, + projects: [], + threads: [], + updatedAt: new Date(0).toISOString(), + }), + getProjectShellById: () => Effect.succeed(Option.none()), + getThreadShellById: () => Effect.succeed(Option.none()), + getThreadDetailById: () => Effect.succeed(Option.none()), getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), @@ -1828,7 +1895,20 @@ it.layer(NodeServices.layer)("server router seam", (it) => { it.effect("routes websocket rpc subscribeServerConfig streams snapshot then update", () => Effect.gen(function* () { - const providers = [] as const; + const providers = [ + { + provider: "codex" as const, + enabled: true, + installed: true, + version: "1.0.0", + status: "ready" as const, + auth: { status: "authenticated" as const }, + checkedAt: "2026-04-11T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + }, + ] as const; const changeEvent = { keybindings: [], issues: [], @@ -1885,7 +1965,20 @@ it.layer(NodeServices.layer)("server router seam", (it) => { it.effect("routes websocket rpc subscribeServerConfig emits provider status updates", () => Effect.gen(function* () { - const providers = [] as const; + const nextProviders = [ + { + provider: "codex" as const, + enabled: true, + installed: true, + version: "1.0.0", + status: "ready" as const, + auth: { status: "authenticated" as const }, + checkedAt: "2026-04-11T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + }, + ] as const; yield* buildAppUnderTest({ layers: { @@ -1898,7 +1991,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }, providerRegistry: { getProviders: Effect.succeed([]), - streamChanges: Stream.succeed(providers), + streamChanges: Stream.succeed(nextProviders), }, }, }); @@ -1912,10 +2005,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const [first, second] = Array.from(events); assert.equal(first?.type, "snapshot"); + if (first?.type === "snapshot") { + assert.deepEqual(first.config.providers, []); + } assert.deepEqual(second, { version: 1, type: "providerStatuses", - payload: { providers }, + payload: { providers: nextProviders }, }); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); @@ -2001,6 +2097,58 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("routes websocket rpc projects.searchEntries excludes gitignored files", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspaceDir = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-ws-project-search-gitignored-", + }); + yield* fs.writeFileString(path.join(workspaceDir, ".gitignore"), ".venv/\n"); + yield* fs.makeDirectory(path.join(workspaceDir, ".venv", "lib"), { recursive: true }); + yield* fs.writeFileString( + path.join(workspaceDir, ".venv", "lib", "ignored-search-target.ts"), + "export const ignored = true;", + ); + yield* fs.makeDirectory(path.join(workspaceDir, "src"), { recursive: true }); + yield* fs.writeFileString( + path.join(workspaceDir, "src", "tracked.ts"), + "export const ok = 1;", + ); + + yield* buildAppUnderTest({ + layers: { + gitCore: { + isInsideWorkTree: () => Effect.succeed(true), + listWorkspaceFiles: () => + Effect.succeed({ + paths: ["src/tracked.ts"], + truncated: false, + }), + filterIgnoredPaths: (_cwd, relativePaths) => + Effect.succeed( + relativePaths.filter((relativePath) => !relativePath.startsWith(".venv/")), + ), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.projectsSearchEntries]({ + cwd: workspaceDir, + query: "ignored-search-target", + limit: 10, + }), + ), + ); + + assert.equal(response.entries.length, 0); + assert.equal(response.truncated, false); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes websocket rpc projects.searchEntries errors", () => Effect.gen(function* () { yield* buildAppUnderTest(); @@ -2052,6 +2200,40 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("creates a missing workspace root during websocket project.create dispatch", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const parentDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ws-project-create-" }); + const missingWorkspaceRoot = path.join(parentDir, "nested", "new-project"); + + yield* buildAppUnderTest(); + + const wsUrl = yield* getWsServerUrl("/ws"); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "project.create", + commandId: CommandId.make("cmd-project-create-missing-root"), + projectId: ProjectId.make("project-create-missing-root"), + title: "New Project", + workspaceRoot: missingWorkspaceRoot, + createWorkspaceRootIfMissing: true, + defaultModelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + createdAt: new Date().toISOString(), + }), + ), + ); + const stat = yield* fs.stat(missingWorkspaceRoot); + + assert.isAtLeast(response.sequence, 0); + assert.equal(stat.type, "Directory"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes websocket rpc projects.writeFile errors", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -2817,11 +2999,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }); const wsUrl = yield* getWsServerUrl("/ws"); - const snapshotResult = yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => client[ORCHESTRATION_WS_METHODS.getSnapshot]({})), - ); - assert.equal(snapshotResult.snapshotSequence, 1); - const dispatchResult = yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ @@ -2934,21 +3111,48 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("closes thread terminals after a successful archive command", () => + it.effect("stops the provider session and closes thread terminals after archive", () => Effect.gen(function* () { const threadId = ThreadId.make("thread-archive"); - const closeInputs: Array[0]> = []; + const effects: string[] = []; + const dispatchedCommands: Array = []; + const now = new Date().toISOString(); yield* buildAppUnderTest({ layers: { terminalManager: { close: (input) => Effect.sync(() => { - closeInputs.push(input); + effects.push(`terminal.close:${input.threadId}`); }), }, orchestrationEngine: { - dispatch: () => Effect.succeed({ sequence: 8 }), + dispatch: (command) => + Effect.sync(() => { + dispatchedCommands.push(command); + effects.push(`dispatch:${command.type}`); + return { sequence: dispatchedCommands.length }; + }), + }, + projectionSnapshotQuery: { + getThreadShellById: () => + Effect.succeed( + Option.some( + makeDefaultOrchestrationThreadShell({ + id: threadId, + updatedAt: now, + session: { + threadId, + status: "ready", + providerName: "claudeAgent", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + }), + ), + ), }, }, }); @@ -2964,8 +3168,363 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ), ); - assert.equal(dispatchResult.sequence, 8); - assert.deepEqual(closeInputs, [{ threadId }]); + assert.equal(dispatchResult.sequence, 1); + assert.deepEqual(effects, [ + "dispatch:thread.archive", + "dispatch:thread.session.stop", + `terminal.close:${threadId}`, + ]); + const sessionStopCommand = dispatchedCommands[1]; + assert.equal(sessionStopCommand?.type, "thread.session.stop"); + if (sessionStopCommand?.type === "thread.session.stop") { + assert.equal(sessionStopCommand.threadId, threadId); + } + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("checks session status before archiving removes the thread from active lookups", () => + Effect.gen(function* () { + const threadId = ThreadId.make("thread-archive-precheck"); + const effects: string[] = []; + const dispatchedCommands: Array = []; + const now = new Date().toISOString(); + let archived = false; + + yield* buildAppUnderTest({ + layers: { + terminalManager: { + close: (input) => + Effect.sync(() => { + effects.push(`terminal.close:${input.threadId}`); + }), + }, + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + dispatchedCommands.push(command); + effects.push(`dispatch:${command.type}`); + if (command.type === "thread.archive") { + archived = true; + } + return { sequence: dispatchedCommands.length }; + }), + }, + projectionSnapshotQuery: { + getThreadShellById: () => + Effect.sync(() => { + effects.push(`query:thread-shell:${archived ? "archived" : "active"}`); + return archived + ? Option.none() + : Option.some( + makeDefaultOrchestrationThreadShell({ + id: threadId, + updatedAt: now, + session: { + threadId, + status: "ready", + providerName: "claudeAgent", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + }), + ); + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const dispatchResult = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.archive", + commandId: CommandId.make("cmd-thread-archive-precheck"), + threadId, + }), + ), + ); + + assert.equal(dispatchResult.sequence, 1); + assert.deepEqual(effects, [ + "query:thread-shell:active", + "dispatch:thread.archive", + "dispatch:thread.session.stop", + `terminal.close:${threadId}`, + ]); + assert.deepEqual( + dispatchedCommands.map((command) => command.type), + ["thread.archive", "thread.session.stop"], + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("archives without dispatching session stop when the thread has no session", () => + Effect.gen(function* () { + const threadId = ThreadId.make("thread-archive-no-session"); + const effects: string[] = []; + const dispatchedCommands: Array = []; + + yield* buildAppUnderTest({ + layers: { + terminalManager: { + close: (input) => + Effect.sync(() => { + effects.push(`terminal.close:${input.threadId}`); + }), + }, + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + dispatchedCommands.push(command); + effects.push(`dispatch:${command.type}`); + return { sequence: dispatchedCommands.length }; + }), + }, + projectionSnapshotQuery: { + getThreadShellById: () => + Effect.succeed( + Option.some(makeDefaultOrchestrationThreadShell({ id: threadId, session: null })), + ), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const dispatchResult = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.archive", + commandId: CommandId.make("cmd-thread-archive-no-session"), + threadId, + }), + ), + ); + + assert.equal(dispatchResult.sequence, 1); + assert.deepEqual(effects, ["dispatch:thread.archive", `terminal.close:${threadId}`]); + assert.deepEqual( + dispatchedCommands.map((command) => command.type), + ["thread.archive"], + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect( + "archives without dispatching session stop when the thread session is already stopped", + () => + Effect.gen(function* () { + const threadId = ThreadId.make("thread-archive-stopped-session"); + const effects: string[] = []; + const dispatchedCommands: Array = []; + const now = new Date().toISOString(); + + yield* buildAppUnderTest({ + layers: { + terminalManager: { + close: (input) => + Effect.sync(() => { + effects.push(`terminal.close:${input.threadId}`); + }), + }, + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + dispatchedCommands.push(command); + effects.push(`dispatch:${command.type}`); + return { sequence: dispatchedCommands.length }; + }), + }, + projectionSnapshotQuery: { + getThreadShellById: () => + Effect.succeed( + Option.some( + makeDefaultOrchestrationThreadShell({ + id: threadId, + updatedAt: now, + session: { + threadId, + status: "stopped", + providerName: "claudeAgent", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + }), + ), + ), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const dispatchResult = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.archive", + commandId: CommandId.make("cmd-thread-archive-stopped-session"), + threadId, + }), + ), + ); + + assert.equal(dispatchResult.sequence, 1); + assert.deepEqual(effects, ["dispatch:thread.archive", `terminal.close:${threadId}`]); + assert.deepEqual( + dispatchedCommands.map((command) => command.type), + ["thread.archive"], + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("archives and still closes terminals when session stop fails", () => + Effect.gen(function* () { + const threadId = ThreadId.make("thread-archive-stop-failure"); + const effects: string[] = []; + const dispatchedCommands: Array = []; + const now = new Date().toISOString(); + + yield* buildAppUnderTest({ + layers: { + terminalManager: { + close: (input) => + Effect.sync(() => { + effects.push(`terminal.close:${input.threadId}`); + }), + }, + orchestrationEngine: { + dispatch: (command) => { + dispatchedCommands.push(command); + effects.push(`dispatch:${command.type}`); + if (command.type === "thread.session.stop") { + return Effect.fail( + new OrchestrationListenerCallbackError({ + listener: "domain-event", + detail: "simulated archive stop failure", + }), + ); + } + return Effect.succeed({ sequence: dispatchedCommands.length }); + }, + }, + projectionSnapshotQuery: { + getThreadShellById: () => + Effect.succeed( + Option.some( + makeDefaultOrchestrationThreadShell({ + id: threadId, + updatedAt: now, + session: { + threadId, + status: "ready", + providerName: "claudeAgent", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + }), + ), + ), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const dispatchResult = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.archive", + commandId: CommandId.make("cmd-thread-archive-stop-failure"), + threadId, + }), + ), + ); + + assert.equal(dispatchResult.sequence, 1); + assert.deepEqual(effects, [ + "dispatch:thread.archive", + "dispatch:thread.session.stop", + `terminal.close:${threadId}`, + ]); + assert.deepEqual( + dispatchedCommands.map((command) => command.type), + ["thread.archive", "thread.session.stop"], + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("archives and still closes terminals when session stop defects", () => + Effect.gen(function* () { + const threadId = ThreadId.make("thread-archive-stop-defect"); + const effects: string[] = []; + const dispatchedCommands: Array = []; + const now = new Date().toISOString(); + + yield* buildAppUnderTest({ + layers: { + terminalManager: { + close: (input) => + Effect.sync(() => { + effects.push(`terminal.close:${input.threadId}`); + }), + }, + orchestrationEngine: { + dispatch: (command) => { + dispatchedCommands.push(command); + effects.push(`dispatch:${command.type}`); + if (command.type === "thread.session.stop") { + return Effect.die(new Error("simulated archive stop defect")); + } + return Effect.succeed({ sequence: dispatchedCommands.length }); + }, + }, + projectionSnapshotQuery: { + getThreadShellById: () => + Effect.succeed( + Option.some( + makeDefaultOrchestrationThreadShell({ + id: threadId, + updatedAt: now, + session: { + threadId, + status: "ready", + providerName: "claudeAgent", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + }), + ), + ), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const dispatchResult = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.archive", + commandId: CommandId.make("cmd-thread-archive-stop-defect"), + threadId, + }), + ), + ); + + assert.equal(dispatchResult.sequence, 1); + assert.deepEqual(effects, [ + "dispatch:thread.archive", + "dispatch:thread.session.stop", + `terminal.close:${threadId}`, + ]); + assert.deepEqual( + dispatchedCommands.map((command) => command.type), + ["thread.archive", "thread.session.stop"], + ); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); @@ -2974,6 +3533,24 @@ it.layer(NodeServices.layer)("server router seam", (it) => { () => Effect.gen(function* () { const dispatchedCommands: Array = []; + const refreshStatus = vi.fn((_: string) => + Effect.succeed({ + isRepo: true, + hasOriginRemote: true, + isDefaultBranch: false, + branch: "t3code/bootstrap-branch", + hasWorkingTreeChanges: false, + workingTree: { + files: [], + insertions: 0, + deletions: 0, + }, + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, + }), + ); const createWorktree = vi.fn((_: Parameters[0]) => Effect.succeed({ worktree: { @@ -2998,6 +3575,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { gitCore: { createWorktree, }, + gitStatusBroadcaster: { + refreshStatus, + }, orchestrationEngine: { dispatch: (command) => Effect.sync(() => { @@ -3075,6 +3655,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { projectCwd: "/tmp/project", worktreePath: "/tmp/bootstrap-worktree", }); + assert.deepEqual(refreshStatus.mock.calls[0]?.[0], "/tmp/bootstrap-worktree"); const setupActivities = dispatchedCommands.filter( (command): command is Extract => @@ -3376,205 +3957,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect( - "routes websocket rpc subscribeOrchestrationDomainEvents with replay/live overlap resilience", - () => - Effect.gen(function* () { - const now = new Date().toISOString(); - const threadId = ThreadId.make("thread-1"); - let replayCursor: number | null = null; - const makeEvent = (sequence: number): OrchestrationEvent => - ({ - sequence, - eventId: `event-${sequence}`, - aggregateKind: "thread", - aggregateId: threadId, - occurredAt: now, - commandId: null, - causationEventId: null, - correlationId: null, - metadata: {}, - type: "thread.reverted", - payload: { - threadId, - turnCount: sequence, - }, - }) as OrchestrationEvent; - - yield* buildAppUnderTest({ - layers: { - orchestrationEngine: { - getReadModel: () => - Effect.succeed({ - ...makeDefaultOrchestrationReadModel(), - snapshotSequence: 1, - }), - readEvents: (fromSequenceExclusive) => { - replayCursor = fromSequenceExclusive; - return Stream.make(makeEvent(2), makeEvent(3)); - }, - streamDomainEvents: Stream.make(makeEvent(3), makeEvent(4)), - }, - }, - }); - - const wsUrl = yield* getWsServerUrl("/ws"); - const events = yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.subscribeOrchestrationDomainEvents]({}).pipe( - Stream.take(3), - Stream.runCollect, - ), - ), - ); - - assert.equal(replayCursor, 1); - assert.deepEqual( - Array.from(events).map((event) => event.sequence), - [2, 3, 4], - ); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), - ); - - it.effect("enriches replayed project events only once before streaming them to subscribers", () => - Effect.gen(function* () { - let resolveCalls = 0; - const repositoryIdentity = { - canonicalKey: "github.com/marcode/marcode", - locator: { - source: "git-remote" as const, - remoteName: "origin", - remoteUrl: "git@github.com:marcode/marcode.git", - }, - displayName: "marcode/marcode", - provider: "github" as const, - owner: "marcode", - name: "marcode", - }; - - yield* buildAppUnderTest({ - layers: { - orchestrationEngine: { - getReadModel: () => - Effect.succeed({ - ...makeDefaultOrchestrationReadModel(), - snapshotSequence: 0, - }), - readEvents: () => - Stream.make({ - sequence: 1, - eventId: EventId.make("event-1"), - aggregateKind: "project", - aggregateId: defaultProjectId, - occurredAt: "2026-04-06T00:00:00.000Z", - commandId: null, - causationEventId: null, - correlationId: null, - metadata: {}, - type: "project.meta-updated", - payload: { - projectId: defaultProjectId, - title: "Replayed Project", - updatedAt: "2026-04-06T00:00:00.000Z", - }, - } satisfies Extract), - streamDomainEvents: Stream.empty, - }, - repositoryIdentityResolver: { - resolve: () => { - resolveCalls += 1; - return Effect.succeed(repositoryIdentity); - }, - }, - }, - }); - - const wsUrl = yield* getWsServerUrl("/ws"); - const events = yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.subscribeOrchestrationDomainEvents]({}).pipe( - Stream.take(1), - Stream.runCollect, - ), - ), - ); - - const event = Array.from(events)[0]; - assert.equal(resolveCalls, 1); - assert.equal(event?.type, "project.meta-updated"); - assert.deepEqual( - event && event.type === "project.meta-updated" ? event.payload.repositoryIdentity : null, - repositoryIdentity, - ); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), - ); - - it.effect("enriches subscribed project meta updates with repository identity metadata", () => - Effect.gen(function* () { - const repositoryIdentity = { - canonicalKey: "github.com/marcode/marcode", - locator: { - source: "git-remote" as const, - remoteName: "upstream", - remoteUrl: "git@github.com:marcode/marcode.git", - }, - displayName: "marcode/marcode", - provider: "github", - owner: "marcode", - name: "marcode", - }; - - yield* buildAppUnderTest({ - layers: { - orchestrationEngine: { - getReadModel: () => - Effect.succeed({ - ...makeDefaultOrchestrationReadModel(), - snapshotSequence: 0, - }), - streamDomainEvents: Stream.make({ - sequence: 1, - eventId: EventId.make("event-1"), - aggregateKind: "project", - aggregateId: defaultProjectId, - occurredAt: "2026-04-05T00:00:00.000Z", - commandId: null, - causationEventId: null, - correlationId: null, - metadata: {}, - type: "project.meta-updated", - payload: { - projectId: defaultProjectId, - title: "Renamed Project", - updatedAt: "2026-04-05T00:00:00.000Z", - }, - } satisfies Extract), - }, - repositoryIdentityResolver: { - resolve: () => Effect.succeed(repositoryIdentity), - }, - }, - }); - - const wsUrl = yield* getWsServerUrl("/ws"); - const events = yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.subscribeOrchestrationDomainEvents]({}).pipe( - Stream.take(1), - Stream.runCollect, - ), - ), - ); - - const event = Array.from(events)[0]; - assert.equal(event?.type, "project.meta-updated"); - assert.deepEqual( - event && event.type === "project.meta-updated" ? event.payload.repositoryIdentity : null, - repositoryIdentity, - ); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), - ); - it.effect("routes websocket rpc orchestration.getSnapshot errors", () => Effect.gen(function* () { yield* buildAppUnderTest({ diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 12e2ce25efe..9061ac7a253 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -1,7 +1,7 @@ import { Effect, Layer } from "effect"; import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http"; -import { ServerConfig } from "./config"; +import { ServerConfig } from "./config.ts"; import { attachmentsRouteLayer, otlpTracesProxyRouteLayer, @@ -9,53 +9,54 @@ import { serverEnvironmentRouteLayer, staticAndDevRouteLayer, browserApiCorsLayer, -} from "./http"; -import { fixPath } from "./os-jank"; -import { websocketRpcRouteLayer } from "./ws"; -import { OpenLive } from "./open"; -import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite"; -import { ServerLifecycleEventsLive } from "./serverLifecycleEvents"; -import { makeEventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger"; -import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory"; -import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime"; -import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter"; -import { makeClaudeAdapterLive } from "./provider/Layers/ClaudeAdapter"; -import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry"; -import { makeProviderServiceLive } from "./provider/Layers/ProviderService"; -import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery"; -import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore"; -import { GitCoreLive } from "./git/Layers/GitCore"; -import { GitHubCliLive } from "./git/Layers/GitHubCli"; -import { RoutingGitHostCliLive } from "./git/Layers/RoutingGitHostCli"; -import { GitStatusBroadcasterLive } from "./git/Layers/GitStatusBroadcaster"; -import { RoutingTextGenerationLive } from "./git/Layers/RoutingTextGeneration"; -import { TerminalManagerLive } from "./terminal/Layers/Manager"; -import { GitManagerLive } from "./git/Layers/GitManager"; -import { KeybindingsLive } from "./keybindings"; -import { ServerRuntimeStartup, ServerRuntimeStartupLive } from "./serverRuntimeStartup"; -import { OrchestrationReactorLive } from "./orchestration/Layers/OrchestrationReactor"; -import { RuntimeReceiptBusLive } from "./orchestration/Layers/RuntimeReceiptBus"; -import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion"; -import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor"; -import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor"; -import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry"; -import { ServerSettingsLive } from "./serverSettings"; -import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver"; -import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver"; -import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries"; -import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem"; -import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths"; -import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner"; -import { ObservabilityLive } from "./observability/Layers/Observability"; -import { JiraTokenServiceLive } from "./jira/Layers/JiraTokenService"; -import { JiraApiClientLive } from "./jira/Layers/JiraApiClient"; +} from "./http.ts"; +import { fixPath } from "./os-jank.ts"; +import { websocketRpcRouteLayer } from "./ws.ts"; +import { OpenLive } from "./open.ts"; +import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts"; +import { ServerLifecycleEventsLive } from "./serverLifecycleEvents.ts"; +import { AnalyticsServiceNoopLive } from "./telemetry/Layers/AnalyticsService.ts"; +import { makeEventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger.ts"; +import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory.ts"; +import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime.ts"; +import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter.ts"; +import { makeClaudeAdapterLive } from "./provider/Layers/ClaudeAdapter.ts"; +import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry.ts"; +import { makeProviderServiceLive } from "./provider/Layers/ProviderService.ts"; +import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReaper.ts"; +import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery.ts"; +import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore.ts"; +import { GitCoreLive } from "./git/Layers/GitCore.ts"; +import { GitHubCliLive } from "./git/Layers/GitHubCli.ts"; +import { RoutingGitHostCliLive } from "./git/Layers/RoutingGitHostCli.ts"; +import { GitStatusBroadcasterLive } from "./git/Layers/GitStatusBroadcaster.ts"; +import { RoutingTextGenerationLive } from "./git/Layers/RoutingTextGeneration.ts"; +import { TerminalManagerLive } from "./terminal/Layers/Manager.ts"; +import { GitManagerLive } from "./git/Layers/GitManager.ts"; +import { KeybindingsLive } from "./keybindings.ts"; +import { ServerRuntimeStartup, ServerRuntimeStartupLive } from "./serverRuntimeStartup.ts"; +import { OrchestrationReactorLive } from "./orchestration/Layers/OrchestrationReactor.ts"; +import { RuntimeReceiptBusLive } from "./orchestration/Layers/RuntimeReceiptBus.ts"; +import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion.ts"; +import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor.ts"; +import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts"; +import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts"; +import { ServerSettingsLive } from "./serverSettings.ts"; +import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts"; +import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver.ts"; +import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts"; +import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts"; +import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; +import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner.ts"; +import { ObservabilityLive } from "./observability/Layers/Observability.ts"; +import { JiraTokenServiceLive } from "./jira/Layers/JiraTokenService.ts"; +import { JiraApiClientLive } from "./jira/Layers/JiraApiClient.ts"; import { jiraAttachmentProxyRouteLayer, jiraAuthRouteLayer, jiraCallbackRouteLayer, -} from "./jira/oauthRoutes"; -import { ServerEnvironmentLive } from "./environment/Layers/ServerEnvironment"; -import { AnalyticsServiceNoopLive } from "./telemetry/Layers/AnalyticsService"; +} from "./jira/oauthRoutes.ts"; +import { ServerEnvironmentLive } from "./environment/Layers/ServerEnvironment.ts"; import { authBearerBootstrapRouteLayer, authBootstrapRouteLayer, @@ -67,27 +68,27 @@ import { authPairingCredentialRouteLayer, authSessionRouteLayer, authWebSocketTokenRouteLayer, -} from "./auth/http"; -import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore"; -import { ServerAuthLive } from "./auth/Layers/ServerAuth"; -import { OrchestrationLayerLive } from "./orchestration/runtimeLayer"; +} from "./auth/http.ts"; +import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts"; +import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts"; +import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; import { clearPersistedServerRuntimeState, makePersistedServerRuntimeState, persistServerRuntimeState, -} from "./serverRuntimeState"; +} from "./serverRuntimeState.ts"; import { orchestrationDispatchRouteLayer, orchestrationSnapshotRouteLayer, -} from "./orchestration/http"; +} from "./orchestration/http.ts"; const PtyAdapterLive = Layer.unwrap( Effect.gen(function* () { if (typeof Bun !== "undefined") { - const BunPTY = yield* Effect.promise(() => import("./terminal/Layers/BunPTY")); + const BunPTY = yield* Effect.promise(() => import("./terminal/Layers/BunPTY.ts")); return BunPTY.layer; } else { - const NodePTY = yield* Effect.promise(() => import("./terminal/Layers/NodePTY")); + const NodePTY = yield* Effect.promise(() => import("./terminal/Layers/NodePTY.ts")); return NodePTY.layer; } }), @@ -142,6 +143,10 @@ const CheckpointingLayerLive = Layer.empty.pipe( Layer.provideMerge(CheckpointStoreLive), ); +const ProviderSessionDirectoryLayerLive = ProviderSessionDirectoryLive.pipe( + Layer.provide(ProviderSessionRuntimeRepositoryLive), +); + const ProviderLayerLive = Layer.unwrap( Effect.gen(function* () { const { providerEventLogPath } = yield* ServerConfig; @@ -151,9 +156,6 @@ const ProviderLayerLive = Layer.unwrap( const canonicalEventLogger = yield* makeEventNdjsonLogger(providerEventLogPath, { stream: "canonical", }); - const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe( - Layer.provide(ProviderSessionRuntimeRepositoryLive), - ); const codexAdapterLayer = makeCodexAdapterLive( nativeEventLogger ? { nativeEventLogger } : undefined, ); @@ -163,11 +165,14 @@ const ProviderLayerLive = Layer.unwrap( const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe( Layer.provide(codexAdapterLayer), Layer.provide(claudeAdapterLayer), - Layer.provideMerge(providerSessionDirectoryLayer), + Layer.provideMerge(ProviderSessionDirectoryLayerLive), ); return makeProviderServiceLive( canonicalEventLogger ? { canonicalEventLogger } : undefined, - ).pipe(Layer.provide(adapterRegistryLayer), Layer.provide(providerSessionDirectoryLayer)); + ).pipe( + Layer.provide(adapterRegistryLayer), + Layer.provideMerge(ProviderSessionDirectoryLayerLive), + ); }), ); @@ -193,13 +198,20 @@ const GitLayerLive = Layer.empty.pipe( const TerminalLayerLive = TerminalManagerLive.pipe(Layer.provide(PtyAdapterLive)); +const WorkspaceEntriesLayerLive = WorkspaceEntriesLive.pipe( + Layer.provide(WorkspacePathsLive), + Layer.provideMerge(GitCoreLive), +); + +const WorkspaceFileSystemLayerLive = WorkspaceFileSystemLive.pipe( + Layer.provide(WorkspacePathsLive), + Layer.provide(WorkspaceEntriesLayerLive), +); + const WorkspaceLayerLive = Layer.mergeAll( WorkspacePathsLive, - WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive)), - WorkspaceFileSystemLive.pipe( - Layer.provide(WorkspacePathsLive), - Layer.provide(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))), - ), + WorkspaceEntriesLayerLive, + WorkspaceFileSystemLayerLive, ); const JiraLayerLive = JiraApiClientLive.pipe(Layer.provideMerge(JiraTokenServiceLive)); @@ -209,12 +221,16 @@ const AuthLayerLive = ServerAuthLive.pipe( Layer.provide(ServerSecretStoreLive), ); +const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe( + Layer.provideMerge(ProviderLayerLive), + Layer.provideMerge(OrchestrationLayerLive), +); + const RuntimeDependenciesLive = ReactorLayerLive.pipe( // Core Services Layer.provideMerge(CheckpointingLayerLive), Layer.provideMerge(GitLayerLive), - Layer.provideMerge(OrchestrationLayerLive), - Layer.provideMerge(ProviderLayerLive), + Layer.provideMerge(ProviderRuntimeLayerLive), Layer.provideMerge(TerminalLayerLive), Layer.provideMerge(PersistenceLayerLive), Layer.provideMerge(KeybindingsLive), diff --git a/apps/server/src/serverLogger.ts b/apps/server/src/serverLogger.ts index ea098dcbbea..57d51b2a9e8 100644 --- a/apps/server/src/serverLogger.ts +++ b/apps/server/src/serverLogger.ts @@ -1,6 +1,6 @@ import { Effect, Logger, References, Layer } from "effect"; -import { ServerConfig } from "./config"; +import { ServerConfig } from "./config.ts"; export const ServerLoggerLive = Effect.gen(function* () { const config = yield* ServerConfig; diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index 5669126fae7..f9b571b5797 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -1,13 +1,21 @@ -import { DEFAULT_MODEL_BY_PROVIDER } from "@marcode/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { DEFAULT_MODEL_BY_PROVIDER, ProjectId, ThreadId } from "@marcode/contracts"; import { assert, it } from "@effect/vitest"; -import { Deferred, Effect, Fiber, Option, Ref } from "effect"; +import { Deferred, Effect, Fiber, Option, Ref, Stream } from "effect"; -import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts"; +import { ServerConfig } from "./config.ts"; +import { + OrchestrationEngineService, + type OrchestrationEngineShape, +} from "./orchestration/Services/OrchestrationEngine.ts"; import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; +import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts"; import { getAutoBootstrapDefaultModelSelection, launchStartupHeartbeat, makeCommandGate, + resolveAutoBootstrapWelcomeTargets, + resolveWelcomeBase, ServerRuntimeStartupError, } from "./serverRuntimeStartup.ts"; @@ -72,6 +80,7 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa getSnapshot: () => Effect.die("unused"), getListingSnapshot: () => Effect.die("unused"), getThread: () => Effect.die("unused"), + getShellSnapshot: () => Effect.die("unused"), getCounts: () => Deferred.await(releaseCounts).pipe( Effect.as({ @@ -80,8 +89,11 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa }), ), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), getThreadCheckpointContext: () => Effect.succeed(Option.none()), + getThreadShellById: () => Effect.succeed(Option.none()), + getThreadDetailById: () => Effect.succeed(Option.none()), }), Effect.provideService(AnalyticsService, { record: () => Effect.void, @@ -91,3 +103,114 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa }), ), ); + +it.effect("resolveWelcomeBase derives cwd and project name from server config", () => + Effect.gen(function* () { + const welcome = yield* resolveWelcomeBase.pipe( + Effect.provideService(ServerConfig, { + cwd: "/tmp/startup-project", + } as never), + ); + + assert.deepStrictEqual(welcome, { + cwd: "/tmp/startup-project", + projectName: "startup-project", + }); + }), +); + +it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and thread ids", () => { + const bootstrapProjectId = ProjectId.make("project-startup-bootstrap"); + const bootstrapThreadId = ThreadId.make("thread-startup-bootstrap"); + + return Effect.gen(function* () { + const dispatchCalls = yield* Ref.make>([]); + const targets = yield* resolveAutoBootstrapWelcomeTargets.pipe( + Effect.provideService(ServerConfig, { + cwd: "/tmp/startup-project", + autoBootstrapProjectFromCwd: true, + } as never), + Effect.provideService(ProjectionSnapshotQuery, { + getSnapshot: () => Effect.die("unused"), + getListingSnapshot: () => Effect.die("unused"), + getThread: () => Effect.die("unused"), + getShellSnapshot: () => Effect.die("unused"), + getCounts: () => Effect.die("unused"), + getActiveProjectByWorkspaceRoot: () => + Effect.succeed( + Option.some({ + id: bootstrapProjectId, + title: "Startup Project", + workspaceRoot: "/tmp/startup-project", + defaultModelSelection: getAutoBootstrapDefaultModelSelection(), + scripts: [], + jiraBoard: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + deletedAt: null, + }), + ), + getProjectShellById: () => Effect.die("unused"), + getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.some(bootstrapThreadId)), + getThreadCheckpointContext: () => Effect.succeed(Option.none()), + getThreadShellById: () => Effect.die("unused"), + getThreadDetailById: () => Effect.die("unused"), + }), + Effect.provideService(OrchestrationEngineService, { + getReadModel: () => Effect.die("unused"), + readEvents: () => Stream.empty, + dispatch: (command) => + Ref.update(dispatchCalls, (calls) => [...calls, command.type]).pipe( + Effect.as({ sequence: 1 }), + ), + streamDomainEvents: Stream.empty, + } satisfies OrchestrationEngineShape), + Effect.provide(NodeServices.layer), + ); + + assert.deepStrictEqual(targets, { + bootstrapProjectId, + bootstrapThreadId, + }); + assert.deepStrictEqual(yield* Ref.get(dispatchCalls), []); + }); +}); + +it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when missing", () => + Effect.gen(function* () { + const dispatchCalls = yield* Ref.make>([]); + const targets = yield* resolveAutoBootstrapWelcomeTargets.pipe( + Effect.provideService(ServerConfig, { + cwd: "/tmp/startup-project", + autoBootstrapProjectFromCwd: true, + } as never), + Effect.provideService(ProjectionSnapshotQuery, { + getSnapshot: () => Effect.die("unused"), + getListingSnapshot: () => Effect.die("unused"), + getThread: () => Effect.die("unused"), + getShellSnapshot: () => Effect.die("unused"), + getCounts: () => Effect.die("unused"), + getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getProjectShellById: () => Effect.die("unused"), + getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getThreadCheckpointContext: () => Effect.succeed(Option.none()), + getThreadShellById: () => Effect.die("unused"), + getThreadDetailById: () => Effect.die("unused"), + }), + Effect.provideService(OrchestrationEngineService, { + getReadModel: () => Effect.die("unused"), + readEvents: () => Stream.empty, + dispatch: (command) => + Ref.update(dispatchCalls, (calls) => [...calls, command.type]).pipe( + Effect.as({ sequence: 1 }), + ), + streamDomainEvents: Stream.empty, + } satisfies OrchestrationEngineShape), + Effect.provide(NodeServices.layer), + ); + + assert.equal(typeof targets.bootstrapProjectId, "string"); + assert.equal(typeof targets.bootstrapThreadId, "string"); + assert.deepStrictEqual(yield* Ref.get(dispatchCalls), ["project.create", "thread.create"]); + }), +); diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 876ef8b42ef..837e1ab8bca 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -21,23 +21,24 @@ import { Console, } from "effect"; -import { ServerConfig } from "./config"; -import { Keybindings } from "./keybindings"; -import { Open } from "./open"; -import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine"; -import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; -import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor"; -import { ServerLifecycleEvents } from "./serverLifecycleEvents"; -import { ServerSettingsService } from "./serverSettings"; -import { ServerEnvironment } from "./environment/Services/ServerEnvironment"; -import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; -import { ServerAuth } from "./auth/Services/ServerAuth"; +import { ServerConfig } from "./config.ts"; +import { Keybindings } from "./keybindings.ts"; +import { Open } from "./open.ts"; +import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; +import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor.ts"; +import { ServerLifecycleEvents } from "./serverLifecycleEvents.ts"; +import { ServerSettingsService } from "./serverSettings.ts"; +import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts"; +import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts"; +import { ServerAuth } from "./auth/Services/ServerAuth.ts"; +import { ProviderSessionReaper } from "./provider/Services/ProviderSessionReaper.ts"; import { formatHeadlessServeOutput, formatHostForUrl, isWildcardHost, issueHeadlessServeAccessInfo, -} from "./startupAccess"; +} from "./startupAccess.ts"; export class ServerRuntimeStartupError extends Data.TaggedError("ServerRuntimeStartupError")<{ readonly message: string; @@ -157,7 +158,18 @@ export const getAutoBootstrapDefaultModelSelection = (): ModelSelection => ({ model: DEFAULT_MODEL_BY_PROVIDER.claudeAgent, }); -const autoBootstrapWelcome = Effect.gen(function* () { +export const resolveWelcomeBase = Effect.gen(function* () { + const serverConfig = yield* ServerConfig; + const segments = serverConfig.cwd.split(/[/\\]/).filter(Boolean); + const projectName = segments[segments.length - 1] ?? "project"; + + return { + cwd: serverConfig.cwd, + projectName, + } as const; +}); + +export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { const serverConfig = yield* ServerConfig; const projectionReadModelQuery = yield* ProjectionSnapshotQuery; const orchestrationEngine = yield* OrchestrationEngineService; @@ -221,12 +233,7 @@ const autoBootstrapWelcome = Effect.gen(function* () { }); } - const segments = serverConfig.cwd.split(/[/\\]/).filter(Boolean); - const projectName = segments[segments.length - 1] ?? "project"; - return { - cwd: serverConfig.cwd, - projectName, ...(bootstrapProjectId ? { bootstrapProjectId } : {}), ...(bootstrapThreadId ? { bootstrapThreadId } : {}), } as const; @@ -271,10 +278,11 @@ const runStartupPhase = (phase: string, effect: Effect.Effect) Effect.withSpan(`server.startup.${phase}`), ); -const makeServerRuntimeStartup = Effect.gen(function* () { +export const makeServerRuntimeStartup = Effect.gen(function* () { const serverConfig = yield* ServerConfig; const keybindings = yield* Keybindings; const orchestrationReactor = yield* OrchestrationReactor; + const providerSessionReaper = yield* ProviderSessionReaper; const lifecycleEvents = yield* ServerLifecycleEvents; const serverSettings = yield* ServerSettingsService; const serverEnvironment = yield* ServerEnvironment; @@ -319,18 +327,19 @@ const makeServerRuntimeStartup = Effect.gen(function* () { yield* Effect.logDebug("startup phase: starting orchestration reactors"); yield* runStartupPhase( "reactors.start", - orchestrationReactor.start().pipe(Scope.provide(reactorScope)), + Effect.gen(function* () { + yield* orchestrationReactor.start().pipe(Scope.provide(reactorScope)); + yield* providerSessionReaper.start().pipe(Scope.provide(reactorScope)); + }), ); - yield* Effect.logDebug("startup phase: preparing welcome payload"); - const welcome = yield* runStartupPhase("welcome.prepare", autoBootstrapWelcome); + const welcomeBase = yield* resolveWelcomeBase; const environment = yield* serverEnvironment.getDescriptor; + yield* Effect.logDebug("startup phase: preparing welcome payload"); yield* Effect.logDebug("startup phase: publishing welcome event", { environmentId: environment.environmentId, - cwd: welcome.cwd, - projectName: welcome.projectName, - bootstrapProjectId: welcome.bootstrapProjectId, - bootstrapThreadId: welcome.bootstrapThreadId, + cwd: welcomeBase.cwd, + projectName: welcomeBase.projectName, }); yield* runStartupPhase( "welcome.publish", @@ -339,10 +348,47 @@ const makeServerRuntimeStartup = Effect.gen(function* () { type: "welcome", payload: { environment, - ...welcome, + ...welcomeBase, }, }), ); + + if (serverConfig.autoBootstrapProjectFromCwd) { + yield* Effect.forkScoped( + runStartupPhase( + "welcome.autobootstrap", + Effect.gen(function* () { + const bootstrapTargets = yield* resolveAutoBootstrapWelcomeTargets; + if (!bootstrapTargets.bootstrapProjectId && !bootstrapTargets.bootstrapThreadId) { + return; + } + + yield* Effect.logDebug("startup phase: publishing bootstrapped welcome event", { + environmentId: environment.environmentId, + cwd: welcomeBase.cwd, + projectName: welcomeBase.projectName, + bootstrapProjectId: bootstrapTargets.bootstrapProjectId, + bootstrapThreadId: bootstrapTargets.bootstrapThreadId, + }); + yield* lifecycleEvents.publish({ + version: 1, + type: "welcome", + payload: { + environment, + ...welcomeBase, + ...bootstrapTargets, + }, + }); + }).pipe( + Effect.catch((cause) => + Effect.logWarning("startup auto-bootstrap welcome failed", { + cause, + }), + ), + ), + ), + ); + } }).pipe( Effect.annotateSpans({ "server.mode": serverConfig.mode, diff --git a/apps/server/src/serverRuntimeState.ts b/apps/server/src/serverRuntimeState.ts index 00c83844682..569e4ac1179 100644 --- a/apps/server/src/serverRuntimeState.ts +++ b/apps/server/src/serverRuntimeState.ts @@ -1,7 +1,7 @@ import { Effect, FileSystem, Option, Path, Schema } from "effect"; -import { type ServerConfigShape } from "./config"; -import { formatHostForUrl, isWildcardHost } from "./startupAccess"; +import { type ServerConfigShape } from "./config.ts"; +import { formatHostForUrl, isWildcardHost } from "./startupAccess.ts"; export const PersistedServerRuntimeState = Schema.Struct({ version: Schema.Literal(1), diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 923b6f1a3d1..3e73ee9c3aa 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -2,8 +2,8 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { DEFAULT_SERVER_SETTINGS, ServerSettingsPatch } from "@marcode/contracts"; import { assert, it } from "@effect/vitest"; import { Effect, FileSystem, Layer, Schema } from "effect"; -import { ServerConfig } from "./config"; -import { ServerSettingsLive, ServerSettingsService } from "./serverSettings"; +import { ServerConfig } from "./config.ts"; +import { ServerSettingsLive, ServerSettingsService } from "./serverSettings.ts"; const makeServerSettingsLayer = () => ServerSettingsLive.pipe( @@ -92,6 +92,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { enabled: true, binaryPath: "/usr/local/bin/claude", customModels: ["claude-custom"], + launchArgs: "", }); assert.deepEqual(next.textGenerationModelSelection, { provider: "codex", @@ -141,6 +142,35 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(makeServerSettingsLayer())), ); + it.effect("drops stale text generation options when resetting model selection", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsService; + + yield* serverSettings.updateSettings({ + textGenerationModelSelection: { + provider: "codex", + model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, + options: { + reasoningEffort: "high", + fastMode: true, + }, + }, + }); + + const next = yield* serverSettings.updateSettings({ + textGenerationModelSelection: { + provider: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.provider, + model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, + }, + }); + + assert.deepEqual(next.textGenerationModelSelection, { + provider: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.provider, + model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, + }); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + it.effect("trims provider path settings when updates are applied", () => Effect.gen(function* () { const serverSettings = yield* ServerSettingsService; @@ -167,6 +197,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { enabled: true, binaryPath: "/opt/homebrew/bin/claude", customModels: [], + launchArgs: "", }); }).pipe(Effect.provide(makeServerSettingsLayer())), ); @@ -176,12 +207,14 @@ it.layer(NodeServices.layer)("server settings", (it) => { const serverSettings = yield* ServerSettingsService; const next = yield* serverSettings.updateSettings({ + addProjectBaseDirectory: " ~/Development ", observability: { otlpTracesUrl: " http://localhost:4318/v1/traces ", otlpMetricsUrl: " http://localhost:4318/v1/metrics ", }, }); + assert.equal(next.addProjectBaseDirectory, "~/Development"); assert.deepEqual(next.observability, { otlpTracesUrl: "http://localhost:4318/v1/traces", otlpMetricsUrl: "http://localhost:4318/v1/metrics", @@ -215,6 +248,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { const serverConfig = yield* ServerConfig; const fileSystem = yield* FileSystem.FileSystem; const next = yield* serverSettings.updateSettings({ + addProjectBaseDirectory: "~/Development", observability: { otlpTracesUrl: "http://localhost:4318/v1/traces", otlpMetricsUrl: "http://localhost:4318/v1/metrics", @@ -230,6 +264,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { 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", diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index ee42e2befea..7eb2f898e1f 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -39,9 +39,10 @@ import { Cause, } from "effect"; import * as Semaphore from "effect/Semaphore"; -import { ServerConfig } from "./config"; +import { ServerConfig } from "./config.ts"; import { type DeepPartial, deepMerge } from "@marcode/shared/Struct"; import { fromLenientJson } from "@marcode/shared/schemaJson"; +import { applyServerSettingsPatch } from "@marcode/shared/serverSettings"; export interface ServerSettingsShape { /** Start the settings runtime and attach file watching. */ @@ -80,7 +81,20 @@ export class ServerSettingsService extends Context.Service< getSettings: Ref.get(currentSettingsRef), updateSettings: (patch) => Ref.get(currentSettingsRef).pipe( - Effect.map((currentSettings) => deepMerge(currentSettings, patch)), + Effect.flatMap((currentSettings) => + Schema.decodeEffect(ServerSettings)( + applyServerSettingsPatch(currentSettings, patch), + ).pipe( + Effect.mapError( + (cause) => + new ServerSettingsError({ + settingsPath: "", + detail: `failed to normalize server settings: ${SchemaIssue.makeFormatterDefault()(cause.issue)}`, + cause, + }), + ), + ), + ), Effect.tap((nextSettings) => Ref.set(currentSettingsRef, nextSettings)), ), streamChanges: Stream.empty, @@ -314,7 +328,9 @@ const makeServerSettings = Effect.gen(function* () { writeSemaphore.withPermits(1)( Effect.gen(function* () { const current = yield* getSettingsFromCache; - const next = yield* Schema.decodeEffect(ServerSettings)(deepMerge(current, patch)).pipe( + const next = yield* Schema.decodeEffect(ServerSettings)( + applyServerSettingsPatch(current, patch), + ).pipe( Effect.mapError( (cause) => new ServerSettingsError({ diff --git a/apps/server/src/startupAccess.test.ts b/apps/server/src/startupAccess.test.ts index ef6ece31e28..03c01170f15 100644 --- a/apps/server/src/startupAccess.test.ts +++ b/apps/server/src/startupAccess.test.ts @@ -7,7 +7,7 @@ import { resolveHeadlessConnectionHost, resolveHeadlessConnectionString, resolveListeningPort, -} from "./startupAccess"; +} from "./startupAccess.ts"; it("prefers localhost when no explicit host is configured", () => { expect(resolveHeadlessConnectionHost(undefined)).toBe("localhost"); diff --git a/apps/server/src/startupAccess.ts b/apps/server/src/startupAccess.ts index 927eb0d8d08..9bc901c7045 100644 --- a/apps/server/src/startupAccess.ts +++ b/apps/server/src/startupAccess.ts @@ -4,8 +4,8 @@ import { QrCode } from "@marcode/shared/qrCode"; import { Effect } from "effect"; import { HttpServer } from "effect/unstable/http"; -import { ServerConfig } from "./config"; -import { ServerAuth } from "./auth/Services/ServerAuth"; +import { ServerConfig } from "./config.ts"; +import { ServerAuth } from "./auth/Services/ServerAuth.ts"; export interface HeadlessServeAccessInfo { readonly connectionString: string; diff --git a/apps/server/src/telemetry/Layers/AnalyticsService.ts b/apps/server/src/telemetry/Layers/AnalyticsService.ts index 109fb26dcd7..1af0d552ae3 100644 --- a/apps/server/src/telemetry/Layers/AnalyticsService.ts +++ b/apps/server/src/telemetry/Layers/AnalyticsService.ts @@ -1,6 +1,6 @@ import { Effect, Layer } from "effect"; -import { AnalyticsService } from "../Services/AnalyticsService"; +import { AnalyticsService } from "../Services/AnalyticsService.ts"; export const AnalyticsServiceNoopLive = Layer.succeed(AnalyticsService, { record: () => Effect.void, diff --git a/apps/server/src/terminal/Layers/BunPTY.ts b/apps/server/src/terminal/Layers/BunPTY.ts index d695cb3b1ed..e7fe2abf47d 100644 --- a/apps/server/src/terminal/Layers/BunPTY.ts +++ b/apps/server/src/terminal/Layers/BunPTY.ts @@ -1,13 +1,16 @@ import { Effect, Layer } from "effect"; -import { PtyAdapter, PtyAdapterShape, PtyExitEvent, PtyProcess } from "../Services/PTY"; +import { PtyAdapter } from "../Services/PTY.ts"; +import type { PtyAdapterShape, PtyExitEvent, PtyProcess } from "../Services/PTY.ts"; class BunPtyProcess implements PtyProcess { private readonly dataListeners = new Set<(data: string) => void>(); private readonly exitListeners = new Set<(event: PtyExitEvent) => void>(); private readonly decoder = new TextDecoder(); + private readonly process: Bun.Subprocess; private didExit = false; - constructor(private readonly process: Bun.Subprocess) { + constructor(process: Bun.Subprocess) { + this.process = process; void this.process.exited .then((exitCode) => { this.emitExit({ diff --git a/apps/server/src/terminal/Layers/Manager.test.ts b/apps/server/src/terminal/Layers/Manager.test.ts index c24fe5f68f6..3d43e852b85 100644 --- a/apps/server/src/terminal/Layers/Manager.test.ts +++ b/apps/server/src/terminal/Layers/Manager.test.ts @@ -24,25 +24,28 @@ import { import { TestClock } from "effect/testing"; import { expect } from "vitest"; -import type { TerminalManagerShape } from "../Services/Manager"; +import type { TerminalManagerShape } from "../Services/Manager.ts"; import { type PtyAdapterShape, type PtyExitEvent, type PtyProcess, type PtySpawnInput, PtySpawnError, -} from "../Services/PTY"; -import { makeTerminalManagerWithOptions } from "./Manager"; +} from "../Services/PTY.ts"; +import { makeTerminalManagerWithOptions } from "./Manager.ts"; class FakePtyProcess implements PtyProcess { readonly writes: string[] = []; readonly resizeCalls: Array<{ cols: number; rows: number }> = []; readonly killSignals: Array = []; + readonly pid: number; private readonly dataListeners = new Set<(data: string) => void>(); private readonly exitListeners = new Set<(event: PtyExitEvent) => void>(); killed = false; - constructor(readonly pid: number) {} + constructor(pid: number) { + this.pid = pid; + } write(data: string): void { this.writes.push(data); @@ -88,9 +91,12 @@ class FakePtyAdapter implements PtyAdapterShape { readonly spawnInputs: PtySpawnInput[] = []; readonly processes: FakePtyProcess[] = []; readonly spawnFailures: Error[] = []; + private readonly mode: "sync" | "async"; private nextPid = 9000; - constructor(private readonly mode: "sync" | "async" = "sync") {} + constructor(mode: "sync" | "async" = "sync") { + this.mode = mode; + } spawn(input: PtySpawnInput): Effect.Effect { this.spawnInputs.push(input); @@ -188,6 +194,8 @@ function multiTerminalHistoryLogPath( interface CreateManagerOptions { shellResolver?: () => string; + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; subprocessChecker?: (terminalPid: number) => Effect.Effect; subprocessPollIntervalMs?: number; processKillGraceMs?: number; @@ -222,6 +230,8 @@ const createManager = ( historyLineLimit, ptyAdapter, ...(options.shellResolver !== undefined ? { shellResolver: options.shellResolver } : {}), + ...(options.platform !== undefined ? { platform: options.platform } : {}), + ...(options.env !== undefined ? { env: options.env } : {}), ...(options.subprocessChecker !== undefined ? { subprocessChecker: options.subprocessChecker } : {}), @@ -291,6 +301,8 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( it.effect("preserves non-notFound cwd stat failures", () => Effect.gen(function* () { + if (process.platform === "win32") return; + const { manager, baseDir } = yield* createManager(); const blockedRoot = path.join(baseDir, "blocked-root"); const blockedCwd = path.join(blockedRoot, "cwd"); @@ -821,8 +833,12 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( it.effect("retries with fallback shells when preferred shell spawn fails", () => Effect.gen(function* () { + const missingShell = + process.platform === "win32" + ? "C:\\definitely\\missing-shell.exe" + : "/definitely/missing-shell -l"; const { manager, ptyAdapter } = yield* createManager(5, { - shellResolver: () => "/definitely/missing-shell -l", + shellResolver: () => missingShell, }); ptyAdapter.spawnFailures.push(new Error("posix_spawnp failed.")); @@ -830,12 +846,17 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( assert.equal(snapshot.status, "running"); expect(ptyAdapter.spawnInputs.length).toBeGreaterThanOrEqual(2); - expect(ptyAdapter.spawnInputs[0]?.shell).toBe("/definitely/missing-shell"); + expect(ptyAdapter.spawnInputs[0]?.shell).toBe( + process.platform === "win32" ? missingShell : "/definitely/missing-shell", + ); if (process.platform === "win32") { expect( ptyAdapter.spawnInputs.some( - (input) => input.shell === "cmd.exe" || input.shell === "powershell.exe", + (input) => + input.shell === "pwsh.exe" || + input.shell === "powershell.exe" || + input.shell === "cmd.exe", ), ).toBe(true); } else { @@ -848,6 +869,56 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( }), ); + it.effect("prefers PowerShell over ComSpec for Windows terminals", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(5, { + platform: "win32", + env: { + ComSpec: "C:\\Windows\\System32\\cmd.exe", + PATH: "C:\\Windows\\System32", + SystemRoot: "C:\\Windows", + }, + }); + + yield* manager.open(openInput()); + + expect(ptyAdapter.spawnInputs[0]).toEqual( + expect.objectContaining({ + shell: "pwsh.exe", + args: ["-NoLogo"], + }), + ); + }), + ); + + it.effect("falls back to built-in PowerShell by absolute path on Windows", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(5, { + platform: "win32", + env: { + ComSpec: "C:\\Windows\\System32\\cmd.exe", + PATH: "C:\\Windows\\System32", + SystemRoot: "C:\\Windows", + }, + shellResolver: () => "C:\\missing\\custom-shell.exe", + }); + ptyAdapter.spawnFailures.push( + new Error("spawn custom-shell.exe ENOENT"), + new Error("spawn pwsh.exe ENOENT"), + ); + + yield* manager.open(openInput()); + + expect(ptyAdapter.spawnInputs.map((input) => input.shell)).toEqual([ + "C:\\missing\\custom-shell.exe", + "pwsh.exe", + "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", + ]); + expect(ptyAdapter.spawnInputs[1]?.args).toEqual(["-NoLogo"]); + expect(ptyAdapter.spawnInputs[2]?.args).toEqual(["-NoLogo"]); + }), + ); + it.effect("filters app runtime env variables from terminal sessions", () => Effect.gen(function* () { const originalValues = new Map(); diff --git a/apps/server/src/terminal/Layers/Manager.ts b/apps/server/src/terminal/Layers/Manager.ts index a01b4b5397c..6af1658f76b 100644 --- a/apps/server/src/terminal/Layers/Manager.ts +++ b/apps/server/src/terminal/Layers/Manager.ts @@ -22,13 +22,13 @@ import { SynchronizedRef, } from "effect"; -import { ServerConfig } from "../../config"; +import { ServerConfig } from "../../config.ts"; import { increment, terminalRestartsTotal, terminalSessionsTotal, -} from "../../observability/Metrics"; -import { runProcess } from "../../processRunner"; +} from "../../observability/Metrics.ts"; +import { runProcess } from "../../processRunner.ts"; import { TerminalCwdError, TerminalHistoryError, @@ -36,14 +36,14 @@ import { TerminalNotRunningError, TerminalSessionLookupError, type TerminalManagerShape, -} from "../Services/Manager"; +} from "../Services/Manager.ts"; import { PtyAdapter, PtySpawnError, type PtyAdapterShape, type PtyExitEvent, type PtyProcess, -} from "../Services/PTY"; +} from "../Services/PTY.ts"; const DEFAULT_HISTORY_LINE_LIMIT = 5_000; const DEFAULT_PERSIST_DEBOUNCE_MS = 40; @@ -186,19 +186,25 @@ function enqueueProcessEvent( return true; } -function defaultShellResolver(): string { - if (process.platform === "win32") { - return process.env.ComSpec ?? "cmd.exe"; +function defaultShellResolver( + platform: NodeJS.Platform = process.platform, + env: NodeJS.ProcessEnv = process.env, +): string { + if (platform === "win32") { + return "pwsh.exe"; } - return process.env.SHELL ?? "bash"; + return env.SHELL ?? "bash"; } -function normalizeShellCommand(value: string | undefined): string | null { +function normalizeShellCommand( + value: string | undefined, + platform: NodeJS.Platform = process.platform, +): string | null { if (!value) return null; const trimmed = value.trim(); if (trimmed.length === 0) return null; - if (process.platform === "win32") { + if (platform === "win32") { return trimmed; } @@ -207,15 +213,42 @@ function normalizeShellCommand(value: string | undefined): string | null { return firstToken.replace(/^['"]|['"]$/g, ""); } -function shellCandidateFromCommand(command: string | null): ShellCandidate | null { +function shellCandidateFromCommand( + command: string | null, + platform: NodeJS.Platform = process.platform, +): ShellCandidate | null { if (!command || command.length === 0) return null; - const shellName = path.basename(command).toLowerCase(); - if (process.platform !== "win32" && shellName === "zsh") { + const shellName = + platform === "win32" + ? path.win32.basename(command).toLowerCase() + : path.basename(command).toLowerCase(); + if (platform === "win32" && (shellName === "pwsh.exe" || shellName === "powershell.exe")) { + return { shell: command, args: ["-NoLogo"] }; + } + if (platform !== "win32" && shellName === "zsh") { return { shell: command, args: ["-o", "nopromptsp"] }; } return { shell: command }; } +function windowsSystemRoot(env: NodeJS.ProcessEnv): string { + return env.SystemRoot?.trim() || env.windir?.trim() || "C:\\Windows"; +} + +function windowsPowerShellPath(env: NodeJS.ProcessEnv): string { + return path.win32.join( + windowsSystemRoot(env), + "System32", + "WindowsPowerShell", + "v1.0", + "powershell.exe", + ); +} + +function windowsCmdPath(env: NodeJS.ProcessEnv): string { + return path.win32.join(windowsSystemRoot(env), "System32", "cmd.exe"); +} + function formatShellCandidate(candidate: ShellCandidate): string { if (!candidate.args || candidate.args.length === 0) return candidate.shell; return `${candidate.shell} ${candidate.args.join(" ")}`; @@ -234,27 +267,37 @@ function uniqueShellCandidates(candidates: Array): ShellC return ordered; } -function resolveShellCandidates(shellResolver: () => string): ShellCandidate[] { - const requested = shellCandidateFromCommand(normalizeShellCommand(shellResolver())); +function resolveShellCandidates( + shellResolver: () => string, + platform: NodeJS.Platform = process.platform, + env: NodeJS.ProcessEnv = process.env, +): ShellCandidate[] { + const requested = shellCandidateFromCommand( + normalizeShellCommand(shellResolver(), platform), + platform, + ); - if (process.platform === "win32") { + if (platform === "win32") { return uniqueShellCandidates([ requested, - shellCandidateFromCommand(process.env.ComSpec ?? null), - shellCandidateFromCommand("powershell.exe"), - shellCandidateFromCommand("cmd.exe"), + shellCandidateFromCommand("pwsh.exe", platform), + shellCandidateFromCommand(windowsPowerShellPath(env), platform), + shellCandidateFromCommand("powershell.exe", platform), + shellCandidateFromCommand(env.ComSpec ?? null, platform), + shellCandidateFromCommand(windowsCmdPath(env), platform), + shellCandidateFromCommand("cmd.exe", platform), ]); } return uniqueShellCandidates([ requested, - shellCandidateFromCommand(normalizeShellCommand(process.env.SHELL)), - shellCandidateFromCommand("/bin/zsh"), - shellCandidateFromCommand("/bin/bash"), - shellCandidateFromCommand("/bin/sh"), - shellCandidateFromCommand("zsh"), - shellCandidateFromCommand("bash"), - shellCandidateFromCommand("sh"), + shellCandidateFromCommand(normalizeShellCommand(env.SHELL, platform), platform), + shellCandidateFromCommand("/bin/zsh", platform), + shellCandidateFromCommand("/bin/bash", platform), + shellCandidateFromCommand("/bin/sh", platform), + shellCandidateFromCommand("zsh", platform), + shellCandidateFromCommand("bash", platform), + shellCandidateFromCommand("sh", platform), ]); } @@ -651,6 +694,8 @@ interface TerminalManagerOptions { historyLineLimit?: number; ptyAdapter: PtyAdapterShape; shellResolver?: () => string; + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; subprocessChecker?: TerminalSubprocessChecker; subprocessPollIntervalMs?: number; processKillGraceMs?: number; @@ -674,7 +719,9 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith const logsDir = options.logsDir; const historyLineLimit = options.historyLineLimit ?? DEFAULT_HISTORY_LINE_LIMIT; - const shellResolver = options.shellResolver ?? defaultShellResolver; + const platform = options.platform ?? process.platform; + const baseEnv = options.env ?? process.env; + const shellResolver = options.shellResolver ?? (() => defaultShellResolver(platform, baseEnv)); const subprocessChecker = options.subprocessChecker ?? defaultSubprocessChecker; const subprocessPollIntervalMs = options.subprocessPollIntervalMs ?? DEFAULT_SUBPROCESS_POLL_INTERVAL_MS; @@ -1337,8 +1384,8 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith increment(terminalSessionsTotal, { lifecycle: eventType }).pipe( Effect.andThen( Effect.gen(function* () { - const shellCandidates = resolveShellCandidates(shellResolver); - const terminalEnv = createTerminalSpawnEnv(process.env, session.runtimeEnv); + const shellCandidates = resolveShellCandidates(shellResolver, platform, baseEnv); + const terminalEnv = createTerminalSpawnEnv(baseEnv, session.runtimeEnv); const spawnResult = yield* trySpawn(shellCandidates, terminalEnv, session); ptyProcess = spawnResult.process; startedShell = spawnResult.shellLabel; diff --git a/apps/server/src/terminal/Layers/NodePTY.test.ts b/apps/server/src/terminal/Layers/NodePTY.test.ts index 58fcc70e4e5..06f186312aa 100644 --- a/apps/server/src/terminal/Layers/NodePTY.test.ts +++ b/apps/server/src/terminal/Layers/NodePTY.test.ts @@ -1,7 +1,7 @@ import { FileSystem, Path, Effect } from "effect"; import { assert, it } from "@effect/vitest"; -import { ensureNodePtySpawnHelperExecutable } from "./NodePTY"; +import { ensureNodePtySpawnHelperExecutable } from "./NodePTY.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; it.layer(NodeServices.layer)("ensureNodePtySpawnHelperExecutable", (it) => { diff --git a/apps/server/src/terminal/Layers/NodePTY.ts b/apps/server/src/terminal/Layers/NodePTY.ts index cf1fdd21982..1c75a4a958b 100644 --- a/apps/server/src/terminal/Layers/NodePTY.ts +++ b/apps/server/src/terminal/Layers/NodePTY.ts @@ -1,7 +1,13 @@ import { createRequire } from "node:module"; import { Effect, FileSystem, Layer, Path } from "effect"; -import { PtyAdapter, PtyAdapterShape, PtyExitEvent, PtyProcess } from "../Services/PTY"; +import { PtyAdapter } from "../Services/PTY.ts"; +import { + PtySpawnError, + type PtyAdapterShape, + type PtyExitEvent, + type PtyProcess, +} from "../Services/PTY.ts"; let didEnsureSpawnHelperExecutable = false; @@ -46,7 +52,11 @@ export const ensureNodePtySpawnHelperExecutable = Effect.fn(function* (explicitP }); class NodePtyProcess implements PtyProcess { - constructor(private readonly process: import("node-pty").IPty) {} + private readonly process: import("node-pty").IPty; + + constructor(process: import("node-pty").IPty) { + this.process = process; + } get pid(): number { return this.process.pid; @@ -103,12 +113,21 @@ export const layer = Layer.effect( return { spawn: Effect.fn(function* (input) { yield* ensureNodePtySpawnHelperExecutableCached; - const ptyProcess = nodePty.spawn(input.shell, input.args ?? [], { - cwd: input.cwd, - cols: input.cols, - rows: input.rows, - env: input.env, - name: globalThis.process.platform === "win32" ? "xterm-color" : "xterm-256color", + const ptyProcess = yield* Effect.try({ + try: () => + nodePty.spawn(input.shell, input.args ?? [], { + cwd: input.cwd, + cols: input.cols, + rows: input.rows, + env: input.env, + name: globalThis.process.platform === "win32" ? "xterm-color" : "xterm-256color", + }), + catch: (cause) => + new PtySpawnError({ + adapter: "node-pty", + message: cause instanceof Error ? cause.message : "Failed to spawn PTY process", + cause, + }), }); return new NodePtyProcess(ptyProcess); }), diff --git a/apps/server/src/terminal/Services/Manager.ts b/apps/server/src/terminal/Services/Manager.ts index b2aadb3752b..f96a859a6b3 100644 --- a/apps/server/src/terminal/Services/Manager.ts +++ b/apps/server/src/terminal/Services/Manager.ts @@ -22,7 +22,7 @@ import { TerminalSessionStatus, TerminalWriteInput, } from "@marcode/contracts"; -import { PtyProcess } from "./PTY"; +import type { PtyProcess } from "./PTY.ts"; import { Effect, Context } from "effect"; export { diff --git a/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts b/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts index 2f015e0181d..7814e4dd0d9 100644 --- a/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts @@ -68,6 +68,11 @@ const searchWorkspaceEntries = (input: { cwd: string; query: string; limit: numb return yield* workspaceEntries.search(input); }); +const appendSeparator = (input: string) => + input.endsWith("/") || input.endsWith("\\") + ? input + : `${input}${process.platform === "win32" ? "\\" : "/"}`; + it.layer(TestLayer)("WorkspaceEntriesLive", (it) => { afterEach(() => { vi.restoreAllMocks(); @@ -275,4 +280,85 @@ it.layer(TestLayer)("WorkspaceEntriesLive", (it) => { }), ); }); + + describe("browse", () => { + it.effect("returns matching directories and excludes files", () => + Effect.gen(function* () { + const workspaceEntries = yield* 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; + const path = yield* Path.Path; + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-browse-hidden-" }); + yield* writeTextFile(cwd, ".config/settings.json", "{}"); + yield* writeTextFile(cwd, "config/settings.json", "{}"); + + const directoryResult = yield* workspaceEntries.browse({ + partialPath: appendSeparator(cwd), + }); + const hiddenPrefixResult = yield* workspaceEntries.browse({ + partialPath: `${appendSeparator(cwd)}.c`, + }); + + expect(directoryResult.entries.map((entry) => entry.name)).toEqual([".config", "config"]); + expect(hiddenPrefixResult).toEqual({ + parentPath: cwd, + entries: [{ name: ".config", fullPath: path.join(cwd, ".config") }], + }); + }), + ); + + it.effect("supports relative paths when cwd is provided", () => + Effect.gen(function* () { + const workspaceEntries = yield* 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; + + const error = yield* workspaceEntries + .browse({ + partialPath: "./src", + }) + .pipe(Effect.flip); + + expect(error.detail).toBe("Relative filesystem browse paths require a current project."); + }), + ); + }); }); diff --git a/apps/server/src/workspace/Layers/WorkspaceEntries.ts b/apps/server/src/workspace/Layers/WorkspaceEntries.ts index d0c9eaf1d73..7403a416130 100644 --- a/apps/server/src/workspace/Layers/WorkspaceEntries.ts +++ b/apps/server/src/workspace/Layers/WorkspaceEntries.ts @@ -1,9 +1,11 @@ +import * as OS from "node:os"; import fsPromises from "node:fs/promises"; import type { Dirent } from "node:fs"; import { Cache, Duration, Effect, Exit, Layer, Option, Path } from "effect"; -import { type ProjectEntry } from "@marcode/contracts"; +import { type FilesystemBrowseInput, type ProjectEntry } from "@marcode/contracts"; +import { isExplicitRelativePath, isWindowsAbsolutePath } from "@marcode/shared/path"; import { insertRankedSearchResult, normalizeSearchQuery, @@ -14,6 +16,7 @@ import { import { GitCore } from "../../git/Services/GitCore.ts"; import { WorkspaceEntries, + WorkspaceEntriesBrowseError, WorkspaceEntriesError, type WorkspaceEntriesShape, } from "../Services/WorkspaceEntries.ts"; @@ -52,6 +55,16 @@ function toPosixPath(input: string): string { return input.replaceAll("\\", "/"); } +function expandHomePath(input: string, path: Path.Path): string { + if (input === "~") { + return OS.homedir(); + } + if (input.startsWith("~/") || input.startsWith("~\\")) { + return path.join(OS.homedir(), input.slice(2)); + } + return input; +} + function parentPathOf(input: string): string | undefined { const separatorIndex = input.lastIndexOf("/"); if (separatorIndex === -1) { @@ -129,6 +142,36 @@ function directoryAncestorsOf(relativePath: string): string[] { return directories; } +const resolveBrowseTarget = ( + input: FilesystemBrowseInput, + pathService: Path.Path, +): Effect.Effect => + Effect.gen(function* () { + if (process.platform !== "win32" && isWindowsAbsolutePath(input.partialPath)) { + return yield* new WorkspaceEntriesBrowseError({ + cwd: input.cwd, + partialPath: input.partialPath, + operation: "workspaceEntries.resolveBrowseTarget", + detail: "Windows-style paths are only supported on Windows.", + }); + } + + if (!isExplicitRelativePath(input.partialPath)) { + return pathService.resolve(expandHomePath(input.partialPath, pathService)); + } + + if (!input.cwd) { + return yield* new WorkspaceEntriesBrowseError({ + cwd: input.cwd, + partialPath: input.partialPath, + operation: "workspaceEntries.resolveBrowseTarget", + detail: "Relative filesystem browse paths require a current project.", + }); + } + + return pathService.resolve(expandHomePath(input.cwd, pathService), input.partialPath); + }); + export const makeWorkspaceEntries = Effect.gen(function* () { const path = yield* Path.Path; const gitOption = yield* Effect.serviceOption(GitCore); @@ -379,6 +422,46 @@ export const makeWorkspaceEntries = Effect.gen(function* () { }, ); + const browse: WorkspaceEntriesShape["browse"] = Effect.fn("WorkspaceEntries.browse")( + function* (input) { + const resolvedInputPath = yield* resolveBrowseTarget(input, path); + const endsWithSeparator = /[\\/]$/.test(input.partialPath) || input.partialPath === "~"; + const parentPath = endsWithSeparator ? resolvedInputPath : path.dirname(resolvedInputPath); + const prefix = endsWithSeparator ? "" : path.basename(resolvedInputPath); + + const dirents = yield* Effect.tryPromise({ + try: () => fsPromises.readdir(parentPath, { withFileTypes: true }), + catch: (cause) => + new WorkspaceEntriesBrowseError({ + cwd: input.cwd, + partialPath: input.partialPath, + operation: "workspaceEntries.browse.readDirectory", + detail: `Unable to browse '${parentPath}': ${cause instanceof Error ? cause.message : String(cause)}`, + cause, + }), + }); + + const showHidden = endsWithSeparator || prefix.startsWith("."); + const lowerPrefix = prefix.toLowerCase(); + + return { + parentPath, + entries: dirents + .filter( + (dirent) => + dirent.isDirectory() && + dirent.name.toLowerCase().startsWith(lowerPrefix) && + (showHidden || !dirent.name.startsWith(".")), + ) + .map((dirent) => ({ + name: dirent.name, + fullPath: path.join(parentPath, dirent.name), + })) + .toSorted((left, right) => left.name.localeCompare(right.name)), + }; + }, + ); + const search: WorkspaceEntriesShape["search"] = Effect.fn("WorkspaceEntries.search")( function* (input) { const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd); @@ -415,6 +498,7 @@ export const makeWorkspaceEntries = Effect.gen(function* () { ); return { + browse, invalidate, search, } satisfies WorkspaceEntriesShape; diff --git a/apps/server/src/workspace/Layers/WorkspacePaths.test.ts b/apps/server/src/workspace/Layers/WorkspacePaths.test.ts index 7b5f25eb162..81b22b60a3a 100644 --- a/apps/server/src/workspace/Layers/WorkspacePaths.test.ts +++ b/apps/server/src/workspace/Layers/WorkspacePaths.test.ts @@ -58,6 +58,24 @@ it.layer(TestLayer)("WorkspacePathsLive", (it) => { }), ); + it.effect("creates missing directories when createIfMissing is enabled", () => + Effect.gen(function* () { + const workspacePaths = yield* WorkspacePaths; + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* makeTempDir(); + const path = yield* Path.Path; + const missingPath = path.join(cwd, "nested", "new-project"); + + const resolved = yield* workspacePaths.normalizeWorkspaceRoot(missingPath, { + createIfMissing: true, + }); + const stat = yield* fileSystem.stat(resolved); + + expect(resolved).toBe(missingPath); + expect(stat.type).toBe("Directory"); + }), + ); + it.effect("rejects file paths", () => Effect.gen(function* () { const workspacePaths = yield* WorkspacePaths; diff --git a/apps/server/src/workspace/Layers/WorkspacePaths.ts b/apps/server/src/workspace/Layers/WorkspacePaths.ts index fa7a90cf07d..f19bb3624d8 100644 --- a/apps/server/src/workspace/Layers/WorkspacePaths.ts +++ b/apps/server/src/workspace/Layers/WorkspacePaths.ts @@ -4,6 +4,7 @@ import { Effect, FileSystem, Layer, Path } from "effect"; import { WorkspacePaths, WorkspacePathOutsideRootError, + WorkspaceRootCreateFailedError, WorkspaceRootNotDirectoryError, WorkspaceRootNotExistsError, type WorkspacePathsShape, @@ -29,11 +30,25 @@ export const makeWorkspacePaths = Effect.gen(function* () { const normalizeWorkspaceRoot: WorkspacePathsShape["normalizeWorkspaceRoot"] = Effect.fn( "WorkspacePaths.normalizeWorkspaceRoot", - )(function* (workspaceRoot) { + )(function* (workspaceRoot, options) { const normalizedWorkspaceRoot = path.resolve(expandHomePath(workspaceRoot.trim(), path)); - const workspaceStat = yield* fileSystem + let workspaceStat = yield* fileSystem .stat(normalizedWorkspaceRoot) .pipe(Effect.catch(() => Effect.succeed(null))); + if (!workspaceStat && options?.createIfMissing) { + yield* fileSystem.makeDirectory(normalizedWorkspaceRoot, { recursive: true }).pipe( + Effect.mapError( + () => + new WorkspaceRootCreateFailedError({ + workspaceRoot, + normalizedWorkspaceRoot, + }), + ), + ); + workspaceStat = yield* fileSystem + .stat(normalizedWorkspaceRoot) + .pipe(Effect.catch(() => Effect.succeed(null))); + } if (!workspaceStat) { return yield* new WorkspaceRootNotExistsError({ workspaceRoot, diff --git a/apps/server/src/workspace/Services/WorkspaceEntries.ts b/apps/server/src/workspace/Services/WorkspaceEntries.ts index 981d2e29af7..ecd419f0f72 100644 --- a/apps/server/src/workspace/Services/WorkspaceEntries.ts +++ b/apps/server/src/workspace/Services/WorkspaceEntries.ts @@ -9,7 +9,12 @@ import { Schema, Context } from "effect"; import type { Effect } from "effect"; -import type { ProjectSearchEntriesInput, ProjectSearchEntriesResult } from "@marcode/contracts"; +import type { + FilesystemBrowseInput, + FilesystemBrowseResult, + ProjectSearchEntriesInput, + ProjectSearchEntriesResult, +} from "@marcode/contracts"; export class WorkspaceEntriesError extends Schema.TaggedErrorClass()( "WorkspaceEntriesError", @@ -21,11 +26,29 @@ export class WorkspaceEntriesError extends Schema.TaggedErrorClass()( + "WorkspaceEntriesBrowseError", + { + cwd: Schema.optional(Schema.String), + partialPath: Schema.String, + operation: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) {} + /** * WorkspaceEntriesShape - Service API for workspace entry search and cache * invalidation. */ export interface WorkspaceEntriesShape { + /** + * Browse matching directories for the provided partial path. + */ + readonly browse: ( + input: FilesystemBrowseInput, + ) => Effect.Effect; + /** * Search indexed workspace entries for files and directories matching the * provided query. diff --git a/apps/server/src/workspace/Services/WorkspacePaths.ts b/apps/server/src/workspace/Services/WorkspacePaths.ts index fd3a4d6a1f1..da3e486ae5a 100644 --- a/apps/server/src/workspace/Services/WorkspacePaths.ts +++ b/apps/server/src/workspace/Services/WorkspacePaths.ts @@ -21,6 +21,18 @@ export class WorkspaceRootNotExistsError extends Schema.TaggedErrorClass()( + "WorkspaceRootCreateFailedError", + { + workspaceRoot: Schema.String, + normalizedWorkspaceRoot: Schema.String, + }, +) { + override get message(): string { + return `Failed to create workspace root: ${this.normalizedWorkspaceRoot}`; + } +} + export class WorkspaceRootNotDirectoryError extends Schema.TaggedErrorClass()( "WorkspaceRootNotDirectoryError", { @@ -47,6 +59,7 @@ export class WorkspacePathOutsideRootError extends Schema.TaggedErrorClass Effect.Effect; + options?: { readonly createIfMissing?: boolean }, + ) => Effect.Effect< + string, + WorkspaceRootNotExistsError | WorkspaceRootCreateFailedError | WorkspaceRootNotDirectoryError + >; /** * Resolve a relative path within a validated workspace root. diff --git a/apps/server/src/workspaceEntries.test.ts b/apps/server/src/workspaceEntries.test.ts index 3bc95280c00..2cd9acb7783 100644 --- a/apps/server/src/workspaceEntries.test.ts +++ b/apps/server/src/workspaceEntries.test.ts @@ -6,7 +6,7 @@ import { spawnSync } from "node:child_process"; import { afterEach, assert, describe, it, vi } from "vitest"; -import { searchWorkspaceEntries } from "./workspaceEntries"; +import { searchWorkspaceEntries } from "./workspaceEntries.ts"; const tempDirs: string[] = []; diff --git a/apps/server/src/workspaceEntries.ts b/apps/server/src/workspaceEntries.ts index c4ab076847c..a1f0687eb3d 100644 --- a/apps/server/src/workspaceEntries.ts +++ b/apps/server/src/workspaceEntries.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import type { Dirent } from "node:fs"; import path from "node:path"; -import { runProcess } from "./processRunner"; +import { runProcess } from "./processRunner.ts"; import { type ProjectBrowseDirectoriesInput, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index fe03095e9df..98c8bfbf8b2 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1,4 +1,4 @@ -import { Cause, Effect, Layer, Option, Queue, Ref, Schema, Stream } from "effect"; +import { Cause, Duration, Effect, Layer, Option, Queue, Ref, Schema, Stream } from "effect"; import { type AuthAccessStreamEvent, AuthSessionId, @@ -10,6 +10,7 @@ import { JiraRpcError, OrchestrationDispatchCommandError, type OrchestrationEvent, + type OrchestrationShellStreamEvent, OrchestrationGetFullThreadDiffError, OrchestrationGetListingSnapshotError, OrchestrationGetSnapshotError, @@ -20,6 +21,7 @@ import { ProjectSearchEntriesError, ProjectWriteFileError, OrchestrationReplayEventsError, + FilesystemBrowseError, ThreadId, type TerminalEvent, WS_METHODS, @@ -29,46 +31,70 @@ import { clamp } from "effect/Number"; import { HttpRouter, HttpServerRequest } from "effect/unstable/http"; import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; -import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery"; -import { ServerConfig } from "./config"; -import { GitCore } from "./git/Services/GitCore"; -import { GitManager } from "./git/Services/GitManager"; -import { GitStatusBroadcaster } from "./git/Services/GitStatusBroadcaster"; -import { Keybindings } from "./keybindings"; -import { Open, resolveAvailableEditors } from "./open"; -import { normalizeDispatchCommand } from "./orchestration/Normalizer"; -import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine"; -import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; +import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery.ts"; +import { ServerConfig } from "./config.ts"; +import { GitCore } from "./git/Services/GitCore.ts"; +import { GitManager } from "./git/Services/GitManager.ts"; +import { GitStatusBroadcaster } from "./git/Services/GitStatusBroadcaster.ts"; +import { Keybindings } from "./keybindings.ts"; +import { Open, resolveAvailableEditors } from "./open.ts"; +import { normalizeDispatchCommand } from "./orchestration/Normalizer.ts"; +import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; import { observeRpcEffect, observeRpcStream, observeRpcStreamEffect, -} from "./observability/RpcInstrumentation"; -import { ProviderRegistry } from "./provider/Services/ProviderRegistry"; -import { ProviderService } from "./provider/Services/ProviderService"; -import { ServerLifecycleEvents } from "./serverLifecycleEvents"; -import { ServerRuntimeStartup } from "./serverRuntimeStartup"; -import { ServerSettingsService } from "./serverSettings"; -import { TerminalManager } from "./terminal/Services/Manager"; -import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries"; -import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem"; -import { browseDirectories } from "./workspaceEntries"; -import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths"; -import { JiraApiClient } from "./jira/Services/JiraApiClient"; -import { JiraTokenService } from "./jira/Services/JiraTokenService"; -import { ProjectSetupScriptRunner } from "./project/Services/ProjectSetupScriptRunner"; -import { RepositoryIdentityResolver } from "./project/Services/RepositoryIdentityResolver"; -import { ServerEnvironment } from "./environment/Services/ServerEnvironment"; -import { ServerAuth } from "./auth/Services/ServerAuth"; +} from "./observability/RpcInstrumentation.ts"; +import { ProviderRegistry } from "./provider/Services/ProviderRegistry.ts"; +import { ProviderService } from "./provider/Services/ProviderService.ts"; +import { ServerLifecycleEvents } from "./serverLifecycleEvents.ts"; +import { ServerRuntimeStartup } from "./serverRuntimeStartup.ts"; +import { ServerSettingsService } from "./serverSettings.ts"; +import { TerminalManager } from "./terminal/Services/Manager.ts"; +import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts"; +import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts"; +import { browseDirectories } from "./workspaceEntries.ts"; +import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths.ts"; +import { JiraApiClient } from "./jira/Services/JiraApiClient.ts"; +import { JiraTokenService } from "./jira/Services/JiraTokenService.ts"; +import { ProjectSetupScriptRunner } from "./project/Services/ProjectSetupScriptRunner.ts"; +import { RepositoryIdentityResolver } from "./project/Services/RepositoryIdentityResolver.ts"; +import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts"; +import { ServerAuth } from "./auth/Services/ServerAuth.ts"; import { BootstrapCredentialService, type BootstrapCredentialChange, -} from "./auth/Services/BootstrapCredentialService"; +} from "./auth/Services/BootstrapCredentialService.ts"; import { SessionCredentialService, type SessionCredentialChange, -} from "./auth/Services/SessionCredentialService"; -import { respondToAuthError } from "./auth/http"; +} from "./auth/Services/SessionCredentialService.ts"; +import { respondToAuthError } from "./auth/http.ts"; + +function isThreadDetailEvent(event: OrchestrationEvent): event is Extract< + OrchestrationEvent, + { + type: + | "thread.message-sent" + | "thread.proposed-plan-upserted" + | "thread.activity-appended" + | "thread.turn-diff-completed" + | "thread.reverted" + | "thread.session-set"; + } +> { + return ( + event.type === "thread.message-sent" || + event.type === "thread.proposed-plan-upserted" || + event.type === "thread.activity-appended" || + event.type === "thread.turn-diff-completed" || + event.type === "thread.reverted" || + event.type === "thread.session-set" + ); +} + +const PROVIDER_STATUS_DEBOUNCE_MS = 200; function toAuthAccessStreamEvent( change: BootstrapCredentialChange | SessionCredentialChange, @@ -238,6 +264,57 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => const enrichOrchestrationEvents = (events: ReadonlyArray) => Effect.forEach(events, enrichProjectEvent, { concurrency: 4 }); + const toShellStreamEvent = ( + event: OrchestrationEvent, + ): Effect.Effect, never, never> => { + switch (event.type) { + case "project.created": + case "project.meta-updated": + return projectionSnapshotQuery.getProjectShellById(event.payload.projectId).pipe( + Effect.map((project) => + Option.map(project, (nextProject) => ({ + kind: "project-upserted" as const, + sequence: event.sequence, + project: nextProject, + })), + ), + Effect.catch(() => Effect.succeed(Option.none())), + ); + case "project.deleted": + return Effect.succeed( + Option.some({ + kind: "project-removed" as const, + sequence: event.sequence, + projectId: event.payload.projectId, + }), + ); + case "thread.deleted": + return Effect.succeed( + Option.some({ + kind: "thread-removed" as const, + sequence: event.sequence, + threadId: event.payload.threadId, + }), + ); + default: + if (event.aggregateKind !== "thread") { + return Effect.succeed(Option.none()); + } + return projectionSnapshotQuery + .getThreadShellById(ThreadId.make(event.aggregateId)) + .pipe( + Effect.map((thread) => + Option.map(thread, (nextThread) => ({ + kind: "thread-upserted" as const, + sequence: event.sequence, + thread: nextThread, + })), + ), + Effect.catch(() => Effect.succeed(Option.none())), + ); + } + }; + const dispatchBootstrapTurnStart = ( command: Extract, ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> => @@ -406,6 +483,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => branch: worktree.worktree.branch, worktreePath: targetWorktreePath, }); + yield* refreshGitStatus(targetWorktreePath); } yield* runSetupProgram(); @@ -531,8 +609,45 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => ORCHESTRATION_WS_METHODS.dispatchCommand, Effect.gen(function* () { const normalizedCommand = yield* normalizeDispatchCommand(command); + const shouldStopSessionAfterArchive = + normalizedCommand.type === "thread.archive" + ? yield* projectionSnapshotQuery + .getThreadShellById(normalizedCommand.threadId) + .pipe( + Effect.map( + Option.match({ + onNone: () => false, + onSome: (thread) => + thread.session !== null && thread.session.status !== "stopped", + }), + ), + Effect.catch(() => Effect.succeed(false)), + ) + : false; const result = yield* dispatchNormalizedCommand(normalizedCommand); if (normalizedCommand.type === "thread.archive") { + if (shouldStopSessionAfterArchive) { + yield* Effect.gen(function* () { + const stopCommand = yield* normalizeDispatchCommand({ + type: "thread.session.stop", + commandId: CommandId.make( + `session-stop-for-archive:${normalizedCommand.commandId}`, + ), + threadId: normalizedCommand.threadId, + createdAt: new Date().toISOString(), + }); + + yield* dispatchNormalizedCommand(stopCommand); + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("failed to stop provider session during archive", { + threadId: normalizedCommand.threadId, + cause, + }), + ), + ); + } + yield* terminalManager.close({ threadId: normalizedCommand.threadId }).pipe( Effect.catch((error) => Effect.logWarning("failed to close thread terminals after archive", { @@ -606,65 +721,85 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => ), { "rpc.aggregate": "orchestration" }, ), - [WS_METHODS.subscribeOrchestrationDomainEvents]: (_input) => + [ORCHESTRATION_WS_METHODS.subscribeShell]: (_input) => observeRpcStreamEffect( - WS_METHODS.subscribeOrchestrationDomainEvents, + ORCHESTRATION_WS_METHODS.subscribeShell, Effect.gen(function* () { - const snapshot = yield* orchestrationEngine.getReadModel(); - const fromSequenceExclusive = snapshot.snapshotSequence; - const replayEvents: Array = yield* Stream.runCollect( - orchestrationEngine.readEvents(fromSequenceExclusive), - ).pipe( - Effect.map((events) => Array.from(events)), - Effect.flatMap(enrichOrchestrationEvents), - Effect.catch(() => Effect.succeed([] as Array)), + const snapshot = yield* projectionSnapshotQuery.getShellSnapshot().pipe( + Effect.mapError( + (cause) => + new OrchestrationGetSnapshotError({ + message: "Failed to load orchestration shell snapshot", + cause, + }), + ), ); - const replayStream = Stream.fromIterable(replayEvents); + const liveStream = orchestrationEngine.streamDomainEvents.pipe( - Stream.mapEffect(enrichProjectEvent), + Stream.mapEffect(toShellStreamEvent), + Stream.flatMap((event) => + Option.isSome(event) ? Stream.succeed(event.value) : Stream.empty, + ), ); - const source = Stream.merge(replayStream, liveStream); - type SequenceState = { - readonly nextSequence: number; - readonly pendingBySequence: Map; - }; - const state = yield* Ref.make({ - nextSequence: fromSequenceExclusive + 1, - pendingBySequence: new Map(), - }); - - return source.pipe( - Stream.mapEffect((event) => - Ref.modify( - state, - ({ - nextSequence, - pendingBySequence, - }): [Array, SequenceState] => { - if (event.sequence < nextSequence || pendingBySequence.has(event.sequence)) { - return [[], { nextSequence, pendingBySequence }]; - } - const updatedPending = new Map(pendingBySequence); - updatedPending.set(event.sequence, event); + return Stream.concat( + Stream.make({ + kind: "snapshot" as const, + snapshot, + }), + liveStream, + ); + }), + { "rpc.aggregate": "orchestration" }, + ), + [ORCHESTRATION_WS_METHODS.subscribeThread]: (input) => + observeRpcStreamEffect( + ORCHESTRATION_WS_METHODS.subscribeThread, + Effect.gen(function* () { + const [threadDetail, snapshotSequence] = yield* Effect.all([ + projectionSnapshotQuery.getThreadDetailById(input.threadId).pipe( + Effect.mapError( + (cause) => + new OrchestrationGetSnapshotError({ + message: `Failed to load thread ${input.threadId}`, + cause, + }), + ), + ), + orchestrationEngine + .getReadModel() + .pipe(Effect.map((readModel) => readModel.snapshotSequence)), + ]); - const emit: Array = []; - let expected = nextSequence; - for (;;) { - const expectedEvent = updatedPending.get(expected); - if (!expectedEvent) { - break; - } - emit.push(expectedEvent); - updatedPending.delete(expected); - expected += 1; - } + if (Option.isNone(threadDetail)) { + return yield* new OrchestrationGetSnapshotError({ + message: `Thread ${input.threadId} was not found`, + cause: input.threadId, + }); + } - return [emit, { nextSequence: expected, pendingBySequence: updatedPending }]; - }, - ), + const liveStream = orchestrationEngine.streamDomainEvents.pipe( + Stream.filter( + (event) => + event.aggregateKind === "thread" && + event.aggregateId === input.threadId && + isThreadDetailEvent(event), ), - Stream.flatMap((events) => Stream.fromIterable(events)), + Stream.map((event) => ({ + kind: "event" as const, + event, + })), + ); + + return Stream.concat( + Stream.make({ + kind: "snapshot" as const, + snapshot: { + snapshotSequence, + thread: threadDetail.value, + }, + }), + liveStream, ); }), { "rpc.aggregate": "orchestration" }, @@ -743,6 +878,20 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => observeRpcEffect(WS_METHODS.shellOpenInEditor, open.openInEditor(input), { "rpc.aggregate": "workspace", }), + [WS_METHODS.filesystemBrowse]: (input) => + observeRpcEffect( + WS_METHODS.filesystemBrowse, + workspaceEntries.browse(input).pipe( + Effect.mapError( + (cause) => + new FilesystemBrowseError({ + message: cause.detail, + cause, + }), + ), + ), + { "rpc.aggregate": "workspace" }, + ), [WS_METHODS.subscribeGitStatus]: (input) => observeRpcStream( WS_METHODS.subscribeGitStatus, @@ -918,6 +1067,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => type: "providerStatuses" as const, payload: { providers }, })), + Stream.debounce(Duration.millis(PROVIDER_STATUS_DEBOUNCE_MS)), ); const settingsUpdates = serverSettings.streamChanges.pipe( Stream.map((settings) => ({ @@ -927,13 +1077,26 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => })), ); + yield* Effect.all( + [providerRegistry.refresh("codex"), providerRegistry.refresh("claudeAgent")], + { + concurrency: "unbounded", + discard: true, + }, + ).pipe(Effect.ignoreCause({ log: true }), Effect.forkScoped); + + const liveUpdates = Stream.merge( + keybindingsUpdates, + Stream.merge(providerStatuses, settingsUpdates), + ); + return Stream.concat( Stream.make({ version: 1 as const, type: "snapshot" as const, config: yield* loadServerConfig, }), - Stream.merge(keybindingsUpdates, Stream.merge(providerStatuses, settingsUpdates)), + liveUpdates, ); }), { "rpc.aggregate": "server" }, diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json index 07d52467f51..c19bdbf4565 100644 --- a/apps/server/tsconfig.json +++ b/apps/server/tsconfig.json @@ -3,9 +3,7 @@ "compilerOptions": { "composite": true, "types": ["node", "bun"], - "lib": ["ES2023", "esnext.disposable"], - "noEmit": true, - "allowImportingTsExtensions": true, + "lib": ["ESNext", "esnext.disposable"], "plugins": [ { "name": "@effect/language-service", diff --git a/apps/server/vitest.config.ts b/apps/server/vitest.config.ts index 1c5b2f0d38d..660d69423d9 100644 --- a/apps/server/vitest.config.ts +++ b/apps/server/vitest.config.ts @@ -1,6 +1,6 @@ import { defineConfig, mergeConfig } from "vitest/config"; -import baseConfig from "../../vitest.config"; +import baseConfig from "../../vitest.config.ts"; export default mergeConfig( baseConfig, diff --git a/apps/web/package.json b/apps/web/package.json index c17bae154d2..5c3ef7bb9b7 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -29,7 +29,6 @@ "@tanstack/react-pacer": "^0.19.4", "@tanstack/react-query": "^5.90.0", "@tanstack/react-router": "^1.160.2", - "@tanstack/react-virtual": "^3.13.18", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", diff --git a/apps/web/src/authBootstrap.test.ts b/apps/web/src/authBootstrap.test.ts index 8a777ab7edd..b9e6f439d69 100644 --- a/apps/web/src/authBootstrap.test.ts +++ b/apps/web/src/authBootstrap.test.ts @@ -430,7 +430,7 @@ describe("resolveInitialServerAuthGateState", () => { expect(fetchMock.mock.calls[3]?.[0]).toBe("http://localhost:3773/api/auth/session"); }); - it("revalidates the server session state after a previous authenticated result", async () => { + it("memoizes the authenticated gate state after the first successful read", async () => { const fetchMock = vi .fn() .mockResolvedValueOnce( @@ -465,15 +465,9 @@ describe("resolveInitialServerAuthGateState", () => { status: "authenticated", }); await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ - status: "requires-auth", - auth: { - policy: "loopback-browser", - bootstrapMethods: ["one-time-token"], - sessionMethods: ["browser-session-cookie"], - sessionCookieName: "marcode_session", - }, + status: "authenticated", }); - expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenCalledTimes(1); }); it("creates a pairing credential from the authenticated auth endpoint", async () => { diff --git a/apps/web/src/branding.test.ts b/apps/web/src/branding.test.ts new file mode 100644 index 00000000000..b6643d2a72f --- /dev/null +++ b/apps/web/src/branding.test.ts @@ -0,0 +1,37 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +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)"); + }); +}); diff --git a/apps/web/src/branding.ts b/apps/web/src/branding.ts index acb970eaa5e..886d3fa403f 100644 --- a/apps/web/src/branding.ts +++ b/apps/web/src/branding.ts @@ -1,5 +1,19 @@ -export const APP_BASE_NAME = "MarCode"; -export const APP_STAGE_LABEL = import.meta.env.DEV ? "Dev" : "Alpha"; -export const APP_DISPLAY_NAME = `${APP_BASE_NAME} (${APP_STAGE_LABEL})`; +import type { DesktopAppBranding } from "@marcode/contracts"; + +function readInjectedDesktopAppBranding(): DesktopAppBranding | null { + if (typeof window === "undefined") { + return null; + } + + return window.desktopBridge?.getAppBranding?.() ?? null; +} + +const injectedDesktopAppBranding = readInjectedDesktopAppBranding(); + +export const APP_BASE_NAME = injectedDesktopAppBranding?.baseName ?? "MarCode"; +export const APP_STAGE_LABEL = + injectedDesktopAppBranding?.stageLabel ?? (import.meta.env.DEV ? "Dev" : "Alpha"); +export const APP_DISPLAY_NAME = + injectedDesktopAppBranding?.displayName ?? `${APP_BASE_NAME} (${APP_STAGE_LABEL})`; export const APP_VERSION = import.meta.env.APP_VERSION || "0.0.0"; export const SPLASH_LOGO_PATH = "/splash-logo.png"; diff --git a/apps/web/src/chat-scroll.test.ts b/apps/web/src/chat-scroll.test.ts deleted file mode 100644 index 5311fb40aed..00000000000 --- a/apps/web/src/chat-scroll.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX, isScrollContainerNearBottom } from "./chat-scroll"; - -describe("isScrollContainerNearBottom", () => { - it("returns true when already at bottom", () => { - expect( - isScrollContainerNearBottom({ - scrollTop: 600, - clientHeight: 400, - scrollHeight: 1_000, - }), - ).toBe(true); - }); - - it("returns true when within the auto-scroll threshold", () => { - expect( - isScrollContainerNearBottom({ - scrollTop: 540, - clientHeight: 400, - scrollHeight: 1_000, - }), - ).toBe(true); - }); - - it("returns false when the user is meaningfully above the bottom", () => { - expect( - isScrollContainerNearBottom({ - scrollTop: 520, - clientHeight: 400, - scrollHeight: 1_000, - }), - ).toBe(false); - }); - - it("clamps negative thresholds to zero", () => { - expect( - isScrollContainerNearBottom( - { - scrollTop: 539, - clientHeight: 400, - scrollHeight: 1_000, - }, - -1, - ), - ).toBe(false); - }); - - it("falls back to the default threshold for non-finite values", () => { - expect( - isScrollContainerNearBottom( - { - scrollTop: 540, - clientHeight: 400, - scrollHeight: 1_000, - }, - Number.NaN, - ), - ).toBe(true); - expect(AUTO_SCROLL_BOTTOM_THRESHOLD_PX).toBe(64); - }); -}); diff --git a/apps/web/src/commandPaletteStore.ts b/apps/web/src/commandPaletteStore.ts index 4f291d5a480..d156f3d440d 100644 --- a/apps/web/src/commandPaletteStore.ts +++ b/apps/web/src/commandPaletteStore.ts @@ -1,13 +1,54 @@ import { create } from "zustand"; +type CommandPaletteOpenIntent = + | { kind: "add-project"; requestId: number } + | { kind: "add-folder"; requestId: number; initialPath: string }; + +interface AddFolderResult { + requestId: number; + path: string; +} + interface CommandPaletteStore { open: boolean; + openIntent: CommandPaletteOpenIntent | null; + addFolderResult: AddFolderResult | null; setOpen: (open: boolean) => void; toggleOpen: () => void; + openAddProject: () => void; + openAddFolder: (initialPath: string) => number; + reportAddFolderResult: (requestId: number, path: string) => void; + consumeAddFolderResult: (requestId: number) => void; + clearOpenIntent: () => void; } -export const useCommandPaletteStore = create((set) => ({ +export const useCommandPaletteStore = create((set, get) => ({ open: false, - setOpen: (open) => set({ open }), - toggleOpen: () => set((state) => ({ open: !state.open })), + openIntent: null, + addFolderResult: null, + setOpen: (open) => set({ open, ...(open ? {} : { openIntent: null }) }), + toggleOpen: () => + set((state) => ({ open: !state.open, ...(state.open ? { openIntent: null } : {}) })), + openAddProject: () => + set((state) => ({ + open: true, + openIntent: { + kind: "add-project", + requestId: (state.openIntent?.requestId ?? 0) + 1, + }, + })), + openAddFolder: (initialPath) => { + const nextRequestId = (get().openIntent?.requestId ?? 0) + 1; + set({ + open: true, + openIntent: { kind: "add-folder", requestId: nextRequestId, initialPath }, + }); + return nextRequestId; + }, + reportAddFolderResult: (requestId, path) => set({ addFolderResult: { requestId, path } }), + consumeAddFolderResult: (requestId) => + set((state) => + state.addFolderResult?.requestId === requestId ? { addFolderResult: null } : state, + ), + clearOpenIntent: () => set({ openIntent: null }), })); diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index cbb9c63039c..006159f5a8b 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -10,6 +10,7 @@ import { resolveEffectiveEnvMode, resolveEnvModeLabel, resolveBranchToolbarValue, + resolveLockedWorkspaceLabel, shouldIncludeBranchPickerItem, } from "./BranchToolbar.logic"; @@ -159,6 +160,16 @@ describe("resolveCurrentWorkspaceLabel", () => { }); }); +describe("resolveLockedWorkspaceLabel", () => { + it("uses a shorter label for the main repo checkout", () => { + expect(resolveLockedWorkspaceLabel(null)).toBe("Local checkout"); + }); + + it("uses a shorter label for an attached worktree", () => { + expect(resolveLockedWorkspaceLabel("/repo/.t3/worktrees/feature-a")).toBe("Worktree"); + }); +}); + describe("deriveLocalBranchNameFromRemoteRef", () => { it("strips the remote prefix from a remote ref", () => { expect(deriveLocalBranchNameFromRemoteRef("origin/feature/demo")).toBe("feature/demo"); diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 6d662e35387..220960945f9 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -50,6 +50,10 @@ export function resolveCurrentWorkspaceLabel(activeWorktreePath: string | null): return activeWorktreePath ? "Current worktree" : resolveEnvModeLabel("local"); } +export function resolveLockedWorkspaceLabel(activeWorktreePath: string | null): string { + return activeWorktreePath ? "Worktree" : "Local checkout"; +} + export function resolveEffectiveEnvMode(input: { activeWorktreePath: string | null; hasServerThread: boolean; diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index b5cbf23e4b2..2171174dfbb 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -20,6 +20,9 @@ interface BranchToolbarProps { threadId: ThreadId; draftId?: DraftId; onEnvModeChange: (mode: EnvMode) => void; + effectiveEnvModeOverride?: EnvMode; + activeThreadBranchOverride?: string | null; + onActiveThreadBranchOverrideChange?: (branch: string | null) => void; envLocked: boolean; onCheckoutPullRequestRequest?: (reference: string) => void; onComposerFocusRequest?: () => void; @@ -32,6 +35,9 @@ export const BranchToolbar = memo(function BranchToolbar({ threadId, draftId, onEnvModeChange, + effectiveEnvModeOverride, + activeThreadBranchOverride, + onActiveThreadBranchOverrideChange, envLocked, onCheckoutPullRequestRequest, onComposerFocusRequest, @@ -59,11 +65,13 @@ export const BranchToolbar = memo(function BranchToolbar({ const activeProject = useStore(activeProjectSelector); const hasActiveThread = serverThread !== undefined || draftThread !== null; const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; - const effectiveEnvMode = resolveEffectiveEnvMode({ - activeWorktreePath, - hasServerThread: serverThread !== undefined, - draftThreadEnvMode: draftThread?.envMode, - }); + const effectiveEnvMode = + effectiveEnvModeOverride ?? + resolveEffectiveEnvMode({ + activeWorktreePath, + hasServerThread: serverThread !== undefined, + draftThreadEnvMode: draftThread?.envMode, + }); const envModeLocked = envLocked || (serverThread !== undefined && activeWorktreePath !== null); const showEnvironmentPicker = @@ -98,6 +106,9 @@ export const BranchToolbar = memo(function BranchToolbar({ threadId={threadId} {...(draftId ? { draftId } : {})} envLocked={envLocked} + {...(effectiveEnvModeOverride ? { effectiveEnvModeOverride } : {})} + {...(activeThreadBranchOverride !== undefined ? { activeThreadBranchOverride } : {})} + {...(onActiveThreadBranchOverrideChange ? { onActiveThreadBranchOverrideChange } : {})} {...(onCheckoutPullRequestRequest ? { onCheckoutPullRequestRequest } : {})} {...(onComposerFocusRequest ? { onComposerFocusRequest } : {})} /> diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx index 39bf50359dc..6e1c80f5573 100644 --- a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx @@ -4,6 +4,7 @@ import { memo, useMemo } from "react"; import { resolveCurrentWorkspaceLabel, resolveEnvModeLabel, + resolveLockedWorkspaceLabel, type EnvMode, } from "./BranchToolbar.logic"; import { @@ -43,12 +44,12 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe {activeWorktreePath ? ( <> - {resolveCurrentWorkspaceLabel(activeWorktreePath)} + {resolveLockedWorkspaceLabel(activeWorktreePath)} ) : ( <> - {resolveCurrentWorkspaceLabel(activeWorktreePath)} + {resolveLockedWorkspaceLabel(activeWorktreePath)} )} diff --git a/apps/web/src/components/ChatMarkdown.browser.tsx b/apps/web/src/components/ChatMarkdown.browser.tsx index 48f345bd2f9..a397d52a37f 100644 --- a/apps/web/src/components/ChatMarkdown.browser.tsx +++ b/apps/web/src/components/ChatMarkdown.browser.tsx @@ -63,7 +63,7 @@ describe("ChatMarkdown", () => { ); try { - const link = page.getByRole("link", { name: "PermissionRule.ts:1" }); + const link = page.getByRole("link", { name: "PermissionRule.ts · L1" }); await expect.element(link).toBeInTheDocument(); await expect.element(link).toHaveAttribute("href", `${filePath}#L1`); @@ -76,4 +76,66 @@ describe("ChatMarkdown", () => { await screen.unmount(); } }); + + it("shows column information inline when present", async () => { + const filePath = + "/Users/yashsingh/p/sco/claude-code-extract/src/utils/permissions/PermissionRule.ts"; + const screen = await render( + , + ); + + try { + const link = page.getByRole("link", { name: "PermissionRule.ts · L1:C7" }); + await expect.element(link).toBeInTheDocument(); + await expect.element(link).toHaveAttribute("href", `${filePath}#L1C7`); + + await link.click(); + + await vi.waitFor(() => { + expect(openInPreferredEditorMock).toHaveBeenCalledWith( + expect.anything(), + `${filePath}:1:7`, + ); + }); + } finally { + await screen.unmount(); + } + }); + + it("disambiguates duplicate file basenames inline", async () => { + const firstPath = "/Users/yashsingh/p/t3code/apps/web/src/components/chat/MessagesTimeline.tsx"; + const secondPath = "/Users/yashsingh/p/t3code/apps/web/src/components/MessagesTimeline.tsx"; + const screen = await render( + , + ); + + try { + await expect + .element(page.getByRole("link", { name: "MessagesTimeline.tsx · components/chat" })) + .toBeInTheDocument(); + await expect + .element(page.getByRole("link", { name: "MessagesTimeline.tsx · src/components" })) + .toBeInTheDocument(); + } finally { + await screen.unmount(); + } + }); + + it("keeps normal web links unchanged", async () => { + const screen = await render( + , + ); + + try { + const link = page.getByRole("link", { name: "OpenAI" }); + await expect.element(link).toBeInTheDocument(); + await expect.element(link).toHaveAttribute("href", "https://openai.com/docs"); + await expect.element(link).toHaveAttribute("target", "_blank"); + } finally { + await screen.unmount(); + } + }); }); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 366f9231579..ba1c944cc87 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -3,6 +3,7 @@ import { CheckIcon, CopyIcon } from "lucide-react"; import React, { Children, Suspense, + type MouseEvent as ReactMouseEvent, isValidElement, use, useCallback, @@ -17,13 +18,17 @@ import type { Components } from "react-markdown"; import ReactMarkdown from "react-markdown"; import { defaultUrlTransform } from "react-markdown"; import remarkGfm from "remark-gfm"; +import { VscodeEntryIcon } from "./chat/VscodeEntryIcon"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; +import { toastManager } from "./ui/toast"; import { openInPreferredEditor } from "../editorPreferences"; import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; import { fnv1a32 } from "../lib/diffRendering"; import { LRUCache } from "../lib/lruCache"; import { useTheme } from "../hooks/useTheme"; -import { resolveMarkdownFileLinkTarget, rewriteMarkdownFileUriHref } from "../markdown-links"; +import { resolveMarkdownFileLinkMeta, rewriteMarkdownFileUriHref } from "../markdown-links"; import { readLocalApi } from "../localApi"; +import { cn } from "../lib/utils"; class CodeHighlightErrorBoundary extends React.Component< { fallback: ReactNode; children: ReactNode }, @@ -236,34 +241,289 @@ function SuspenseShikiCodeBlock({ ); } +interface MarkdownFileLinkProps { + href: string; + targetPath: string; + displayPath: string; + filePath: string; + label: string; + theme: "light" | "dark"; + className?: string | undefined; +} + +const MARKDOWN_LINK_HREF_PATTERN = /\[[^\]]*]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g; +const MARKDOWN_FILE_LINK_CLASS_NAME = + "chat-markdown-file-link relative top-[2px] max-w-full no-underline"; +const MARKDOWN_FILE_LINK_ICON_CLASS_NAME = "chat-markdown-file-link-icon size-3.5 shrink-0"; +const MARKDOWN_FILE_LINK_LABEL_CLASS_NAME = "chat-markdown-file-link-label truncate"; + +function pathParentSegments(path: string): string[] { + const normalized = path.replaceAll("\\", "/"); + const segments = normalized.split("/").filter((segment) => segment.length > 0); + return segments.slice(0, -1); +} + +function buildFileLinkParentSuffixByPath(filePaths: ReadonlyArray): Map { + const groups = new Map>(); + for (const filePath of filePaths) { + const pathSegments = filePath + .replaceAll("\\", "/") + .split("/") + .filter((segment) => segment.length > 0); + const basename = pathSegments[pathSegments.length - 1]; + if (!basename) continue; + const group = groups.get(basename) ?? new Set(); + group.add(filePath); + groups.set(basename, group); + } + + const suffixByPath = new Map(); + for (const group of groups.values()) { + const uniquePaths = [...group]; + if (uniquePaths.length < 2) continue; + + const parentSegmentsByPath = new Map( + uniquePaths.map((filePath) => [filePath, pathParentSegments(filePath)]), + ); + const minUniqueDepthByPath = new Map(); + + for (const filePath of uniquePaths) { + const segments = parentSegmentsByPath.get(filePath) ?? []; + let resolvedDepth = segments.length; + for (let depth = 1; depth <= segments.length; depth += 1) { + const candidate = segments.slice(-depth).join("/"); + const collision = uniquePaths.some((otherPath) => { + if (otherPath === filePath) return false; + const otherSegments = parentSegmentsByPath.get(otherPath) ?? []; + return otherSegments.slice(-depth).join("/") === candidate; + }); + if (!collision) { + resolvedDepth = depth; + break; + } + } + minUniqueDepthByPath.set(filePath, resolvedDepth); + } + + for (const filePath of uniquePaths) { + const segments = parentSegmentsByPath.get(filePath) ?? []; + if (segments.length === 0) continue; + const minUniqueDepth = minUniqueDepthByPath.get(filePath) ?? 1; + const suffixDepth = Math.min(segments.length, Math.max(minUniqueDepth, 2)); + suffixByPath.set(filePath, segments.slice(-suffixDepth).join("/")); + } + } + + return suffixByPath; +} + +function extractMarkdownLinkHrefs(text: string): string[] { + const hrefs: string[] = []; + for (const match of text.matchAll(MARKDOWN_LINK_HREF_PATTERN)) { + const href = match[1]?.trim(); + if (!href) continue; + hrefs.push(href); + } + return hrefs; +} + +function normalizeMarkdownLinkHrefKey(href: string): string { + return rewriteMarkdownFileUriHref(href.trim()) ?? href.trim(); +} + +const MarkdownFileLink = memo(function MarkdownFileLink({ + href, + targetPath, + displayPath, + filePath, + label, + theme, + className, +}: MarkdownFileLinkProps) { + const handleOpen = useCallback(() => { + const api = readLocalApi(); + if (!api) { + toastManager.add({ + type: "error", + title: "Open in editor is unavailable", + }); + return; + } + + void openInPreferredEditor(api, targetPath).catch((error) => { + toastManager.add({ + type: "error", + title: "Unable to open file", + description: error instanceof Error ? error.message : "An error occurred.", + }); + }); + }, [targetPath]); + + const handleCopy = useCallback((value: string, title: string) => { + if (typeof window === "undefined" || !navigator.clipboard?.writeText) { + toastManager.add({ + 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) => { + toastManager.add({ + type: "error", + title: `Failed to copy ${title.toLowerCase()}`, + description: error instanceof Error ? error.message : "An error occurred.", + }); + }, + ); + }, []); + + const handleContextMenu = useCallback( + async (event: ReactMouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + + const api = readLocalApi(); + if (!api) return; + + const clicked = await api.contextMenu.show( + [ + { id: "open", label: "Open in editor" }, + { id: "copy-relative", label: "Copy relative path" }, + { id: "copy-full", label: "Copy full path" }, + ] as const, + { x: event.clientX, y: event.clientY }, + ); + + if (clicked === "open") { + handleOpen(); + return; + } + if (clicked === "copy-relative") { + handleCopy(displayPath, "Relative path"); + return; + } + if (clicked === "copy-full") { + handleCopy(targetPath, "Full path"); + } + }, + [displayPath, handleCopy, handleOpen, targetPath], + ); + + return ( + + { + event.preventDefault(); + event.stopPropagation(); + handleOpen(); + }} + onContextMenu={handleContextMenu} + > + + {label} + + } + /> + +
+ {displayPath} +
+
+
+ ); +}, areMarkdownFileLinkPropsEqual); + +function areMarkdownFileLinkPropsEqual( + previous: Readonly, + next: Readonly, +): boolean { + return ( + previous.href === next.href && + previous.targetPath === next.targetPath && + previous.displayPath === next.displayPath && + previous.filePath === next.filePath && + previous.label === next.label && + previous.theme === next.theme && + previous.className === next.className + ); +} + function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { const { resolvedTheme } = useTheme(); const diffThemeName = resolveDiffThemeName(resolvedTheme); + const markdownFileLinkMetaByHref = useMemo(() => { + const metaByHref = new Map< + string, + NonNullable> + >(); + for (const href of extractMarkdownLinkHrefs(text)) { + const normalizedHref = normalizeMarkdownLinkHrefKey(href); + if (metaByHref.has(normalizedHref)) continue; + const meta = resolveMarkdownFileLinkMeta(normalizedHref, cwd); + if (meta) { + metaByHref.set(normalizedHref, meta); + } + } + return metaByHref; + }, [cwd, text]); + const fileLinkParentSuffixByPath = useMemo(() => { + const filePaths = [...markdownFileLinkMetaByHref.values()].map((meta) => meta.filePath); + return buildFileLinkParentSuffixByPath(filePaths); + }, [markdownFileLinkMetaByHref]); const markdownUrlTransform = useCallback((href: string) => { return rewriteMarkdownFileUriHref(href) ?? defaultUrlTransform(href); }, []); const markdownComponents = useMemo( () => ({ a({ node: _node, href, ...props }) { - const targetPath = resolveMarkdownFileLinkTarget(href, cwd); - if (!targetPath) { + const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : ""; + const fileLinkMeta = normalizedHref ? markdownFileLinkMetaByHref.get(normalizedHref) : null; + if (!fileLinkMeta) { return ; } + const parentSuffix = fileLinkParentSuffixByPath.get(fileLinkMeta.filePath); + const labelParts = [fileLinkMeta.basename]; + if (typeof parentSuffix === "string" && parentSuffix.length > 0) { + labelParts.push(parentSuffix); + } + if (fileLinkMeta.line) { + labelParts.push( + `L${fileLinkMeta.line}${fileLinkMeta.column ? `:C${fileLinkMeta.column}` : ""}`, + ); + } + return ( - { - event.preventDefault(); - event.stopPropagation(); - const api = readLocalApi(); - if (api) { - void openInPreferredEditor(api, targetPath); - } else { - console.warn("Native API not found. Unable to open file in editor."); - } - }} + ); }, @@ -289,7 +549,13 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { ); }, }), - [cwd, diffThemeName, isStreaming], + [ + diffThemeName, + fileLinkParentSuffixByPath, + isStreaming, + markdownFileLinkMetaByHref, + resolvedTheme, + ], ); return ( diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 29dc11ecef8..4cd063d0794 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -5,8 +5,8 @@ import { EventId, ORCHESTRATION_WS_METHODS, EnvironmentId, + type EnvironmentApi, type MessageId, - type OrchestrationEvent, type OrchestrationReadModel, type ProjectId, type ServerConfig, @@ -32,6 +32,16 @@ import { render } from "vitest-browser-react"; import { useCommandPaletteStore } from "../commandPaletteStore"; import { useComposerDraftStore, DraftId } from "../composerDraftStore"; +import { + __resetEnvironmentApiOverridesForTests, + __setEnvironmentApiOverrideForTests, +} from "../environmentApi"; +import { + resetSavedEnvironmentRegistryStoreForTests, + resetSavedEnvironmentRuntimeStoreForTests, + useSavedEnvironmentRegistryStore, + useSavedEnvironmentRuntimeStore, +} from "../environments/runtime"; import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER, removeInlineTerminalContextPlaceholder, @@ -42,6 +52,7 @@ import { __resetLocalApiForTests } from "../localApi"; import { AppAtomRegistryProvider } from "../rpc/atomRegistry"; import { getServerConfig } from "../rpc/serverState"; import { getRouter } from "../router"; +import { deriveLogicalProjectKeyFromSettings } from "../logicalProject"; import { selectBootstrapCompleteForActiveEnvironment, useStore } from "../store"; import { useTerminalStateStore } from "../terminalStateStore"; import { useUiStateStore } from "../uiStateStore"; @@ -58,18 +69,32 @@ vi.mock("../lib/gitStatusState", () => ({ })); const THREAD_ID = "thread-browser-test" as ThreadId; +const THREAD_TITLE = "Browser test thread"; const ARCHIVED_SECONDARY_THREAD_ID = "thread-secondary-project-archived" as ThreadId; const PROJECT_ID = "project-1" as ProjectId; const SECOND_PROJECT_ID = "project-2" as ProjectId; const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); +const REMOTE_ENVIRONMENT_ID = EnvironmentId.make("environment-remote"); const THREAD_REF = scopeThreadRef(LOCAL_ENVIRONMENT_ID, THREAD_ID); const THREAD_KEY = scopedThreadKey(THREAD_REF); const UUID_ROUTE_RE = /^\/draft\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; const PROJECT_DRAFT_KEY = `${LOCAL_ENVIRONMENT_ID}:${PROJECT_ID}`; -const PROJECT_KEY = scopedProjectKey(scopeProjectRef(LOCAL_ENVIRONMENT_ID, PROJECT_ID)); +const PROJECT_LOGICAL_KEY = deriveLogicalProjectKeyFromSettings( + { + environmentId: LOCAL_ENVIRONMENT_ID, + id: PROJECT_ID, + cwd: "/repo/project", + repositoryIdentity: null, + }, + { + sidebarProjectGroupingMode: DEFAULT_CLIENT_SETTINGS.sidebarProjectGroupingMode, + sidebarProjectGroupingOverrides: DEFAULT_CLIENT_SETTINGS.sidebarProjectGroupingOverrides, + }, +); const NOW_ISO = "2026-03-04T12:00:00.000Z"; const BASE_TIME_MS = Date.parse(NOW_ISO); const ATTACHMENT_SVG = ""; +const ADD_PROJECT_SUBMENU_PLACEHOLDER = "Enter path (e.g. ~/projects/my-app)"; interface TestFixture { snapshot: OrchestrationReadModel; @@ -190,6 +215,42 @@ function createBaseServerConfig(): ServerConfig { }; } +function createMockEnvironmentApi(input: { + browse: EnvironmentApi["filesystem"]["browse"]; + dispatchCommand: EnvironmentApi["orchestration"]["dispatchCommand"]; +}): EnvironmentApi { + return { + terminal: {} as EnvironmentApi["terminal"], + projects: {} as EnvironmentApi["projects"], + filesystem: { + browse: input.browse, + }, + git: {} as EnvironmentApi["git"], + orchestration: { + getSnapshot: (() => { + throw new Error("Not implemented in browser test."); + }) as EnvironmentApi["orchestration"]["getSnapshot"], + getListingSnapshot: (() => { + throw new Error("Not implemented in browser test."); + }) as EnvironmentApi["orchestration"]["getListingSnapshot"], + getThread: (() => { + throw new Error("Not implemented in browser test."); + }) as EnvironmentApi["orchestration"]["getThread"], + dispatchCommand: input.dispatchCommand, + getTurnDiff: (() => { + throw new Error("Not implemented in browser test."); + }) as EnvironmentApi["orchestration"]["getTurnDiff"], + getFullThreadDiff: (() => { + throw new Error("Not implemented in browser test."); + }) as EnvironmentApi["orchestration"]["getFullThreadDiff"], + subscribeShell: (() => () => undefined) as EnvironmentApi["orchestration"]["subscribeShell"], + subscribeThread: (() => () => + undefined) as EnvironmentApi["orchestration"]["subscribeThread"], + }, + jira: {} as EnvironmentApi["jira"], + }; +} + function createUserMessage(options: { id: MessageId; text: string; @@ -308,7 +369,7 @@ function createSnapshotForTargetUser(options: { { id: THREAD_ID, projectId: PROJECT_ID, - title: "Browser test thread", + title: THREAD_TITLE, modelSelection: { provider: "codex", model: "gpt-5", @@ -414,74 +475,94 @@ function addThreadToSnapshot( }; } -function createThreadCreatedEvent(threadId: ThreadId, sequence: number): OrchestrationEvent { +function toShellThread(thread: OrchestrationReadModel["threads"][number]) { return { - sequence, - eventId: EventId.make(`event-thread-created-${sequence}`), - aggregateKind: "thread", - aggregateId: threadId, - occurredAt: NOW_ISO, - commandId: null, - causationEventId: null, - correlationId: null, - metadata: {}, - type: "thread.created", - payload: { - threadId, - projectId: PROJECT_ID, - title: "New thread", - modelSelection: { - provider: "codex", - model: "gpt-5", - }, - runtimeMode: "full-access", - interactionMode: "default", - branch: "main", - worktreePath: null, - createdAt: NOW_ISO, - updatedAt: NOW_ISO, - }, + id: thread.id, + projectId: thread.projectId, + title: thread.title, + modelSelection: thread.modelSelection, + runtimeMode: thread.runtimeMode, + interactionMode: thread.interactionMode, + branch: thread.branch, + worktreePath: thread.worktreePath, + latestTurn: thread.latestTurn, + createdAt: thread.createdAt, + updatedAt: thread.updatedAt, + archivedAt: thread.archivedAt, + session: thread.session, + latestUserMessageAt: + thread.messages.findLast((message) => message.role === "user")?.createdAt ?? null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, }; } -function createThreadSessionSetEvent(threadId: ThreadId, sequence: number): OrchestrationEvent { +function toShellSnapshot(snapshot: OrchestrationReadModel) { return { - sequence, - eventId: EventId.make(`event-thread-session-set-${sequence}`), - aggregateKind: "thread", - aggregateId: threadId, - occurredAt: NOW_ISO, - commandId: null, - causationEventId: null, - correlationId: null, - metadata: {}, - type: "thread.session-set", - payload: { - threadId, - session: { - threadId, - status: "running", - providerName: "codex", - runtimeMode: "full-access", - activeTurnId: `turn-${threadId}` as TurnId, - lastError: null, - updatedAt: NOW_ISO, - }, - }, + snapshotSequence: snapshot.snapshotSequence, + projects: snapshot.projects.map((project) => ({ + id: project.id, + title: project.title, + workspaceRoot: project.workspaceRoot, + repositoryIdentity: project.repositoryIdentity ?? null, + defaultModelSelection: project.defaultModelSelection, + scripts: project.scripts, + createdAt: project.createdAt, + updatedAt: project.updatedAt, + })), + threads: snapshot.threads.map(toShellThread), + updatedAt: snapshot.updatedAt, + }; +} + +function updateThreadSessionInSnapshot( + snapshot: OrchestrationReadModel, + threadId: ThreadId, + session: OrchestrationReadModel["threads"][number]["session"], +): OrchestrationReadModel { + return { + ...snapshot, + snapshotSequence: snapshot.snapshotSequence + 1, + threads: snapshot.threads.map((thread) => + thread.id === threadId + ? { + ...thread, + session, + updatedAt: NOW_ISO, + } + : thread, + ), }; } -function sendOrchestrationDomainEvent(event: OrchestrationEvent): void { - rpcHarness.emitStreamValue(WS_METHODS.subscribeOrchestrationDomainEvents, event); +function sendShellThreadUpsert( + threadId: ThreadId, + options?: { + readonly session?: OrchestrationReadModel["threads"][number]["session"]; + }, +): void { + const thread = fixture.snapshot.threads.find((entry) => entry.id === threadId); + if (!thread) { + throw new Error(`Expected thread ${threadId} in snapshot.`); + } + + const shellThread = + options?.session !== undefined + ? toShellThread({ ...thread, session: options.session }) + : toShellThread(thread); + rpcHarness.emitStreamValue(ORCHESTRATION_WS_METHODS.subscribeShell, { + kind: "thread-upserted", + sequence: fixture.snapshot.snapshotSequence, + thread: shellThread, + }); } async function waitForWsClient(): Promise { await vi.waitFor( () => { expect( - wsRequests.some( - (request) => request._tag === WS_METHODS.subscribeOrchestrationDomainEvents, - ), + wsRequests.some((request) => request._tag === ORCHESTRATION_WS_METHODS.subscribeShell), ).toBe(true); expect( wsRequests.some((request) => request._tag === WS_METHODS.subscribeServerLifecycle), @@ -541,15 +622,21 @@ async function waitForAppBootstrap(): Promise { async function materializePromotedDraftThreadViaDomainEvent(threadId: ThreadId): Promise { await waitForWsClient(); fixture.snapshot = addThreadToSnapshot(fixture.snapshot, threadId); - sendOrchestrationDomainEvent( - createThreadCreatedEvent(threadId, fixture.snapshot.snapshotSequence), - ); + fixture.snapshot = updateThreadSessionInSnapshot(fixture.snapshot, threadId, null); + sendShellThreadUpsert(threadId, { session: null }); } async function startPromotedServerThreadViaDomainEvent(threadId: ThreadId): Promise { - sendOrchestrationDomainEvent( - createThreadSessionSetEvent(threadId, fixture.snapshot.snapshotSequence + 1), - ); + fixture.snapshot = updateThreadSessionInSnapshot(fixture.snapshot, threadId, { + threadId, + status: "running", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: `turn-${threadId}` as TurnId, + lastError: null, + updatedAt: NOW_ISO, + }); + sendShellThreadUpsert(threadId); } async function promoteDraftThreadViaDomainEvent(threadId: ThreadId): Promise { @@ -1427,6 +1514,44 @@ async function waitForCommandPaletteShortcutLabel(): Promise { ); } +async function waitForCommandPaletteInput(placeholder: string): Promise { + return waitForElement( + () => document.querySelector(`input[placeholder="${placeholder}"]`) as HTMLInputElement | null, + `Command palette input with placeholder "${placeholder}" did not render.`, + ); +} + +function getCommandPaletteLegendEntries(): string[] { + const footer = document.querySelector('[data-slot="command-footer"]'); + if (!footer) { + return []; + } + + return Array.from(footer.querySelectorAll('[data-slot="kbd-group"]')) + .map((group) => + Array.from(group.children) + .map((child) => child.textContent?.trim() ?? "") + .filter((value) => value.length > 0) + .join(" "), + ) + .filter((value) => value.length > 0); +} + +async function dispatchInputKey( + input: HTMLInputElement, + init: Pick, +): Promise { + input.focus(); + input.dispatchEvent( + new KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + ...init, + }), + ); + await waitForLayout(); +} + async function waitForImagesToLoad(scope: ParentNode): Promise { const images = Array.from(scope.querySelectorAll("img")); if (images.length === 0) { @@ -1650,6 +1775,28 @@ describe("ChatView timeline estimator parity (full app)", () => { }, ]; } + if (request._tag === ORCHESTRATION_WS_METHODS.subscribeShell) { + return [ + { + kind: "snapshot", + snapshot: toShellSnapshot(fixture.snapshot), + }, + ]; + } + if (request._tag === ORCHESTRATION_WS_METHODS.subscribeThread) { + const thread = fixture.snapshot.threads.find((entry) => entry.id === request.threadId); + return thread + ? [ + { + kind: "snapshot", + snapshot: { + snapshotSequence: fixture.snapshot.snapshotSequence, + thread, + }, + }, + ] + : []; + } return []; }, }); @@ -1659,6 +1806,10 @@ describe("ChatView timeline estimator parity (full app)", () => { document.body.innerHTML = ""; wsRequests.length = 0; customWsRpcResolver = null; + __resetEnvironmentApiOverridesForTests(); + resetSavedEnvironmentRegistryStoreForTests(); + resetSavedEnvironmentRuntimeStoreForTests(); + Reflect.deleteProperty(window, "desktopBridge"); useComposerDraftStore.setState({ draftsByThreadKey: {}, draftThreadsByThreadKey: {}, @@ -1668,6 +1819,8 @@ describe("ChatView timeline estimator parity (full app)", () => { }); useCommandPaletteStore.setState({ open: false, + openIntent: null, + addFolderResult: null, }); useStore.setState({ activeEnvironmentId: null, @@ -1726,9 +1879,9 @@ describe("ChatView timeline estimator parity (full app)", () => { it("re-expands the bootstrap project using its scoped key", async () => { useUiStateStore.setState({ projectExpandedById: { - [PROJECT_KEY]: false, + [PROJECT_LOGICAL_KEY]: false, }, - projectOrder: [PROJECT_KEY], + projectOrder: [PROJECT_LOGICAL_KEY], threadLastVisitedAtById: {}, }); @@ -1743,7 +1896,7 @@ describe("ChatView timeline estimator parity (full app)", () => { try { await vi.waitFor( () => { - expect(useUiStateStore.getState().projectExpandedById[PROJECT_KEY]).toBe(true); + expect(useUiStateStore.getState().projectExpandedById[PROJECT_LOGICAL_KEY]).toBe(true); }, { timeout: 8_000, interval: 16 }, ); @@ -2092,6 +2245,55 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("shows Kiro in the open picker menu and opens the project cwd with it", async () => { + setDraftThreadWithoutWorktree(); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createDraftOnlySnapshot(), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + availableEditors: ["kiro"], + }; + }, + }); + + try { + await waitForServerConfigToApply(); + const menuButton = await waitForElement( + () => document.querySelector('button[aria-label="Copy options"]'), + "Unable to find Open picker button.", + ); + (menuButton as HTMLButtonElement).click(); + + const kiroItem = await waitForElement( + () => + Array.from(document.querySelectorAll('[data-slot="menu-item"]')).find((item) => + item.textContent?.includes("Kiro"), + ) ?? null, + "Unable to find Kiro menu item.", + ); + (kiroItem as HTMLElement).click(); + + await vi.waitFor( + () => { + const openRequest = wsRequests.find( + (request) => request._tag === WS_METHODS.shellOpenInEditor, + ); + expect(openRequest).toMatchObject({ + _tag: WS_METHODS.shellOpenInEditor, + cwd: "/repo/project", + editor: "kiro", + }); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + it("filters the open picker menu and opens VSCodium from the menu", async () => { setDraftThreadWithoutWorktree(); @@ -2829,6 +3031,108 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("keeps the new worktree branch picker anchored at the top when opening with a preselected branch", async () => { + const draftId = DraftId.make("draft-branch-picker-scroll-regression"); + const branches = [ + { + name: "feature/current", + current: true, + isDefault: false, + worktreePath: null, + }, + { + name: "main", + current: false, + isDefault: true, + worktreePath: null, + }, + ...Array.from({ length: 48 }, (_, index) => ({ + name: `feature/${String(index).padStart(2, "0")}`, + current: false, + isDefault: false, + worktreePath: null, + })), + { + name: "feature/selected", + current: false, + isDefault: false, + worktreePath: null, + }, + ]; + + useComposerDraftStore.setState({ + draftThreadsByThreadKey: { + [draftId]: { + threadId: THREAD_ID, + environmentId: LOCAL_ENVIRONMENT_ID, + projectId: PROJECT_ID, + logicalProjectKey: PROJECT_DRAFT_KEY, + createdAt: NOW_ISO, + runtimeMode: "full-access", + interactionMode: "default", + branch: "feature/selected", + worktreePath: null, + envMode: "worktree", + }, + }, + logicalProjectDraftThreadKeyByLogicalProjectKey: { + [PROJECT_DRAFT_KEY]: draftId, + }, + }); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createDraftOnlySnapshot(), + initialPath: `/draft/${draftId}`, + resolveRpc: (body) => { + if (body._tag === WS_METHODS.gitListBranches) { + return { + isRepo: true, + hasOriginRemote: true, + nextCursor: null, + totalCount: branches.length, + branches, + }; + } + return undefined; + }, + }); + + try { + const branchButton = await waitForElement( + () => + Array.from(document.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "From feature/selected", + ) as HTMLButtonElement | null, + 'Unable to find branch selector button with "From feature/selected".', + ); + branchButton.click(); + + await waitForElement( + () => document.querySelector('input[placeholder="Search branches..."]'), + "Unable to find branch search input.", + ); + + const popup = await waitForElement( + () => document.querySelector('[data-slot="combobox-popup"]'), + "Unable to find the branch picker popup.", + ); + + await vi.waitFor( + () => { + const popupSpans = Array.from(popup.querySelectorAll("span")); + expect( + popupSpans.some((element) => element.textContent?.trim() === "feature/current"), + ).toBe(true); + expect(popupSpans.some((element) => element.textContent?.trim() === "main")).toBe(true); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + it("surrounds selected plain text and preserves the inner selection for repeated wrapping", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, @@ -3275,6 +3579,34 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("exposes the full thread title on the sidebar row tooltip", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-thread-tooltip-target" as MessageId, + targetText: "thread tooltip target", + }), + }); + + try { + const threadTitle = page.getByTestId(`thread-title-${THREAD_ID}`); + + await expect.element(threadTitle).toBeInTheDocument(); + await threadTitle.hover(); + + await vi.waitFor( + () => { + const tooltip = document.querySelector('[data-slot="tooltip-popup"]'); + expect(tooltip).not.toBeNull(); + expect(tooltip?.textContent).toContain(THREAD_TITLE); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + it("shows the confirm archive action after clicking the archive button", async () => { localStorage.setItem( "marcode:client-settings:v1", @@ -3414,27 +3746,35 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); - it("snapshots sticky codex settings into a new draft thread", async () => { - useComposerDraftStore.setState({ - stickyModelSelectionByProvider: { - codex: { - provider: "codex", - model: "gpt-5.3-codex", - options: { - reasoningEffort: "medium", - fastMode: true, - }, - }, - }, - stickyActiveProvider: "codex", - }); - + it("creates a fresh worktree draft from an existing worktree thread when the default mode is worktree", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-sticky-codex-traits-test" as MessageId, - targetText: "sticky codex traits test", - }), + snapshot: { + ...createSnapshotForTargetUser({ + targetMessageId: "msg-user-new-thread-worktree-default-test" as MessageId, + targetText: "new thread worktree default test", + }), + threads: createSnapshotForTargetUser({ + targetMessageId: "msg-user-new-thread-worktree-default-test" as MessageId, + targetText: "new thread worktree default test", + }).threads.map((thread) => + thread.id === THREAD_ID + ? Object.assign({}, thread, { + branch: "feature/existing", + worktreePath: "/repo/.t3/worktrees/existing", + }) + : thread, + ), + }, + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + settings: { + ...nextFixture.serverConfig.settings, + defaultThreadEnvMode: "worktree", + }, + }; + }, }); try { @@ -3446,20 +3786,104 @@ describe("ChatView timeline estimator parity (full app)", () => { const newThreadPath = await waitForURL( mounted.router, (path) => UUID_ROUTE_RE.test(path), - "Route should have changed to a new draft thread UUID.", + "Route should change to a new draft thread.", ); const newDraftId = draftIdFromPath(newThreadPath); - expect(composerDraftFor(newDraftId)).toMatchObject({ - modelSelectionByProvider: { - codex: { - provider: "codex", - model: "gpt-5.3-codex", - options: { - fastMode: true, - }, - }, - }, + expect(useComposerDraftStore.getState().getDraftSession(newDraftId)).toMatchObject({ + envMode: "worktree", + worktreePath: null, + }); + } finally { + await mounted.cleanup(); + } + }); + + it("creates a new draft instead of reusing a promoting draft thread", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-promoting-draft-new-thread-test" as MessageId, + targetText: "promoting draft new thread test", + }), + }); + + try { + const newThreadButton = page.getByTestId("new-thread-button"); + await expect.element(newThreadButton).toBeInTheDocument(); + + await newThreadButton.click(); + + const firstDraftPath = await waitForURL( + mounted.router, + (path) => UUID_ROUTE_RE.test(path), + "Route should change to the first draft thread.", + ); + const firstDraftId = draftIdFromPath(firstDraftPath); + const firstThreadId = draftThreadIdFor(firstDraftId); + + await materializePromotedDraftThreadViaDomainEvent(firstThreadId); + expect(mounted.router.state.location.pathname).toBe(firstDraftPath); + + await newThreadButton.click(); + + const secondDraftPath = await waitForURL( + mounted.router, + (path) => UUID_ROUTE_RE.test(path) && path !== firstDraftPath, + "Route should change to a second draft thread instead of reusing the promoting draft.", + ); + expect(draftIdFromPath(secondDraftPath)).not.toBe(firstDraftId); + } finally { + await mounted.cleanup(); + } + }); + + it("snapshots sticky codex settings into a new draft thread", async () => { + useComposerDraftStore.setState({ + stickyModelSelectionByProvider: { + codex: { + provider: "codex", + model: "gpt-5.3-codex", + options: { + reasoningEffort: "medium", + fastMode: true, + }, + }, + }, + stickyActiveProvider: "codex", + }); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-sticky-codex-traits-test" as MessageId, + targetText: "sticky codex traits test", + }), + }); + + try { + const newThreadButton = page.getByTestId("new-thread-button"); + await expect.element(newThreadButton).toBeInTheDocument(); + + await newThreadButton.click(); + + const newThreadPath = await waitForURL( + mounted.router, + (path) => UUID_ROUTE_RE.test(path), + "Route should have changed to a new draft thread UUID.", + ); + const newDraftId = draftIdFromPath(newThreadPath); + + expect(composerDraftFor(newDraftId)).toMatchObject({ + modelSelectionByProvider: { + codex: { + provider: "codex", + model: "gpt-5.3-codex", + options: { + fastMode: true, + }, + }, + }, activeProvider: "codex", }); } finally { @@ -3820,6 +4244,746 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("adds a project from browse mode with Enter when no directory is highlighted", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-command-palette-add-project-enter" as MessageId, + targetText: "command palette add project enter", + }), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + keybindings: [ + { + command: "commandPalette.toggle", + shortcut: { + key: "k", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }, + whenAst: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, + }, + }, + ], + }; + }, + resolveRpc: (body) => { + if (body._tag === WS_METHODS.filesystemBrowse) { + if (body.partialPath === "~/Development/") { + return { + parentPath: "~/Development/", + entries: [ + { name: "alpha", fullPath: "~/Development/alpha" }, + { name: "beta", fullPath: "~/Development/beta" }, + ], + }; + } + + return { + parentPath: "~/", + entries: [{ name: "Development", fullPath: "~/Development" }], + }; + } + + if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { + return { + sequence: fixture.snapshot.snapshotSequence + 1, + }; + } + + return undefined; + }, + }); + + try { + await waitForServerConfigToApply(); + await waitForCommandPaletteShortcutLabel(); + const palette = page.getByTestId("command-palette"); + await openCommandPaletteFromTrigger(); + + await expect.element(palette).toBeInTheDocument(); + await palette.getByText("Add project", { exact: true }).click(); + + const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER); + await page.getByPlaceholder(ADD_PROJECT_SUBMENU_PLACEHOLDER).fill("~/Development/"); + await expect.element(palette.getByText("alpha", { exact: true })).toBeInTheDocument(); + + await expect + .element(palette.getByRole("button", { name: "Add (Enter)" })) + .toBeInTheDocument(); + + await dispatchInputKey(browseInput, { key: "Enter" }); + + await vi.waitFor( + () => { + const dispatchRequest = wsRequests.find( + (request) => + request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && + request.type === "project.create", + ) as + | { + _tag: string; + type?: string; + workspaceRoot?: string; + title?: string; + } + | undefined; + + expect(dispatchRequest).toMatchObject({ + _tag: ORCHESTRATION_WS_METHODS.dispatchCommand, + type: "project.create", + workspaceRoot: "~/Development", + title: "Development", + }); + }, + { timeout: 8_000, interval: 16 }, + ); + + await waitForURL( + mounted.router, + (path) => UUID_ROUTE_RE.test(path), + "Route should have changed to a new draft thread after adding a project with Enter.", + ); + } finally { + await mounted.cleanup(); + } + }); + + it("opens add project browse mode from the sidebar add button", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-sidebar-add-project-trigger" as MessageId, + targetText: "sidebar add project trigger", + }), + resolveRpc: (body) => { + if (body._tag === WS_METHODS.filesystemBrowse) { + return { + parentPath: "~/", + entries: [{ name: "Development", fullPath: "~/Development" }], + }; + } + + return undefined; + }, + }); + + try { + await waitForServerConfigToApply(); + + await page.getByTestId("sidebar-add-project-trigger").click(); + + const palette = page.getByTestId("command-palette"); + await expect.element(palette).toBeInTheDocument(); + + const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER); + await expect.element(browseInput).toHaveValue("~/"); + + await vi.waitFor( + () => { + expect( + wsRequests.some( + (request) => + request._tag === WS_METHODS.filesystemBrowse && request.partialPath === "~/", + ), + ).toBe(true); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("starts add project browse mode from the configured base directory", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-sidebar-add-project-custom-base-dir" as MessageId, + targetText: "sidebar add project custom base directory", + }), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + settings: { + ...nextFixture.serverConfig.settings, + addProjectBaseDirectory: "~/Development", + }, + }; + }, + resolveRpc: (body) => { + if (body._tag === WS_METHODS.filesystemBrowse) { + if (body.partialPath === "~/Development/") { + return { + parentPath: "~/Development/", + entries: [{ name: "codething", fullPath: "~/Development/codething" }], + }; + } + + return { + parentPath: "~/", + entries: [{ name: "Development", fullPath: "~/Development" }], + }; + } + + return undefined; + }, + }); + + try { + await waitForServerConfigToApply(); + + await page.getByTestId("sidebar-add-project-trigger").click(); + + const palette = page.getByTestId("command-palette"); + await expect.element(palette).toBeInTheDocument(); + + const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER); + await expect.element(browseInput).toHaveValue("~/Development/"); + + await vi.waitFor( + () => { + expect( + wsRequests.some( + (request) => + request._tag === WS_METHODS.filesystemBrowse && + request.partialPath === "~/Development/", + ), + ).toBe(true); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("shows create-folder affordances for missing project paths", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-command-palette-create-missing-project" as MessageId, + targetText: "command palette create missing project", + }), + resolveRpc: (body) => { + if (body._tag === WS_METHODS.filesystemBrowse) { + if (body.partialPath === "~/Desktop/") { + return { + parentPath: "~/Desktop/", + entries: [{ name: "existing", fullPath: "~/Desktop/existing" }], + }; + } + + return { + parentPath: "~/", + entries: [{ name: "Desktop", fullPath: "~/Desktop" }], + }; + } + + if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { + return { + sequence: fixture.snapshot.snapshotSequence + 1, + }; + } + + return undefined; + }, + }); + + try { + await waitForServerConfigToApply(); + const palette = page.getByTestId("command-palette"); + await page.getByTestId("sidebar-add-project-trigger").click(); + + await expect.element(palette).toBeInTheDocument(); + const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER); + await page.getByPlaceholder(ADD_PROJECT_SUBMENU_PLACEHOLDER).fill("~/Desktop/fresh-project"); + + await expect + .element(palette.getByRole("button", { name: "Create & Add (Enter)" })) + .toBeInTheDocument(); + await expect.element(palette.getByText("Will create this folder")).not.toBeInTheDocument(); + + await dispatchInputKey(browseInput, { key: "Enter" }); + + await vi.waitFor( + () => { + const dispatchRequest = wsRequests.find( + (request) => + request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && + request.type === "project.create", + ) as + | { + _tag: string; + type?: string; + workspaceRoot?: string; + title?: string; + createWorkspaceRootIfMissing?: boolean; + } + | undefined; + + expect(dispatchRequest).toMatchObject({ + _tag: ORCHESTRATION_WS_METHODS.dispatchCommand, + type: "project.create", + workspaceRoot: "~/Desktop/fresh-project", + title: "fresh-project", + createWorkspaceRootIfMissing: true, + }); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("does not show create affordances for an existing directory with a trailing slash", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-command-palette-existing-trailing-directory" as MessageId, + targetText: "command palette existing trailing directory", + }), + resolveRpc: (body) => { + if (body._tag === WS_METHODS.filesystemBrowse) { + if (body.partialPath === "~/Development/codex/") { + return { + parentPath: "~/Development/codex/", + entries: [{ name: "Codex.app", fullPath: "~/Development/codex/Codex.app" }], + }; + } + + return { + parentPath: "~/", + entries: [{ name: "Development", fullPath: "~/Development" }], + }; + } + + if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { + return { + sequence: fixture.snapshot.snapshotSequence + 1, + }; + } + + return undefined; + }, + }); + + try { + await waitForServerConfigToApply(); + const palette = page.getByTestId("command-palette"); + await page.getByTestId("sidebar-add-project-trigger").click(); + + await expect.element(palette).toBeInTheDocument(); + const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER); + await page.getByPlaceholder(ADD_PROJECT_SUBMENU_PLACEHOLDER).fill("~/Development/codex/"); + + await vi.waitFor( + () => { + expect( + wsRequests.some( + (request) => + request._tag === WS_METHODS.filesystemBrowse && + request.partialPath === "~/Development/codex/", + ), + ).toBe(true); + }, + { timeout: 8_000, interval: 16 }, + ); + + await expect + .element(palette.getByRole("button", { name: "Add (Enter)" })) + .toBeInTheDocument(); + await expect + .element(palette.getByRole("button", { name: "Create & Add (Enter)" })) + .not.toBeInTheDocument(); + + await dispatchInputKey(browseInput, { key: "Enter" }); + + await vi.waitFor( + () => { + const dispatchRequest = wsRequests.find( + (request) => + request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && + request.type === "project.create", + ) as + | { + _tag: string; + type?: string; + workspaceRoot?: string; + title?: string; + } + | undefined; + + expect(dispatchRequest).toMatchObject({ + _tag: ORCHESTRATION_WS_METHODS.dispatchCommand, + type: "project.create", + workspaceRoot: "~/Development/codex", + title: "codex", + }); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("selects an environment before browsing when multiple environments are available", async () => { + const remoteBrowseMock = vi.fn(async ({ partialPath }: { partialPath: string }) => { + if (partialPath === "~/workspaces/") { + return { + parentPath: "~/workspaces/", + entries: [{ name: "codething", fullPath: "~/workspaces/codething" }], + }; + } + + return { + parentPath: "~/", + entries: [{ name: "workspaces", fullPath: "~/workspaces" }], + }; + }); + const remoteDispatchMock = vi.fn(async () => ({ + sequence: fixture.snapshot.snapshotSequence + 1, + })); + + __setEnvironmentApiOverrideForTests( + REMOTE_ENVIRONMENT_ID, + createMockEnvironmentApi({ + browse: remoteBrowseMock, + dispatchCommand: remoteDispatchMock, + }), + ); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-command-palette-add-project-multi-env" as MessageId, + targetText: "command palette add project multi env", + }), + }); + + try { + await waitForServerConfigToApply(); + useSavedEnvironmentRegistryStore.getState().upsert({ + environmentId: REMOTE_ENVIRONMENT_ID, + label: "Staging", + httpBaseUrl: "https://staging.example.test", + wsBaseUrl: "wss://staging.example.test/ws", + createdAt: NOW_ISO, + lastConnectedAt: NOW_ISO, + }); + useSavedEnvironmentRuntimeStore.getState().patch(REMOTE_ENVIRONMENT_ID, { + connectionState: "connected", + authState: "authenticated", + descriptor: { + ...fixture.serverConfig.environment, + environmentId: REMOTE_ENVIRONMENT_ID, + label: "Staging", + }, + serverConfig: { + ...fixture.serverConfig, + environment: { + ...fixture.serverConfig.environment, + environmentId: REMOTE_ENVIRONMENT_ID, + label: "Staging", + }, + settings: { + ...fixture.serverConfig.settings, + addProjectBaseDirectory: "~/workspaces", + }, + }, + connectedAt: NOW_ISO, + }); + + const palette = page.getByTestId("command-palette"); + await openCommandPaletteFromTrigger(); + + await expect.element(palette).toBeInTheDocument(); + await palette.getByText("Add project", { exact: true }).click(); + await expect.element(palette.getByText("Environments", { exact: true })).toBeInTheDocument(); + await expect + .element(palette.getByText("This device", { exact: true }).first()) + .toBeInTheDocument(); + await palette.getByText("Staging", { exact: true }).click(); + + const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER); + await expect.element(browseInput).toHaveValue("~/workspaces/"); + + await vi.waitFor( + () => { + expect(remoteBrowseMock).toHaveBeenCalledWith({ partialPath: "~/workspaces/" }); + }, + { timeout: 8_000, interval: 16 }, + ); + + await page.getByPlaceholder(ADD_PROJECT_SUBMENU_PLACEHOLDER).fill("~/workspaces/"); + await vi.waitFor( + () => { + expect(remoteBrowseMock).toHaveBeenCalledWith({ partialPath: "~/workspaces/" }); + }, + { timeout: 8_000, interval: 16 }, + ); + await expect.element(palette.getByText("codething", { exact: true })).toBeInTheDocument(); + await expect + .element(palette.getByRole("button", { name: "Add (Enter)" })) + .toBeInTheDocument(); + + await dispatchInputKey(browseInput, { key: "Enter" }); + + await vi.waitFor( + () => { + expect(remoteDispatchMock).toHaveBeenCalledWith( + expect.objectContaining({ + type: "project.create", + workspaceRoot: "~/workspaces", + title: "workspaces", + }), + ); + }, + { timeout: 8_000, interval: 16 }, + ); + + await waitForURL( + mounted.router, + (path) => UUID_ROUTE_RE.test(path), + "Route should have changed to a new draft thread after adding a remote project.", + ); + } finally { + await mounted.cleanup(); + } + }); + + it("picks a local project from the native file manager", async () => { + const pickFolder = vi.fn().mockResolvedValue("/Users/julius/Projects/finder-picked"); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-command-palette-add-project-file-manager" as MessageId, + targetText: "command palette add project file manager", + }), + resolveRpc: (body) => { + if (body._tag === WS_METHODS.filesystemBrowse) { + if (body.partialPath === "~/Applications/") { + return { + parentPath: "~/Applications/", + entries: [{ name: "Utilities", fullPath: "~/Applications/Utilities" }], + }; + } + + return { + parentPath: "~/", + entries: [{ name: "Applications", fullPath: "~/Applications" }], + }; + } + + if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { + return { + sequence: fixture.snapshot.snapshotSequence + 1, + }; + } + + return undefined; + }, + }); + + try { + await waitForServerConfigToApply(); + window.desktopBridge = { + pickFolder, + setTheme: vi.fn().mockResolvedValue(undefined), + } as unknown as NonNullable; + + await page.getByTestId("sidebar-add-project-trigger").click(); + + const palette = page.getByTestId("command-palette"); + await expect.element(palette).toBeInTheDocument(); + const browseInput = palette.getByPlaceholder(ADD_PROJECT_SUBMENU_PLACEHOLDER); + await browseInput.fill("~/Applications/access"); + + const fileManagerLabel = isMacPlatform(navigator.platform) + ? "Open in Finder" + : navigator.platform.toLowerCase().startsWith("win") + ? "Open in Explorer" + : "Open in Files"; + await palette.getByRole("button", { name: fileManagerLabel }).click(); + + await vi.waitFor( + () => { + expect(pickFolder).toHaveBeenCalledWith({ initialPath: "~/Applications" }); + }, + { timeout: 8_000, interval: 16 }, + ); + + await vi.waitFor( + () => { + const dispatchRequest = wsRequests.find( + (request) => + request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && + request.type === "project.create", + ) as + | { + _tag: string; + type?: string; + workspaceRoot?: string; + title?: string; + } + | undefined; + + expect(dispatchRequest).toMatchObject({ + _tag: ORCHESTRATION_WS_METHODS.dispatchCommand, + type: "project.create", + workspaceRoot: "/Users/julius/Projects/finder-picked", + title: "finder-picked", + }); + }, + { timeout: 8_000, interval: 16 }, + ); + + await waitForURL( + mounted.router, + (path) => UUID_ROUTE_RE.test(path), + "Route should have changed to a new draft thread after adding a project from the native file manager.", + ); + } finally { + await mounted.cleanup(); + } + }); + + it("adds a project from browse mode with Mod+Enter when a directory is highlighted", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-command-palette-add-project-mod-enter" as MessageId, + targetText: "command palette add project mod enter", + }), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + keybindings: [ + { + command: "commandPalette.toggle", + shortcut: { + key: "k", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }, + whenAst: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, + }, + }, + ], + }; + }, + resolveRpc: (body) => { + if (body._tag === WS_METHODS.filesystemBrowse) { + if (body.partialPath === "~/Development/") { + return { + parentPath: "~/Development/", + entries: [ + { name: "alpha", fullPath: "~/Development/alpha" }, + { name: "beta", fullPath: "~/Development/beta" }, + ], + }; + } + + return { + parentPath: "~/", + entries: [{ name: "Development", fullPath: "~/Development" }], + }; + } + + if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { + return { + sequence: fixture.snapshot.snapshotSequence + 1, + }; + } + + return undefined; + }, + }); + + try { + await waitForServerConfigToApply(); + await waitForCommandPaletteShortcutLabel(); + const palette = page.getByTestId("command-palette"); + await openCommandPaletteFromTrigger(); + + await expect.element(palette).toBeInTheDocument(); + await palette.getByText("Add project", { exact: true }).click(); + + const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER); + await page.getByPlaceholder(ADD_PROJECT_SUBMENU_PLACEHOLDER).fill("~/Development/"); + await expect.element(palette.getByText("alpha", { exact: true })).toBeInTheDocument(); + + await dispatchInputKey(browseInput, { key: "ArrowDown" }); + + const addButtonLabel = isMacPlatform(navigator.platform) + ? "Add (\u2318 Enter)" + : "Add (Ctrl Enter)"; + await vi.waitFor( + () => { + const legendEntries = getCommandPaletteLegendEntries(); + expect(legendEntries).toContain("Enter Select"); + }, + { timeout: 8_000, interval: 16 }, + ); + await expect + .element(palette.getByRole("button", { name: addButtonLabel })) + .toBeInTheDocument(); + + await dispatchInputKey(browseInput, { + key: "Enter", + metaKey: isMacPlatform(navigator.platform), + ctrlKey: !isMacPlatform(navigator.platform), + }); + + await vi.waitFor( + () => { + const dispatchRequest = wsRequests.find( + (request) => + request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && + request.type === "project.create", + ) as + | { + _tag: string; + type?: string; + workspaceRoot?: string; + title?: string; + } + | undefined; + + expect(dispatchRequest).toMatchObject({ + _tag: ORCHESTRATION_WS_METHODS.dispatchCommand, + type: "project.create", + workspaceRoot: "~/Development", + title: "Development", + }); + }, + { timeout: 8_000, interval: 16 }, + ); + + await waitForURL( + mounted.router, + (path) => UUID_ROUTE_RE.test(path), + "Route should have changed to a new draft thread after adding a project with Mod+Enter.", + ); + } finally { + await mounted.cleanup(); + } + }); + it("keeps project-context thread matches available when searching by project name", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 70d8e55b123..20c3d396fa0 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -11,6 +11,7 @@ import { deriveComposerSendState, hasServerAcknowledgedLocalDispatch, reconcileMountedTerminalThreadIds, + resolveSendEnvMode, shouldWriteThreadErrorToCurrentServerThread, waitForStartedServerThread, } from "./ChatView.logic"; @@ -82,6 +83,17 @@ describe("buildExpiredTerminalContextToastCopy", () => { }); }); +describe("resolveSendEnvMode", () => { + it("keeps worktree mode for git repositories", () => { + expect(resolveSendEnvMode({ requestedEnvMode: "worktree", isGitRepo: true })).toBe("worktree"); + }); + + it("forces local mode for non-git repositories", () => { + expect(resolveSendEnvMode({ requestedEnvMode: "worktree", isGitRepo: false })).toBe("local"); + expect(resolveSendEnvMode({ requestedEnvMode: "local", isGitRepo: false })).toBe("local"); + }); +}); + describe("reconcileMountedTerminalThreadIds", () => { it("keeps previously mounted open threads and adds the active open thread", () => { expect( diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 512811724d6..b6c7804ddb9 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -16,7 +16,11 @@ import { type ThreadSession, } from "../types"; import { randomUUID } from "~/lib/utils"; -import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; +import { + type ComposerImageAttachment, + type DraftThreadEnvMode, + type DraftThreadState, +} from "../composerDraftStore"; import { Schema } from "effect"; import { selectThreadByRef, selectThreadsAcrossEnvironments, useStore } from "../store"; import { @@ -168,10 +172,11 @@ export function readFileAsDataUrl(file: File): Promise { }); } -export function buildTemporaryWorktreeBranchName(): string { - // Keep the 8-hex suffix shape for backend temporary-branch detection. - const token = randomUUID().slice(0, 8).toLowerCase(); - return `${WORKTREE_BRANCH_PREFIX}/${token}`; +export function resolveSendEnvMode(input: { + requestedEnvMode: DraftThreadEnvMode; + isGitRepo: boolean; +}): DraftThreadEnvMode { + return input.isGitRepo ? input.requestedEnvMode : "local"; } export function cloneComposerImageForRetry( @@ -347,23 +352,37 @@ export function hasServerAcknowledgedLocalDispatch(input: { if (!input.localDispatch) { return false; } - if ( - input.phase === "running" || - input.hasPendingApproval || - input.hasPendingUserInput || - Boolean(input.threadError) - ) { + if (input.hasPendingApproval || input.hasPendingUserInput || Boolean(input.threadError)) { return true; } const latestTurn = input.latestTurn ?? null; const session = input.session ?? null; - - return ( + const latestTurnChanged = input.localDispatch.latestTurnTurnId !== (latestTurn?.turnId ?? null) || input.localDispatch.latestTurnRequestedAt !== (latestTurn?.requestedAt ?? null) || input.localDispatch.latestTurnStartedAt !== (latestTurn?.startedAt ?? null) || - input.localDispatch.latestTurnCompletedAt !== (latestTurn?.completedAt ?? null) || + input.localDispatch.latestTurnCompletedAt !== (latestTurn?.completedAt ?? null); + + if (input.phase === "running") { + if (!latestTurnChanged) { + return false; + } + if (latestTurn?.startedAt === null || latestTurn === null) { + return false; + } + if ( + session?.activeTurnId !== undefined && + session.activeTurnId !== null && + latestTurn?.turnId !== session.activeTurnId + ) { + return false; + } + return true; + } + + return ( + latestTurnChanged || input.localDispatch.sessionOrchestrationStatus !== (session?.orchestrationStatus ?? null) || input.localDispatch.sessionUpdatedAt !== (session?.updatedAt ?? null) ); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f01e45511f2..c589acc7d6f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -45,11 +45,12 @@ import { useState, } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { useDebouncedValue } from "@tanstack/react-pacer"; +import { Debouncer, useDebouncedValue } from "@tanstack/react-pacer"; import { useNavigate, useSearch } from "@tanstack/react-router"; import { useShallow } from "zustand/react/shallow"; import { useGitStatus } from "~/lib/gitStatusState"; import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; +import { isScrollContainerNearBottom } from "../chat-scroll"; import { usePrimaryEnvironmentId } from "../environments/primary"; import { readEnvironmentApi } from "../environmentApi"; import { getServerHttpOrigin, isElectron } from "../env"; @@ -75,14 +76,12 @@ import { findSidebarProposedPlan, findLatestProposedPlan, deriveWorkLogEntries, - deriveTodoItems, hasActionableProposedPlan, hasToolActivityForTurn, isLatestTurnSettled, formatElapsed, type AgentTaskSummary, } from "../session-logic"; -import { isScrollContainerNearBottom } from "../chat-scroll"; import { buildPendingUserInputAnswers, derivePendingUserInputProgress, @@ -130,6 +129,9 @@ import { basenameOfPath } from "../vscode-icons"; import { useTheme } from "../hooks/useTheme"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import { useCommandPaletteStore } from "../commandPaletteStore"; +import { buildTemporaryWorktreeBranchName } from "@marcode/shared/git"; +import { useMediaQuery } from "../hooks/useMediaQuery"; +import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; import { BranchToolbar } from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import PlanSidebar from "./PlanSidebar"; @@ -165,7 +167,7 @@ import { import { useSettings } from "../hooks/useSettings"; import { resolveAppModelSelection } from "../modelSelection"; import { isTerminalFocused } from "../lib/terminalFocus"; -import { deriveLogicalProjectKey } from "../logicalProject"; +import { deriveLogicalProjectKeyFromSettings } from "../logicalProject"; import { useSavedEnvironmentRegistryStore, useSavedEnvironmentRuntimeStore, @@ -234,7 +236,6 @@ import { ComposerPrimaryActions } from "./chat/ComposerPrimaryActions"; import { ComposerPendingApprovalPanel } from "./chat/ComposerPendingApprovalPanel"; import { ComposerPendingUserInputPanel } from "./chat/ComposerPendingUserInputPanel"; import { ComposerPlanFollowUpBanner } from "./chat/ComposerPlanFollowUpBanner"; -import { ComposerTodoListPanel } from "./chat/ComposerActiveTasksPanel"; import { SubagentDetailDrawer } from "./chat/SubagentDetailDrawer"; import { getComposerProviderState, @@ -250,7 +251,6 @@ import { MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, buildExpiredTerminalContextToastCopy, buildLocalDraftThread, - buildTemporaryWorktreeBranchName, cloneComposerImageForRetry, collectUserMessageBlobPreviewUrls, createLocalDispatchSnapshot, @@ -264,6 +264,7 @@ import { PullRequestDialogState, readFileAsDataUrl, reconcileMountedTerminalThreadIds, + resolveSendEnvMode, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, shouldWriteThreadErrorToCurrentServerThread, @@ -279,6 +280,8 @@ import { useServerKeybindings, } from "~/rpc/serverState"; import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; +import { retainThreadDetailSubscription } from "../environments/runtime/service"; +import { RightPanelSheet } from "./RightPanelSheet"; const ATTACHMENT_PREVIEW_HANDOFF_TTL_MS = 5000; const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; @@ -459,10 +462,23 @@ interface SubmitComposerTurnInput { clearComposerDraft: boolean; } -interface ChatViewProps { - threadId: ThreadId; - environmentId?: EnvironmentId; -} +type ChatViewProps = + | { + environmentId: EnvironmentId; + threadId: ThreadId; + onDiffPanelOpen?: () => void; + reserveTitleBarControlInset?: boolean; + routeKind: "server"; + draftId?: never; + } + | { + environmentId: EnvironmentId; + threadId: ThreadId; + onDiffPanelOpen?: () => void; + reserveTitleBarControlInset?: boolean; + routeKind: "draft"; + draftId: DraftId; + }; interface TerminalLaunchContext { threadId: ThreadId; @@ -722,31 +738,42 @@ function PersistentThreadTerminalDrawer({ ); } -export default function ChatView({ threadId, environmentId: environmentIdProp }: ChatViewProps) { +export default function ChatView(props: ChatViewProps) { + const { + environmentId, + threadId, + routeKind, + onDiffPanelOpen, + reserveTitleBarControlInset = true, + } = props; const { isMobile, state: sidebarState } = useSidebar(); const sidebarVisible = !isMobile && sidebarState === "expanded"; - const primaryEnvironmentId = usePrimaryEnvironmentId(); - const serverThread = useThreadById(threadId); - const activeThreadEnvironmentId = - serverThread?.environmentId ?? environmentIdProp ?? primaryEnvironmentId; - const threadRef: ScopedThreadRef = useMemo( - () => - activeThreadEnvironmentId - ? scopeThreadRef(activeThreadEnvironmentId, threadId) - : scopeThreadRef("" as EnvironmentId, threadId), - [activeThreadEnvironmentId, threadId], + const draftId = routeKind === "draft" ? props.draftId : null; + const routeThreadRef = useMemo( + () => scopeThreadRef(environmentId, threadId), + [environmentId, threadId], + ); + const routeThreadKey = useMemo(() => scopedThreadKey(routeThreadRef), [routeThreadRef]); + const composerDraftTarget: ScopedThreadRef | DraftId = + routeKind === "server" ? routeThreadRef : props.draftId; + const threadRef: ScopedThreadRef = routeThreadRef; + const activeThreadEnvironmentId = environmentId; + const serverThread = useStore( + useMemo( + () => createThreadSelectorByRef(routeKind === "server" ? routeThreadRef : null), + [routeKind, routeThreadRef], + ), ); const setStoreThreadError = useStore((store) => store.setError); const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); - const activeThreadLastVisitedAt = useUiStateStore( - (store) => store.threadLastVisitedAtById[threadId], + const activeThreadLastVisitedAt = useUiStateStore((store) => + routeKind === "server" ? store.threadLastVisitedAtById[routeThreadKey] : undefined, ); const settings = useSettings(); const setStickyComposerModelSelection = useComposerDraftStore( (store) => store.setStickyModelSelection, ); const timestampFormat = settings.timestampFormat; - const showTodosInComposer = settings.showTodosInComposer; const navigate = useNavigate(); const queryClient = useQueryClient(); const rawSearch = useSearch({ @@ -820,6 +847,13 @@ export default function ChatView({ threadId, environmentId: environmentIdProp }: const getDraftThreadByProjectRef = useComposerDraftStore( (store) => store.getDraftThreadByProjectRef, ); + const getDraftSessionByLogicalProjectKey = useComposerDraftStore( + (store) => store.getDraftSessionByLogicalProjectKey, + ); + const getDraftSession = useComposerDraftStore((store) => store.getDraftSession); + const setLogicalProjectDraftThreadId = useComposerDraftStore( + (store) => store.setLogicalProjectDraftThreadId, + ); const getDraftThread = useComposerDraftStore((store) => store.getDraftThread); const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId); const clearProjectDraftThreadId = useComposerDraftStore( @@ -872,6 +906,7 @@ export default function ChatView({ threadId, environmentId: environmentIdProp }: const [planSidebarOpen, setPlanSidebarOpen] = useState(false); const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false); const [isComposerPrimaryActionsCompact, setIsComposerPrimaryActionsCompact] = useState(false); + const shouldUsePlanSidebarSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); // Tracks whether the user explicitly dismissed the sidebar for the active turn. const planSidebarDismissedForTurnRef = useRef(null); // When set, the thread-change reset effect will open the sidebar instead of closing it. @@ -895,6 +930,9 @@ export default function ChatView({ threadId, environmentId: environmentIdProp }: const [composerTrigger, setComposerTrigger] = useState(() => detectComposerTrigger(prompt, prompt.length), ); + const [pendingServerThreadEnvMode, setPendingServerThreadEnvMode] = + useState(null); + const [pendingServerThreadBranch, setPendingServerThreadBranch] = useState(); const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useLocalStorage( LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, {}, @@ -928,6 +966,7 @@ export default function ChatView({ threadId, environmentId: environmentIdProp }: const composerMenuOpenRef = useRef(false); const composerMenuItemsRef = useRef([]); const activeComposerMenuItemRef = useRef(null); + const isAtEndRef = useRef(true); const attachmentPreviewHandoffByMessageIdRef = useRef>({}); const attachmentPreviewHandoffTimeoutByMessageIdRef = useRef>({}); const onSendRef = useRef<((e?: { preventDefault: () => void }) => Promise) | null>(null); @@ -1139,6 +1178,71 @@ export default function ChatView({ threadId, environmentId: environmentIdProp }: ); const activeProject = useProjectById(activeThread?.projectId); + useEffect(() => { + if (routeKind !== "server") { + return; + } + return retainThreadDetailSubscription(environmentId, threadId); + }, [environmentId, routeKind, threadId]); + + // Compute the list of environments this logical project spans, used to + // drive the environment picker in BranchToolbar. + const allProjects = useStore(useShallow(selectProjectsAcrossEnvironments)); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((s) => s.byId); + const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((s) => s.byId); + const projectGroupingSettings = useSettings((settings) => ({ + sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode, + sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides, + })); + const logicalProjectEnvironments = useMemo(() => { + if (!activeProject) return []; + const logicalKey = deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings); + const memberProjects = allProjects.filter( + (p) => deriveLogicalProjectKeyFromSettings(p, projectGroupingSettings) === logicalKey, + ); + const seen = new Set(); + const envs: Array<{ + environmentId: EnvironmentId; + projectId: ProjectId; + label: string; + isPrimary: boolean; + }> = []; + for (const p of memberProjects) { + if (seen.has(p.environmentId)) continue; + seen.add(p.environmentId); + const isPrimary = p.environmentId === primaryEnvironmentId; + const savedRecord = savedEnvironmentRegistry[p.environmentId]; + const runtimeState = savedEnvironmentRuntimeById[p.environmentId]; + const label = resolveEnvironmentOptionLabel({ + isPrimary, + environmentId: p.environmentId, + runtimeLabel: runtimeState?.descriptor?.label ?? null, + savedLabel: savedRecord?.label ?? null, + }); + envs.push({ + environmentId: p.environmentId, + projectId: p.id, + label, + isPrimary, + }); + } + // Sort: primary first, then alphabetical + envs.sort((a, b) => { + if (a.isPrimary !== b.isPrimary) return a.isPrimary ? -1 : 1; + return a.label.localeCompare(b.label); + }); + return envs; + }, [ + activeProject, + allProjects, + projectGroupingSettings, + primaryEnvironmentId, + savedEnvironmentRegistry, + savedEnvironmentRuntimeById, + ]); + const hasMultipleEnvironments = logicalProjectEnvironments.length > 1; + const openPullRequestDialog = useCallback( (reference?: string) => { if (!canCheckoutPullRequestIntoThread) { @@ -1159,60 +1263,78 @@ export default function ChatView({ threadId, environmentId: environmentIdProp }: const openOrReuseProjectDraftThread = useCallback( async (input: { branch: string; worktreePath: string | null; envMode: DraftThreadEnvMode }) => { - if (!activeProject || !activeThreadEnvironmentId) { + if (!activeProject) { throw new Error("No active project is available for this pull request."); } - const storedDraftThread = getDraftThreadByProjectRef( - scopeProjectRef(activeThreadEnvironmentId, activeProject.id), + const activeProjectRef = scopeProjectRef(activeProject.environmentId, activeProject.id); + const logicalProjectKey = deriveLogicalProjectKeyFromSettings( + activeProject, + projectGroupingSettings, ); - const projectRef = scopeProjectRef(activeThreadEnvironmentId!, activeProject.id); - if (storedDraftThread) { - const storedRef = scopeThreadRef( - storedDraftThread.environmentId, - storedDraftThread.threadId, + const storedDraftSession = getDraftSessionByLogicalProjectKey(logicalProjectKey); + if (storedDraftSession) { + setDraftThreadContext(storedDraftSession.draftId, input); + setLogicalProjectDraftThreadId( + logicalProjectKey, + activeProjectRef, + storedDraftSession.draftId, + { + threadId: storedDraftSession.threadId, + ...input, + }, ); - setDraftThreadContext(storedRef, input); - setProjectDraftThreadId(projectRef, DraftId.make(storedDraftThread.threadId), input); - if (storedDraftThread.threadId !== threadId) { + if (routeKind !== "draft" || draftId !== storedDraftSession.draftId) { await navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(storedRef), + to: "/draft/$draftId", + params: buildDraftThreadRouteParams(storedDraftSession.draftId), }); } - return storedDraftThread.threadId; + return storedDraftSession.threadId; } - const activeDraftThread = getDraftThread(threadRef); - if (!isServerThread && activeDraftThread?.projectId === activeProject.id) { - setDraftThreadContext(threadRef, input); - setProjectDraftThreadId(projectRef, DraftId.make(threadId), input); - return 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; } - clearProjectDraftThreadId(projectRef); + const nextDraftId = newDraftId(); const nextThreadId = newThreadId(); - setProjectDraftThreadId(projectRef, DraftId.make(nextThreadId), { + setLogicalProjectDraftThreadId(logicalProjectKey, activeProjectRef, nextDraftId, { + threadId: nextThreadId, createdAt: new Date().toISOString(), runtimeMode: DEFAULT_RUNTIME_MODE, interactionMode: DEFAULT_INTERACTION_MODE, ...input, }); await navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(scopeThreadRef(activeThreadEnvironmentId!, nextThreadId)), + to: "/draft/$draftId", + params: buildDraftThreadRouteParams(nextDraftId), }); return nextThreadId; }, [ activeProject, - clearProjectDraftThreadId, - getDraftThread, - getDraftThreadByProjectRef, + draftId, + getDraftSession, + getDraftSessionByLogicalProjectKey, isServerThread, navigate, + projectGroupingSettings, + routeKind, setDraftThreadContext, - setProjectDraftThreadId, - threadId, + setLogicalProjectDraftThreadId, ], ); @@ -1294,20 +1416,13 @@ export default function ChatView({ threadId, environmentId: environmentIdProp }: const phase = derivePhase(activeThread?.session ?? null); const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; const timelineThreadActivities = timelineThread?.activities ?? EMPTY_ACTIVITIES; - const activeTodoItems = useMemo( - () => - showTodosInComposer - ? deriveTodoItems(threadActivities, activeLatestTurn?.turnId ?? undefined) - : [], - [showTodosInComposer, threadActivities, activeLatestTurn?.turnId], - ); const workLogEntries = useMemo( () => deriveWorkLogEntries(timelineThreadActivities, timelineLatestTurn?.turnId ?? undefined, { - excludeTodoToolCalls: showTodosInComposer, + excludeTodoToolCalls: true, isSessionRunning: phase === "running", }), - [timelineLatestTurn?.turnId, timelineThreadActivities, showTodosInComposer, phase], + [timelineLatestTurn?.turnId, timelineThreadActivities, phase], ); const timelineLatestTurnHasToolActivity = useMemo( () => hasToolActivityForTurn(timelineThreadActivities, timelineLatestTurn?.turnId), @@ -1381,6 +1496,7 @@ export default function ChatView({ threadId, environmentId: environmentIdProp }: () => deriveActivePlanState(threadActivities, activeLatestTurn?.turnId ?? undefined), [activeLatestTurn?.turnId, threadActivities], ); + const planSidebarLabel = sidebarProposedPlan || interactionMode === "plan" ? "Plan" : "Tasks"; const showPlanFollowUpPrompt = pendingUserInputs.length === 0 && interactionMode === "plan" && @@ -1413,8 +1529,7 @@ export default function ChatView({ threadId, environmentId: environmentIdProp }: const hasComposerHeader = isComposerApprovalState || pendingUserInputs.length > 0 || - (showPlanFollowUpPrompt && activeProposedPlan !== null) || - activeTodoItems.length > 0; + (showPlanFollowUpPrompt && activeProposedPlan !== null); const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null; const composerFooterActionLayoutKey = useMemo(() => { if (activePendingProgress) { @@ -2320,16 +2435,19 @@ export default function ChatView({ threadId, environmentId: environmentIdProp }: const togglePlanSidebar = useCallback(() => { setPlanSidebarOpen((open) => { if (open) { - const turnKey = activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? null; - if (turnKey) { - planSidebarDismissedForTurnRef.current = turnKey; - } + planSidebarDismissedForTurnRef.current = + activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__"; } else { planSidebarDismissedForTurnRef.current = null; } return !open; }); }, [activePlan?.turnId, sidebarProposedPlan?.turnId]); + const closePlanSidebar = useCallback(() => { + setPlanSidebarOpen(false); + planSidebarDismissedForTurnRef.current = + activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__"; + }, [activePlan?.turnId, sidebarProposedPlan?.turnId]); const persistThreadSettingsForNextTurn = useCallback( async (input: { @@ -2395,6 +2513,27 @@ export default function ChatView({ threadId, environmentId: environmentIdProp }: lastKnownScrollTopRef.current = scrollContainer.scrollTop; shouldAutoScrollRef.current = true; }, []); + const scrollToEnd = useCallback( + (animated = false) => { + scrollMessagesToBottom(animated ? "smooth" : "auto"); + }, + [scrollMessagesToBottom], + ); + // Legacy compatibility shims for upstream scroll API; MarCode uses native + // scroll in MessagesTimeline so these are thin wrappers. + const showScrollDebouncer = useRef( + new Debouncer(() => setShowScrollToBottom(true), { wait: 150 }), + ); + const onIsAtEndChange = useCallback((isAtEnd: boolean) => { + if (isAtEndRef.current === isAtEnd) return; + isAtEndRef.current = isAtEnd; + if (isAtEnd) { + showScrollDebouncer.current.cancel(); + setShowScrollToBottom(false); + } else { + showScrollDebouncer.current.maybeExecute(); + } + }, []); const cancelPendingStickToBottom = useCallback(() => { const pendingFrame = pendingAutoScrollFrameRef.current; if (pendingFrame === null) return; @@ -2697,6 +2836,9 @@ export default function ChatView({ threadId, environmentId: environmentIdProp }: setExpandedWorkGroups({}); setSelectedSubagentTaskId(null); setPullRequestDialogState(null); + isAtEndRef.current = true; + showScrollDebouncer.current.cancel(); + setShowScrollToBottom(false); if (planSidebarOpenOnNextThreadRef.current) { planSidebarOpenOnNextThreadRef.current = false; setPlanSidebarOpen(true); @@ -2706,6 +2848,18 @@ export default function ChatView({ threadId, environmentId: environmentIdProp }: planSidebarDismissedForTurnRef.current = null; }, [activeThread?.id]); + // Auto-open the plan sidebar when plan/todo steps arrive for the current turn. + // Don't auto-open for plans carried over from a previous turn (the user can open manually). + useEffect(() => { + if (!activePlan) return; + if (planSidebarOpen) return; + const latestTurnId = activeLatestTurn?.turnId ?? null; + if (latestTurnId && activePlan.turnId !== latestTurnId) return; + const turnKey = activePlan.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__"; + if (planSidebarDismissedForTurnRef.current === turnKey) return; + setPlanSidebarOpen(true); + }, [activePlan, activeLatestTurn?.turnId, planSidebarOpen, sidebarProposedPlan?.turnId]); + useEffect(() => { if (!composerMenuOpen) { setComposerHighlightedItemId(null); @@ -2910,12 +3064,43 @@ export default function ChatView({ threadId, environmentId: environmentIdProp }: return () => window.removeEventListener("keydown", onKeyDown); }, [closeExpandedImage, expandedImage, navigateExpandedImage]); - const activeWorktreePath = activeThread?.worktreePath; - const envMode: DraftThreadEnvMode = activeWorktreePath - ? "worktree" - : isLocalDraftThread - ? (draftThread?.envMode ?? "local") - : "local"; + const activeWorktreePath = activeThread?.worktreePath ?? null; + const derivedEnvMode: DraftThreadEnvMode = resolveEffectiveEnvMode({ + activeWorktreePath, + hasServerThread: isServerThread, + draftThreadEnvMode: isLocalDraftThread ? draftThread?.envMode : undefined, + }); + const canOverrideServerThreadEnvMode = Boolean( + isServerThread && + activeThread && + activeThread.messages.length === 0 && + activeThread.worktreePath === null && + !envLocked, + ); + const envMode: DraftThreadEnvMode = canOverrideServerThreadEnvMode + ? (pendingServerThreadEnvMode ?? draftThread?.envMode ?? derivedEnvMode) + : derivedEnvMode; + const activeThreadBranch = + canOverrideServerThreadEnvMode && pendingServerThreadBranch !== undefined + ? pendingServerThreadBranch + : (activeThread?.branch ?? null); + const sendEnvMode = resolveSendEnvMode({ + requestedEnvMode: envMode, + isGitRepo, + }); + + useEffect(() => { + setPendingServerThreadEnvMode(null); + setPendingServerThreadBranch(undefined); + }, [activeThread?.id]); + + useEffect(() => { + if (canOverrideServerThreadEnvMode) { + return; + } + setPendingServerThreadEnvMode(null); + setPendingServerThreadBranch(undefined); + }, [canOverrideServerThreadEnvMode]); useEffect(() => { if (!activeThreadId) { @@ -3310,13 +3495,13 @@ export default function ChatView({ threadId, environmentId: environmentIdProp }: const threadIdForSend = activeThread.id; const isFirstMessage = !isServerThread || activeThread.messages.length === 0; const baseBranchForWorktree = - isFirstMessage && envMode === "worktree" && !activeThread.worktreePath - ? activeThread.branch + isFirstMessage && sendEnvMode === "worktree" && !activeThread.worktreePath + ? activeThreadBranch : null; const shouldCreateWorktree = - isFirstMessage && envMode === "worktree" && !activeThread.worktreePath; - if (shouldCreateWorktree && !activeThread.branch) { + isFirstMessage && sendEnvMode === "worktree" && !activeThread.worktreePath; + if (shouldCreateWorktree && !activeThreadBranch) { setStoreThreadError( threadIdForSend, "Select a base branch before sending in New worktree mode.", @@ -3520,7 +3705,7 @@ export default function ChatView({ threadId, environmentId: environmentIdProp }: modelSelection: threadCreateModelSelection, runtimeMode, interactionMode, - branch: activeThread.branch, + branch: activeThreadBranch, worktreePath: activeThread.worktreePath, createdAt: activeThread.createdAt, }, @@ -3926,6 +4111,13 @@ export default function ChatView({ threadId, environmentId: environmentIdProp }: sendInFlightRef.current = true; beginLocalDispatch({ preparingWorktree: false }); setThreadError(threadIdForSend, null); + + // Scroll to the current end *before* adding the optimistic message. + isAtEndRef.current = true; + showScrollDebouncer.current.cancel(); + setShowScrollToBottom(false); + scrollToEnd(false); + setOptimisticUserMessages((existing) => [ ...existing, { @@ -3936,8 +4128,6 @@ export default function ChatView({ threadId, environmentId: environmentIdProp }: streaming: false, }, ]); - shouldAutoScrollRef.current = true; - forceStickToBottom(); try { await persistThreadSettingsForNextTurn({ @@ -4000,7 +4190,6 @@ export default function ChatView({ threadId, environmentId: environmentIdProp }: activeThread, activeProposedPlan, beginLocalDispatch, - forceStickToBottom, isConnecting, isSendBusy, isServerThread, @@ -4063,7 +4252,7 @@ export default function ChatView({ threadId, environmentId: environmentIdProp }: modelSelection: nextThreadModelSelection, runtimeMode, interactionMode: "default", - branch: activeThread.branch, + branch: activeThreadBranch, worktreePath: activeThread.worktreePath, createdAt, }) @@ -4119,6 +4308,7 @@ export default function ChatView({ threadId, environmentId: environmentIdProp }: }, [ activeProject, activeProposedPlan, + activeThreadBranch, activeThread, beginLocalDispatch, isConnecting, @@ -4202,15 +4392,28 @@ export default function ChatView({ threadId, environmentId: environmentIdProp }: }); const onEnvModeChange = useCallback( (mode: DraftThreadEnvMode) => { + if (canOverrideServerThreadEnvMode) { + setPendingServerThreadEnvMode(mode); + scheduleComposerFocus(); + return; + } if (isLocalDraftThread) { setDraftThreadContext(threadRef, { envMode: mode, - ...(mode === "worktree" ? { branch: null } : {}), + ...(mode === "worktree" && draftThread?.worktreePath ? { worktreePath: null } : {}), }); } scheduleComposerFocus(); }, - [isLocalDraftThread, scheduleComposerFocus, setDraftThreadContext, threadId], + [ + canOverrideServerThreadEnvMode, + draftThread?.worktreePath, + isLocalDraftThread, + scheduleComposerFocus, + setDraftThreadContext, + setPendingServerThreadEnvMode, + threadId, + ], ); const applyPromptReplacement = useCallback( @@ -4560,16 +4763,19 @@ export default function ChatView({ threadId, environmentId: environmentIdProp }: }, [navigate, threadId], ); - const onRevertUserMessage = useCallback( - (messageId: MessageId) => { - const targetTurnCount = revertTurnCountByUserMessageId.get(messageId); - if (typeof targetTurnCount !== "number") { - return; - } - void onRevertToTurnCount(targetTurnCount); - }, - [onRevertToTurnCount, revertTurnCountByUserMessageId], - ); + // Both the Map and the revert handler are read from refs at call-time so + // the callback reference is fully stable and never busts context identity. + const revertTurnCountRef = useRef(revertTurnCountByUserMessageId); + revertTurnCountRef.current = revertTurnCountByUserMessageId; + const onRevertToTurnCountRef = useRef(onRevertToTurnCount); + onRevertToTurnCountRef.current = onRevertToTurnCount; + const onRevertUserMessage = useCallback((messageId: MessageId) => { + const targetTurnCount = revertTurnCountRef.current.get(messageId); + if (typeof targetTurnCount !== "number") { + return; + } + void onRevertToTurnCountRef.current(targetTurnCount); + }, []); const discardUserMessageEditSession = useCallback(() => { setEditingUserMessageImages((prev) => { @@ -4750,7 +4956,13 @@ export default function ChatView({ threadId, environmentId: environmentIdProp }: className={cn( "border-b border-border", isElectron && !sidebarVisible ? "pr-3 sm:pr-5 pl-[90px]" : "px-3 sm:px-5", - isElectron ? "drag-region flex h-[52px] items-center" : "py-2 sm:py-3", + isElectron + ? cn( + "drag-region flex h-[52px] items-center wco:h-[env(titlebar-area-height)]", + reserveTitleBarControlInset && + "wco:pr-[calc(100vw-env(titlebar-area-width)-env(titlebar-area-x)+1em)]", + ) + : "py-2 sm:py-3", )} > - ), - } - : {})} - onKeyDown={handleKeyDown} - /> +
+ + + + ), + } + : isBrowsing && !isSubmenu + ? { + startAddon: , + } + : {})} + onKeyDown={handleKeyDown} + /> + {isBrowsing ? ( + + ) : null} +
@@ -436,10 +1141,12 @@ function OpenCommandPaletteDialog() { Navigate - - Enter - Select - + {!canSubmitBrowsePath || hasHighlightedBrowseItem ? ( + + Enter + Select + + ) : null} {isSubmenu ? ( Backspace @@ -451,6 +1158,19 @@ function OpenCommandPaletteDialog() { Close + {canOpenProjectFromFileManager ? ( + + ) : null} diff --git a/apps/web/src/components/CommandPaletteResults.tsx b/apps/web/src/components/CommandPaletteResults.tsx index 3e59149c327..1280f36ae19 100644 --- a/apps/web/src/components/CommandPaletteResults.tsx +++ b/apps/web/src/components/CommandPaletteResults.tsx @@ -14,10 +14,12 @@ import { CommandList, CommandShortcut, } from "./ui/command"; +import { cn } from "~/lib/utils"; interface CommandPaletteResultsProps { emptyStateMessage?: string; groups: ReadonlyArray; + highlightedItemValue?: string | null; isActionsOnly: boolean; keybindings: ResolvedKeybindingsConfig; onExecuteItem: (item: CommandPaletteActionItem | CommandPaletteSubmenuItem) => void; @@ -46,6 +48,7 @@ export function CommandPaletteResults(props: CommandPaletteResultsProps) { item={item} key={item.value} keybindings={props.keybindings} + isActive={props.highlightedItemValue === item.value} onExecuteItem={props.onExecuteItem} /> )} @@ -58,6 +61,7 @@ export function CommandPaletteResults(props: CommandPaletteResultsProps) { function CommandPaletteResultRow(props: { item: CommandPaletteActionItem | CommandPaletteSubmenuItem; + isActive: boolean; keybindings: ResolvedKeybindingsConfig; onExecuteItem: (item: CommandPaletteActionItem | CommandPaletteSubmenuItem) => void; }) { @@ -68,7 +72,10 @@ function CommandPaletteResultRow(props: { return ( { event.preventDefault(); }} @@ -79,14 +86,20 @@ function CommandPaletteResultRow(props: { {props.item.icon} {props.item.description ? ( - {props.item.title} + + {props.item.titleLeadingContent} + {props.item.title} + {props.item.titleTrailingContent} + {props.item.description} ) : ( - + + {props.item.titleLeadingContent} {props.item.title} + {props.item.titleTrailingContent} )} {props.item.timestamp ? ( diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 040254e459a..d4097747fd4 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -493,12 +493,6 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { ) : ( <> - {canScrollTurnStripLeft && ( -
- )} - {canScrollTurnStripRight && ( -
- )} + + + + + + { + if (!open) { + closeProjectGroupingDialog(); + } + }} + > + + + Project grouping + + {projectGroupingTarget + ? `Choose how ${projectGroupingTarget.cwd} should be grouped in the sidebar.` + : "Choose how this project should be grouped in the sidebar."} + + + +
+ Grouping rule + +
+

+ {projectGroupingSelection === "inherit" + ? projectGroupingModeDescription(projectGroupingSettings.sidebarProjectGroupingMode) + : projectGroupingModeDescription(projectGroupingSelection)} +

+
+ + + + +
+
); }); @@ -1876,13 +2191,17 @@ type SortableProjectHandleProps = Pick< function ProjectSortMenu({ projectSortOrder, threadSortOrder, + projectGroupingMode, onProjectSortOrderChange, onThreadSortOrderChange, + onProjectGroupingModeChange, }: { projectSortOrder: SidebarProjectSortOrder; threadSortOrder: SidebarThreadSortOrder; + projectGroupingMode: SidebarProjectGroupingMode; onProjectSortOrderChange: (sortOrder: SidebarProjectSortOrder) => void; onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; + onProjectGroupingModeChange: (mode: SidebarProjectGroupingMode) => void; }) { return ( @@ -1935,6 +2254,30 @@ function ProjectSortMenu({ ))} + + +
+ Group projects +
+ { + if (value === "repository" || value === "repository_path" || value === "separate") { + onProjectGroupingModeChange(value); + } + }} + > + {( + Object.entries(PROJECT_GROUPING_MODE_LABELS) as Array< + [SidebarProjectGroupingMode, string] + > + ).map(([value, label]) => ( + + {label} + + ))} + +
); @@ -2019,7 +2362,7 @@ const SidebarChromeHeader = memo(function SidebarChromeHeader({ return isElectron ? ( {wordmark} @@ -2062,21 +2405,9 @@ interface SidebarProjectsContentProps { handleDesktopUpdateButtonClick: () => void; projectSortOrder: SidebarProjectSortOrder; threadSortOrder: SidebarThreadSortOrder; + projectGroupingMode: SidebarProjectGroupingMode; updateSettings: ReturnType["updateSettings"]; - shouldShowProjectPathEntry: boolean; - handleStartAddProject: () => void; - isElectron: boolean; - isPickingFolder: boolean; - isAddingProject: boolean; - handlePickFolder: () => Promise; - addProjectInputRef: React.RefObject; - addProjectError: string | null; - newCwd: string; - setNewCwd: React.Dispatch>; - setAddProjectError: React.Dispatch>; - handleAddProject: () => void; - setAddingProject: React.Dispatch>; - canAddProject: boolean; + openAddProject: () => void; isManualProjectSorting: boolean; projectDnDSensors: ReturnType; projectCollisionDetection: CollisionDetection; @@ -2114,21 +2445,9 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( handleDesktopUpdateButtonClick, projectSortOrder, threadSortOrder, + projectGroupingMode, updateSettings, - shouldShowProjectPathEntry, - handleStartAddProject, - isElectron, - isPickingFolder, - isAddingProject, - handlePickFolder, - addProjectInputRef, - addProjectError, - newCwd, - setNewCwd, - setAddProjectError, - handleAddProject, - setAddingProject, - canAddProject, + openAddProject, isManualProjectSorting, projectDnDSensors, projectCollisionDetection, @@ -2167,26 +2486,12 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( }, [updateSettings], ); - const handleAddProjectInputChange = useCallback( - (event: React.ChangeEvent) => { - setNewCwd(event.target.value); - setAddProjectError(null); + const handleProjectGroupingModeChange = useCallback( + (groupingMode: SidebarProjectGroupingMode) => { + updateSettings({ sidebarProjectGroupingMode: groupingMode }); }, - [setAddProjectError, setNewCwd], - ); - const handleAddProjectInputKeyDown = useCallback( - (event: React.KeyboardEvent) => { - if (event.key === "Enter") handleAddProject(); - if (event.key === "Escape") { - setAddingProject(false); - setAddProjectError(null); - } - }, - [handleAddProject, setAddProjectError, setAddingProject], + [updateSettings], ); - const handleBrowseForFolderClick = useCallback(() => { - void handlePickFolder(); - }, [handlePickFolder]); return ( @@ -2245,76 +2550,29 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( } > - + - - {shouldShowProjectPathEntry ? "Cancel add project" : "Add project"} - + Add project
- {shouldShowProjectPathEntry && ( -
- {isElectron && ( - - )} -
- - -
- {addProjectError && ( -

- {addProjectError} -

- )} -
- )} {isManualProjectSorting ? ( )} - {projectsLength === 0 && !shouldShowProjectPathEntry && ( + {projectsLength === 0 && (
No projects yet
@@ -2403,7 +2661,6 @@ export default function Sidebar() { const projects = useStore(useShallow(selectProjectsAcrossEnvironments)); const sidebarThreads = useStore(useShallow(selectSidebarThreadsAcrossEnvironments)); const bootstrapComplete = useStore(selectBootstrapCompleteForActiveEnvironment); - const activeEnvironmentId = useStore((store) => store.activeEnvironmentId); const projectExpandedById = useUiStateStore((store) => store.projectExpandedById); const projectOrder = useUiStateStore((store) => store.projectOrder); const reorderProjects = useUiStateStore((store) => store.reorderProjects); @@ -2412,7 +2669,11 @@ export default function Sidebar() { const isOnSettings = pathname.startsWith("/settings"); const sidebarThreadSortOrder = useSettings((s) => s.sidebarThreadSortOrder); const sidebarProjectSortOrder = useSettings((s) => s.sidebarProjectSortOrder); - const defaultThreadEnvMode = useSettings((s) => s.defaultThreadEnvMode); + const sidebarProjectGroupingMode = useSettings((s) => s.sidebarProjectGroupingMode); + const projectGroupingSettings = useSettings((settings) => ({ + sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode, + sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides, + })); const { updateSettings } = useUpdateSettings(); const { handleNewThread } = useNewThreadHandler(); const { archiveThread, deleteThread } = useThreadActions(); @@ -2422,12 +2683,7 @@ export default function Sidebar() { }); const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; const keybindings = useServerKeybindings(); - const [addingProject, setAddingProject] = useState(false); - const [newCwd, setNewCwd] = useState(""); - const [isPickingFolder, setIsPickingFolder] = useState(false); - const [isAddingProject, setIsAddingProject] = useState(false); - const [addProjectError, setAddProjectError] = useState(null); - const addProjectInputRef = useRef(null); + const openAddProjectCommandPalette = useCommandPaletteStore((store) => store.openAddProject); const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< ReadonlySet >(() => new Set()); @@ -2439,10 +2695,7 @@ export default function Sidebar() { const selectedThreadCount = useThreadSelectionStore((s) => s.selectedThreadKeys.size); const clearSelection = useThreadSelectionStore((s) => s.clearSelection); const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); - const isLinuxDesktop = isElectron && isLinuxPlatform(navigator.platform); const platform = navigator.platform; - const shouldBrowseForProjectImmediately = isElectron && !isLinuxDesktop; - const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately; const primaryEnvironmentId = usePrimaryEnvironmentId(); const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((s) => s.byId); const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((s) => s.byId); @@ -2458,80 +2711,36 @@ export default function Sidebar() { // cross-environment grouping. Projects that share a repositoryIdentity // canonicalKey are treated as one logical project in the sidebar. const physicalToLogicalKey = useMemo(() => { - const mapping = new Map(); - for (const project of orderedProjects) { - const physicalKey = scopedProjectKey(scopeProjectRef(project.environmentId, project.id)); - mapping.set(physicalKey, deriveLogicalProjectKey(project)); - } - return mapping; - }, [orderedProjects]); + return buildPhysicalToLogicalProjectKeyMap({ + projects: orderedProjects, + settings: projectGroupingSettings, + }); + }, [orderedProjects, projectGroupingSettings]); + const projectPhysicalKeyByScopedRef = useMemo( + () => + new Map( + orderedProjects.map((project) => [ + scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), + derivePhysicalProjectKey(project), + ]), + ), + [orderedProjects], + ); const sidebarProjects = useMemo(() => { - // Group projects by logical key while preserving insertion order from - // orderedProjects. - const groupedMembers = new Map(); - for (const project of orderedProjects) { - const logicalKey = deriveLogicalProjectKey(project); - const existing = groupedMembers.get(logicalKey); - if (existing) { - existing.push(project); - } else { - groupedMembers.set(logicalKey, [project]); - } - } - - const result: SidebarProjectSnapshot[] = []; - const seen = new Set(); - for (const project of orderedProjects) { - const logicalKey = deriveLogicalProjectKey(project); - if (seen.has(logicalKey)) continue; - seen.add(logicalKey); - - const members = groupedMembers.get(logicalKey)!; - // Prefer the primary environment's project as the representative. - const representative: Project | undefined = - (primaryEnvironmentId - ? members.find((p) => p.environmentId === primaryEnvironmentId) - : undefined) ?? members[0]; - if (!representative) continue; - const hasLocal = - primaryEnvironmentId !== null && - members.some((p) => p.environmentId === primaryEnvironmentId); - const hasRemote = - primaryEnvironmentId !== null - ? members.some((p) => p.environmentId !== primaryEnvironmentId) - : false; - - const refs = members.map((p) => scopeProjectRef(p.environmentId, p.id)); - const remoteLabels = members - .filter((p) => primaryEnvironmentId !== null && p.environmentId !== primaryEnvironmentId) - .map((p) => { - const rt = savedEnvironmentRuntimeById[p.environmentId]; - const saved = savedEnvironmentRegistry[p.environmentId]; - return rt?.descriptor?.label ?? saved?.label ?? p.environmentId; - }); - const snapshot: SidebarProjectSnapshot = { - id: representative.id, - environmentId: representative.environmentId, - name: representative.name, - cwd: representative.cwd, - repositoryIdentity: representative.repositoryIdentity ?? null, - defaultModelSelection: representative.defaultModelSelection, - createdAt: representative.createdAt, - updatedAt: representative.updatedAt, - scripts: representative.scripts, - jiraBoard: representative.jiraBoard, - projectKey: logicalKey, - environmentPresence: - hasLocal && hasRemote ? "mixed" : hasRemote ? "remote-only" : "local-only", - memberProjectRefs: refs, - remoteEnvironmentLabels: remoteLabels, - }; - result.push(snapshot); - } - return result; + return buildSidebarProjectSnapshots({ + projects: orderedProjects, + settings: projectGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: (environmentId) => { + const rt = savedEnvironmentRuntimeById[environmentId]; + const saved = savedEnvironmentRegistry[environmentId]; + return rt?.descriptor?.label ?? saved?.label ?? null; + }, + }); }, [ orderedProjects, + projectGroupingSettings, primaryEnvironmentId, savedEnvironmentRegistry, savedEnvironmentRuntimeById, @@ -2559,18 +2768,22 @@ export default function Sidebar() { } const activeThread = sidebarThreadByKey.get(routeThreadKey); if (!activeThread) return null; - const physicalKey = scopedProjectKey( - scopeProjectRef(activeThread.environmentId, activeThread.projectId), - ); + const physicalKey = + projectPhysicalKeyByScopedRef.get( + scopedProjectKey(scopeProjectRef(activeThread.environmentId, activeThread.projectId)), + ) ?? scopedProjectKey(scopeProjectRef(activeThread.environmentId, activeThread.projectId)); return physicalToLogicalKey.get(physicalKey) ?? physicalKey; - }, [routeThreadKey, sidebarThreadByKey, physicalToLogicalKey]); + }, [routeThreadKey, sidebarThreadByKey, physicalToLogicalKey, projectPhysicalKeyByScopedRef]); // Group threads by logical project key so all threads from grouped projects // are displayed together. const threadsByProjectKey = useMemo(() => { const next = new Map(); for (const thread of sidebarThreads) { - const physicalKey = scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); + const physicalKey = + projectPhysicalKeyByScopedRef.get( + scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), + ) ?? scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); const logicalKey = physicalToLogicalKey.get(physicalKey) ?? physicalKey; const existing = next.get(logicalKey); if (existing) { @@ -2580,7 +2793,7 @@ export default function Sidebar() { } } return next; - }, [sidebarThreads, physicalToLogicalKey]); + }, [sidebarThreads, physicalToLogicalKey, projectPhysicalKeyByScopedRef]); const getCurrentSidebarShortcutContext = useCallback( () => ({ terminalFocus: isTerminalFocused(), @@ -2606,131 +2819,6 @@ export default function Sidebar() { const newThreadShortcutLabel = shortcutLabelForCommand(keybindings, "chat.newLocal", newThreadShortcutLabelOptions) ?? shortcutLabelForCommand(keybindings, "chat.new", newThreadShortcutLabelOptions); - const focusMostRecentThreadForProject = useCallback( - (projectRef: { environmentId: EnvironmentId; projectId: ProjectId }) => { - const physicalKey = scopedProjectKey( - scopeProjectRef(projectRef.environmentId, projectRef.projectId), - ); - const logicalKey = physicalToLogicalKey.get(physicalKey) ?? physicalKey; - const latestThread = sortThreads( - (threadsByProjectKey.get(logicalKey) ?? []).filter((thread) => thread.archivedAt === null), - sidebarThreadSortOrder, - )[0]; - if (!latestThread) return; - - void navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(scopeThreadRef(latestThread.environmentId, latestThread.id)), - }); - }, - [sidebarThreadSortOrder, navigate, threadsByProjectKey, physicalToLogicalKey], - ); - - const addProjectFromInput = useCallback( - async (rawCwd: string) => { - const cwd = rawCwd.trim(); - if (!cwd || isAddingProject) return; - const api = activeEnvironmentId ? readEnvironmentApi(activeEnvironmentId) : undefined; - if (!api) return; - - setIsAddingProject(true); - const finishAddingProject = () => { - setIsAddingProject(false); - setNewCwd(""); - setAddProjectError(null); - setAddingProject(false); - }; - - const existing = projects.find((project) => project.cwd === cwd); - if (existing) { - focusMostRecentThreadForProject({ - environmentId: existing.environmentId, - projectId: existing.id, - }); - finishAddingProject(); - return; - } - - const projectId = newProjectId(); - const title = cwd.split(/[/\\]/).findLast(isNonEmptyString) ?? cwd; - try { - await api.orchestration.dispatchCommand({ - type: "project.create", - commandId: newCommandId(), - projectId, - title, - workspaceRoot: cwd, - defaultModelSelection: { - provider: "codex", - model: DEFAULT_MODEL_BY_PROVIDER.codex, - }, - createdAt: new Date().toISOString(), - }); - if (activeEnvironmentId !== null) { - await handleNewThread(scopeProjectRef(activeEnvironmentId, projectId), { - envMode: defaultThreadEnvMode, - }).catch(() => undefined); - } - } catch (error) { - const description = - error instanceof Error ? error.message : "An error occurred while adding the project."; - setIsAddingProject(false); - if (shouldBrowseForProjectImmediately) { - toastManager.add({ - type: "error", - title: "Failed to add project", - description, - }); - } else { - setAddProjectError(description); - } - return; - } - finishAddingProject(); - }, - [ - focusMostRecentThreadForProject, - activeEnvironmentId, - handleNewThread, - isAddingProject, - projects, - shouldBrowseForProjectImmediately, - defaultThreadEnvMode, - ], - ); - - const handleAddProject = () => { - void addProjectFromInput(newCwd); - }; - - const canAddProject = newCwd.trim().length > 0 && !isAddingProject; - - const handlePickFolder = async () => { - const api = readLocalApi(); - if (!api || isPickingFolder) return; - setIsPickingFolder(true); - let pickedPath: string | null = null; - try { - pickedPath = await api.dialogs.pickFolder(); - } catch { - // Ignore picker failures and leave the current thread selection unchanged. - } - if (pickedPath) { - await addProjectFromInput(pickedPath); - } else if (!shouldBrowseForProjectImmediately) { - addProjectInputRef.current?.focus(); - } - setIsPickingFolder(false); - }; - - const handleStartAddProject = () => { - setAddProjectError(null); - if (shouldBrowseForProjectImmediately) { - void handlePickFolder(); - return; - } - setAddingProject((prev) => !prev); - }; const navigateToThread = useCallback( (threadRef: ScopedThreadRef) => { @@ -2772,8 +2860,10 @@ export default function Sidebar() { const activeProject = sidebarProjects.find((project) => project.projectKey === active.id); const overProject = sidebarProjects.find((project) => project.projectKey === over.id); if (!activeProject || !overProject) return; - const activeMemberKeys = activeProject.memberProjectRefs.map(scopedProjectKey); - const overMemberKeys = overProject.memberProjectRefs.map(scopedProjectKey); + const activeMemberKeys = activeProject.memberProjects.map( + (member) => member.physicalProjectKey, + ); + const overMemberKeys = overProject.memberProjects.map((member) => member.physicalProjectKey); reorderProjects(activeMemberKeys, overMemberKeys); }, [sidebarProjectSortOrder, reorderProjects, sidebarProjects], @@ -2822,7 +2912,10 @@ export default function Sidebar() { id: project.projectKey, })); const sortableThreads = visibleThreads.map((thread) => { - const physicalKey = scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); + const physicalKey = + projectPhysicalKeyByScopedRef.get( + scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), + ) ?? scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); return { ...thread, projectId: (physicalToLogicalKey.get(physicalKey) ?? physicalKey) as ProjectId, @@ -2839,6 +2932,7 @@ export default function Sidebar() { }, [ sidebarProjectSortOrder, physicalToLogicalKey, + projectPhysicalKeyByScopedRef, sidebarProjectByKey, sidebarProjects, visibleThreads, @@ -2913,6 +3007,30 @@ export default function Sidebar() { ? threadJumpLabelByKey : EMPTY_THREAD_JUMP_LABELS; const orderedSidebarThreadKeys = visibleSidebarThreadKeys; + const prewarmedSidebarThreadKeys = useMemo( + () => getSidebarThreadIdsToPrewarm(visibleSidebarThreadKeys), + [visibleSidebarThreadKeys], + ); + const prewarmedSidebarThreadRefs = useMemo( + () => + prewarmedSidebarThreadKeys.flatMap((threadKey) => { + const ref = parseScopedThreadKey(threadKey); + return ref ? [ref] : []; + }), + [prewarmedSidebarThreadKeys], + ); + + useEffect(() => { + const releases = prewarmedSidebarThreadRefs.map((ref) => + retainThreadDetailSubscription(ref.environmentId, ref.threadId), + ); + + return () => { + for (const release of releases) { + release(); + } + }; + }, [prewarmedSidebarThreadRefs]); useEffect(() => { const clearThreadJumpHints = () => { @@ -3222,21 +3340,9 @@ export default function Sidebar() { handleDesktopUpdateButtonClick={handleDesktopUpdateButtonClick} projectSortOrder={sidebarProjectSortOrder} threadSortOrder={sidebarThreadSortOrder} + projectGroupingMode={sidebarProjectGroupingMode} updateSettings={updateSettings} - shouldShowProjectPathEntry={shouldShowProjectPathEntry} - handleStartAddProject={handleStartAddProject} - isElectron={isElectron} - isPickingFolder={isPickingFolder} - isAddingProject={isAddingProject} - handlePickFolder={handlePickFolder} - addProjectInputRef={addProjectInputRef} - addProjectError={addProjectError} - newCwd={newCwd} - setNewCwd={setNewCwd} - setAddProjectError={setAddProjectError} - handleAddProject={handleAddProject} - setAddingProject={setAddingProject} - canAddProject={canAddProject} + openAddProject={openAddProjectCommandPalette} isManualProjectSorting={isManualProjectSorting} projectDnDSensors={projectDnDSensors} projectCollisionDetection={projectCollisionDetection} diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx new file mode 100644 index 00000000000..386a9f4e2d6 --- /dev/null +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -0,0 +1,254 @@ +import { scopeProjectRef, scopedThreadKey, scopeThreadRef } from "@marcode/client-runtime"; +import type { GitStatusResult } from "@marcode/contracts"; +import { CloudIcon, GitPullRequestIcon, TerminalIcon } from "lucide-react"; +import { useMemo } from "react"; +import { usePrimaryEnvironmentId } from "../environments/primary"; +import { + useSavedEnvironmentRegistryStore, + useSavedEnvironmentRuntimeStore, +} from "../environments/runtime"; +import { useGitStatus } from "../lib/gitStatusState"; +import { type AppState, selectProjectByRef, useStore } from "../store"; +import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; +import { useUiStateStore } from "../uiStateStore"; +import { resolveThreadStatusPill, type ThreadStatusPill } from "./Sidebar.logic"; +import type { SidebarThreadSummary } from "../types"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; + +export interface PrStatusIndicator { + label: "PR open" | "PR closed" | "PR merged" | "MR open" | "MR closed" | "MR merged"; + colorClass: string; + tooltip: string; + url: string; +} + +export interface TerminalStatusIndicator { + label: "Terminal process running"; + colorClass: string; + pulse: boolean; +} + +export type ThreadPr = GitStatusResult["pr"]; + +export type GitHostProvider = NonNullable; + +export interface PrStatusIndicatorInput { + pr: ThreadPr; + gitHostProvider: GitHostProvider | undefined; +} + +export function prStatusIndicator(input: PrStatusIndicatorInput): PrStatusIndicator | null { + const { pr, gitHostProvider } = input; + if (!pr) return null; + + const label = gitHostProvider === "gitlab" ? "MR" : "PR"; + + if (pr.state === "open") { + return { + label: `${label} open`, + colorClass: "text-emerald-600 dark:text-emerald-300/90", + tooltip: `#${pr.number} ${label} open: ${pr.title}`, + url: pr.url, + }; + } + if (pr.state === "closed") { + return { + label: `${label} closed`, + colorClass: "text-zinc-500 dark:text-zinc-400/80", + tooltip: `#${pr.number} ${label} closed: ${pr.title}`, + url: pr.url, + }; + } + if (pr.state === "merged") { + return { + label: `${label} merged`, + colorClass: "text-violet-600 dark:text-violet-300/90", + tooltip: `#${pr.number} ${label} merged: ${pr.title}`, + url: pr.url, + }; + } + return null; +} + +export function resolveThreadPr( + threadBranch: string | null, + gitStatus: GitStatusResult | null, +): ThreadPr | null { + if (threadBranch === null || gitStatus === null || gitStatus.branch !== threadBranch) { + return null; + } + + return gitStatus.pr ?? null; +} + +export function terminalStatusFromRunningIds( + runningTerminalIds: string[], +): TerminalStatusIndicator | null { + if (runningTerminalIds.length === 0) { + return null; + } + return { + label: "Terminal process running", + colorClass: "text-teal-600 dark:text-teal-300/90", + pulse: true, + }; +} + +export function ThreadStatusLabel({ + status, + compact = false, +}: { + status: ThreadStatusPill; + compact?: boolean; +}) { + if (compact) { + return ( + + + {status.label} + + ); + } + + return ( + + + {status.label} + + ); +} + +/** + * Non-interactive leading status icons for a thread row in compact contexts + * like the command palette. Shows the PR state icon (if present) and the + * thread status dot, matching the sidebar's leading indicators. + */ +export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummary }) { + const threadRef = scopeThreadRef(thread.environmentId, thread.id); + const lastVisitedAt = useUiStateStore( + (state) => state.threadLastVisitedAtById[scopedThreadKey(threadRef)], + ); + const threadProjectCwd = useStore( + useMemo( + () => (state: AppState) => + selectProjectByRef(state, scopeProjectRef(thread.environmentId, thread.projectId))?.cwd ?? + null, + [thread.environmentId, thread.projectId], + ), + ); + const gitCwd = thread.worktreePath ?? threadProjectCwd; + const gitStatus = useGitStatus({ + environmentId: thread.environmentId, + cwd: thread.branch != null ? gitCwd : null, + }); + const pr = resolveThreadPr(thread.branch, gitStatus.data); + const prStatus = prStatusIndicator({ + pr, + gitHostProvider: gitStatus.data?.gitHostProvider ?? undefined, + }); + const threadStatus = resolveThreadStatusPill({ + thread: { + ...thread, + lastVisitedAt, + }, + }); + + if (!prStatus && !threadStatus) { + return null; + } + + return ( + + {prStatus ? ( + + + } + > + + + {prStatus.tooltip} + + ) : null} + {threadStatus ? : null} + + ); +} + +/** + * Non-interactive trailing status icons for a thread row in compact contexts + * like the command palette. Shows a terminal-running indicator and a remote + * environment indicator, matching the sidebar's trailing indicators. + */ +export function ThreadRowTrailingStatus({ thread }: { thread: SidebarThreadSummary }) { + const threadRef = scopeThreadRef(thread.environmentId, thread.id); + const runningTerminalIds = useTerminalStateStore( + (state) => + selectThreadTerminalState(state.terminalStateByThreadKey, threadRef).runningTerminalIds, + ); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const isRemoteThread = + primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId; + const remoteEnvLabel = useSavedEnvironmentRuntimeStore( + (state) => state.byId[thread.environmentId]?.descriptor?.label ?? null, + ); + const remoteEnvSavedLabel = useSavedEnvironmentRegistryStore( + (state) => state.byId[thread.environmentId]?.label ?? null, + ); + const threadEnvironmentLabel = isRemoteThread + ? (remoteEnvLabel ?? remoteEnvSavedLabel ?? "Remote") + : null; + const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); + + if (!terminalStatus && !isRemoteThread) { + return null; + } + + return ( + + {terminalStatus ? ( + + + + ) : null} + {isRemoteThread ? ( + + + } + > + + + {threadEnvironmentLabel} + + ) : null} + + ); +} diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 9f8a1693eac..d6340bf58c4 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -28,7 +28,11 @@ import { resolveWrappedTerminalLinkRange, wrappedTerminalLinkRangeIntersectsBufferLine, } from "../terminal-links"; -import { isTerminalClearShortcut, terminalNavigationShortcutData } from "../keybindings"; +import { + isTerminalClearShortcut, + terminalDeleteShortcutData, + terminalNavigationShortcutData, +} from "../keybindings"; import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, @@ -407,6 +411,14 @@ export function TerminalViewport({ return false; } + const deleteData = terminalDeleteShortcutData(event); + if (deleteData !== null) { + event.preventDefault(); + event.stopPropagation(); + void sendTerminalInput(deleteData, "Failed to delete terminal input"); + return false; + } + if (!isTerminalClearShortcut(event)) return true; event.preventDefault(); event.stopPropagation(); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 05da31c6710..410d03f553a 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -162,6 +162,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop interactionMode: ProviderInteractionMode; runtimeMode: RuntimeMode; showPlanToggle: boolean; + planSidebarLabel: string; planSidebarOpen: boolean; onToggleInteractionMode: () => void; onRuntimeModeChange: (mode: RuntimeMode) => void; @@ -243,10 +244,14 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop size="sm" type="button" onClick={props.onTogglePlanSidebar} - title={props.planSidebarOpen ? "Hide plan sidebar" : "Show plan sidebar"} + title={ + props.planSidebarOpen + ? `Hide ${props.planSidebarLabel.toLowerCase()} sidebar` + : `Show ${props.planSidebarLabel.toLowerCase()} sidebar` + } > - Plan + {props.planSidebarLabel} ) : null} @@ -380,6 +385,7 @@ export interface ChatComposerProps { activeProposedPlan: Thread["proposedPlans"][number] | null; activePlan: { turnId?: TurnId } | null; sidebarProposedPlan: { turnId?: TurnId } | null; + planSidebarLabel: string; planSidebarOpen: boolean; // Mode @@ -474,6 +480,7 @@ export const ChatComposer = memo( activeProposedPlan, activePlan, sidebarProposedPlan, + planSidebarLabel, planSidebarOpen, runtimeMode, interactionMode, @@ -1905,7 +1912,7 @@ export const ChatComposer = memo( isComposerFooterCompact ? "gap-1.5" : "gap-2 sm:gap-0", )} > -
+
) : ( <> @@ -1946,6 +1950,7 @@ export const ChatComposer = memo( interactionMode={interactionMode} runtimeMode={runtimeMode} showPlanToggle={showPlanSidebarToggle} + planSidebarLabel={planSidebarLabel} planSidebarOpen={planSidebarOpen} onToggleInteractionMode={toggleInteractionMode} onRuntimeModeChange={handleRuntimeModeChange} diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index fcdae7906fe..3dcb0cee065 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -35,7 +35,6 @@ interface ChatHeaderProps { diffToggleShortcutLabel: string | null; gitCwd: string | null; diffOpen: boolean; - hasPlan: boolean; planSidebarOpen: boolean; onRunProjectScript: (script: ProjectScript) => void; onAddProjectScript: (input: NewProjectScriptInput) => Promise; @@ -64,7 +63,6 @@ export const ChatHeader = memo(function ChatHeader({ diffToggleShortcutLabel, gitCwd, diffOpen, - hasPlan, planSidebarOpen, onRunProjectScript, onAddProjectScript, @@ -188,18 +186,13 @@ export const ChatHeader = memo(function ChatHeader({ aria-label="Toggle plan sidebar" variant="outline" size="xs" - disabled={!hasPlan} > } /> - {!hasPlan - ? "No plan available" - : planSidebarOpen - ? "Hide plan sidebar" - : "Show plan sidebar"} + {planSidebarOpen ? "Hide plan sidebar" : "Show plan sidebar"}
diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx index a3bc2d726b7..c7ed9459b82 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx @@ -123,9 +123,7 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str ]; const screen = await render( } onToggleInteractionMode={vi.fn()} - onTogglePlanSidebar={vi.fn()} />, { container: host }, ); diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx index 76d3a577001..4b858f77783 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx @@ -1,10 +1,9 @@ import { ProviderInteractionMode } from "@marcode/contracts"; import { memo, type ReactNode } from "react"; -import { EllipsisIcon, ListTodoIcon } from "lucide-react"; +import { EllipsisIcon } from "lucide-react"; import { Button } from "../ui/button"; import { Menu, - MenuItem, MenuPopup, MenuRadioGroup, MenuRadioItem, @@ -13,12 +12,9 @@ import { } from "../ui/menu"; export const CompactComposerControlsMenu = memo(function CompactComposerControlsMenu(props: { - activePlan: boolean; interactionMode: ProviderInteractionMode; - planSidebarOpen: boolean; traitsMenuContent?: ReactNode; onToggleInteractionMode: () => void; - onTogglePlanSidebar: () => void; }) { return ( @@ -52,15 +48,6 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls Chat Plan - {props.activePlan ? ( - <> - - - - {props.planSidebarOpen ? "Hide plan sidebar" : "Show plan sidebar"} - - - ) : null} ); diff --git a/apps/web/src/components/chat/ComposerActiveTasksPanel.tsx b/apps/web/src/components/chat/ComposerActiveTasksPanel.tsx deleted file mode 100644 index 5ac0b2d954a..00000000000 --- a/apps/web/src/components/chat/ComposerActiveTasksPanel.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { memo, useEffect, useRef, useState } from "react"; -import { - CheckCircle2Icon, - ChevronDownIcon, - ChevronRightIcon, - CircleIcon, - ListTodoIcon, - LoaderIcon, -} from "lucide-react"; -import { type TodoItem } from "../../session-logic"; -import { cn } from "~/lib/utils"; - -function buildTodoSummaryLabel(items: ReadonlyArray): string { - const completed = items.filter((t) => t.status === "completed").length; - return `${completed}/${items.length} completed`; -} - -function todoStatusIcon(status: TodoItem["status"]) { - switch (status) { - case "completed": - return ; - case "in_progress": - return ; - case "pending": - return ; - } -} - -const TodoItemRow = memo(function TodoItemRow(props: { item: TodoItem }) { - const { item } = props; - const isCompleted = item.status === "completed"; - const isInProgress = item.status === "in_progress"; - const label = isInProgress ? item.activeForm : item.content; - - return ( -
- - {todoStatusIcon(item.status)} - - - {label} - -
- ); -}); - -export const ComposerTodoListPanel = memo(function ComposerTodoListPanel(props: { - items: ReadonlyArray; -}) { - const { items } = props; - const [expanded, setExpanded] = useState(true); - const scrollContainerRef = useRef(null); - const allCompleted = items.every((t) => t.status === "completed"); - const ExpandIcon = expanded ? ChevronDownIcon : ChevronRightIcon; - - useEffect(() => { - if (!expanded) return; - const container = scrollContainerRef.current; - if (!container) return; - const inProgressEl = container.querySelector("[data-in-progress]"); - inProgressEl?.scrollIntoView({ block: "nearest" }); - }, [expanded]); - - return ( -
- - - {expanded && ( -
- {items.map((item) => ( - - ))} -
- )} -
- ); -}); diff --git a/apps/web/src/components/chat/ComposerAttachmentsPopover.tsx b/apps/web/src/components/chat/ComposerAttachmentsPopover.tsx index 172f4c81ffa..40062074965 100644 --- a/apps/web/src/components/chat/ComposerAttachmentsPopover.tsx +++ b/apps/web/src/components/chat/ComposerAttachmentsPopover.tsx @@ -1,4 +1,4 @@ -import { useCallback, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { FolderIcon, FolderPlusIcon, ImageIcon, PlusIcon, XIcon } from "lucide-react"; import type { ThreadId, RuntimeMode } from "@marcode/contracts"; import { Button } from "../ui/button"; @@ -18,6 +18,8 @@ import { readNativeApi } from "~/nativeApi"; import { newCommandId } from "~/lib/utils"; import { basenameOfPath } from "~/vscode-icons"; import { toastManager } from "~/components/ui/toast"; +import { useCommandPaletteStore } from "~/commandPaletteStore"; +import { ensureBrowseDirectoryPath, getBrowseParentPath } from "~/lib/projectPaths"; interface ComposerAttachmentsPopoverProps { threadId: ThreadId; @@ -27,6 +29,14 @@ interface ComposerAttachmentsPopoverProps { onRuntimeModeChange: (mode: RuntimeMode) => void; onAttachImages: (files: File[]) => void; disabled: boolean; + projectCwd?: string | null; +} + +function resolveAddFolderInitialPath(projectCwd: string | null | undefined): string { + if (!projectCwd) { + return "~/"; + } + return ensureBrowseDirectoryPath(getBrowseParentPath(projectCwd) ?? projectCwd); } export function ComposerAttachmentsPopover({ @@ -37,10 +47,15 @@ export function ComposerAttachmentsPopover({ onRuntimeModeChange, onAttachImages, disabled, + projectCwd, }: ComposerAttachmentsPopoverProps) { const fileInputRef = useRef(null); const menuHandleRef = useRef(MenuCreateHandle()); const [isPickingFolder, setIsPickingFolder] = useState(false); + const openAddFolder = useCommandPaletteStore((state) => state.openAddFolder); + const addFolderResult = useCommandPaletteStore((state) => state.addFolderResult); + const consumeAddFolderResult = useCommandPaletteStore((state) => state.consumeAddFolderResult); + const pendingAddFolderRequestIdRef = useRef(null); const count = additionalDirectories.length; @@ -73,17 +88,52 @@ export function ComposerAttachmentsPopover({ [threadId, onLocalDirectoriesChange], ); + const addDirectory = useCallback( + async (path: string) => { + if (additionalDirectories.includes(path)) return; + try { + await dispatchMetaUpdate([...additionalDirectories, path]); + } catch (error) { + toastManager.add({ + type: "error", + title: "Failed to add folder", + description: + error instanceof Error + ? error.message + : "An unexpected error occurred while adding the folder.", + }); + } + }, + [additionalDirectories, dispatchMetaUpdate], + ); + + useEffect(() => { + if (!addFolderResult) return; + if (addFolderResult.requestId !== pendingAddFolderRequestIdRef.current) return; + const { requestId, path } = addFolderResult; + pendingAddFolderRequestIdRef.current = null; + consumeAddFolderResult(requestId); + void addDirectory(path); + }, [addDirectory, addFolderResult, consumeAddFolderResult]); + const pickingRef = useRef(false); const handlePickFolder = useCallback(async () => { + if (pickingRef.current) return; + menuHandleRef.current.close(); + + if (projectCwd) { + pendingAddFolderRequestIdRef.current = openAddFolder(resolveAddFolderInitialPath(projectCwd)); + return; + } + const api = readNativeApi(); - if (!api || pickingRef.current) return; + if (!api) return; pickingRef.current = true; setIsPickingFolder(true); try { - menuHandleRef.current.close(); const pickedPath = await api.dialogs.pickFolder(); - if (pickedPath && !additionalDirectories.includes(pickedPath)) { - await dispatchMetaUpdate([...additionalDirectories, pickedPath]); + if (pickedPath) { + await addDirectory(pickedPath); } } catch (error) { toastManager.add({ @@ -98,7 +148,7 @@ export function ComposerAttachmentsPopover({ pickingRef.current = false; setIsPickingFolder(false); } - }, [additionalDirectories, dispatchMetaUpdate]); + }, [addDirectory, openAddFolder, projectCwd]); const removeDirectory = useCallback( (path: string) => { diff --git a/apps/web/src/components/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx index 6c23a405c12..14e13ccc10d 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -10,6 +10,7 @@ import { AntigravityIcon, CursorIcon, Icon, + KiroIcon, TraeIcon, IntelliJIdeaIcon, VisualStudioCode, @@ -32,6 +33,11 @@ const resolveOptions = (platform: string, availableEditors: ReadonlyArray>; readonly setServerExposureMode?: DesktopBridge["setServerExposureMode"]; + readonly setUpdateChannel?: DesktopBridge["setUpdateChannel"]; }): DesktopBridge => { const idleUpdateState: DesktopUpdateState = { enabled: false, status: "idle", + channel: "latest", currentVersion: "0.0.0-test", hostArch: "arm64", appArch: "arm64", @@ -330,6 +333,7 @@ const createDesktopBridgeStub = (overrides?: { }; return { + getAppBranding: vi.fn().mockReturnValue(null), getLocalEnvironmentBootstrap: () => ({ label: "Local environment", httpBaseUrl: "http://127.0.0.1:3773", @@ -364,6 +368,12 @@ const createDesktopBridgeStub = (overrides?: { openExternal: vi.fn().mockResolvedValue(true), onMenuAction: () => () => {}, getUpdateState: vi.fn().mockResolvedValue(idleUpdateState), + setUpdateChannel: + overrides?.setUpdateChannel ?? + vi.fn().mockImplementation(async (channel: DesktopUpdateChannel) => ({ + ...idleUpdateState, + channel, + })), checkForUpdate: vi.fn().mockResolvedValue({ checked: false, state: idleUpdateState }), downloadUpdate: vi .fn() diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index d4eefc0e832..b602e37b5f1 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -16,6 +16,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { type ReactNode, useCallback, useMemo, useRef, useState } from "react"; import { PROVIDER_DISPLAY_NAMES, + type DesktopUpdateChannel, type ScopedThreadRef, type ProviderKind, type ServerProvider, @@ -245,8 +246,42 @@ function AboutVersionTitle() { function AboutVersionSection() { const queryClient = useQueryClient(); const updateStateQuery = useDesktopUpdateState(); + const [isChangingUpdateChannel, setIsChangingUpdateChannel] = useState(false); const updateState = updateStateQuery.data ?? null; + const hasDesktopBridge = typeof window !== "undefined" && Boolean(window.desktopBridge); + const selectedUpdateChannel = updateState?.channel ?? "latest"; + + const handleUpdateChannelChange = useCallback( + (channel: DesktopUpdateChannel) => { + const bridge = window.desktopBridge; + if ( + !bridge || + typeof bridge.setUpdateChannel !== "function" || + channel === selectedUpdateChannel + ) { + return; + } + + setIsChangingUpdateChannel(true); + void bridge + .setUpdateChannel(channel) + .then((state) => { + setDesktopUpdateStateQueryData(queryClient, state); + }) + .catch((error: unknown) => { + toastManager.add({ + type: "error", + title: "Could not change update track", + description: error instanceof Error ? error.message : "Update track change failed.", + }); + }) + .finally(() => { + setIsChangingUpdateChannel(false); + }); + }, + [queryClient, selectedUpdateChannel], + ); const handleButtonClick = useCallback(() => { const bridge = window.desktopBridge; @@ -336,27 +371,59 @@ function AboutVersionSection() { : "Current version of the application."; return ( - } - description={description} - control={ - - - {buttonLabel} - - } - /> - {buttonTooltip ? {buttonTooltip} : null} - - } - /> + <> + } + description={description} + control={ + + + {buttonLabel} + + } + /> + {buttonTooltip ? {buttonTooltip} : null} + + } + /> + { + handleUpdateChannelChange(value as DesktopUpdateChannel); + }} + > + + + {selectedUpdateChannel === "nightly" ? "Nightly" : "Stable"} + + + + + Stable + + + Nightly + + + + } + /> + ); } @@ -390,6 +457,9 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode ? ["New thread mode"] : []), + ...(settings.addProjectBaseDirectory !== DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory + ? ["Add project base directory"] + : []), ...(settings.confirmThreadArchive !== DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive ? ["Archive confirmation"] : []), @@ -404,6 +474,7 @@ export function useSettingsRestore(onRestored?: () => void) { isGitWritingModelDirty, settings.confirmThreadArchive, settings.confirmThreadDelete, + settings.addProjectBaseDirectory, settings.defaultThreadEnvMode, settings.diffWordWrap, settings.enableAssistantStreaming, @@ -935,7 +1006,8 @@ export function GeneralSettingsPanel() { claudeAgent: Boolean( settings.providers.claudeAgent.binaryPath !== DEFAULT_UNIFIED_SETTINGS.providers.claudeAgent.binaryPath || - settings.providers.claudeAgent.customModels.length > 0, + settings.providers.claudeAgent.customModels.length > 0 || + settings.providers.claudeAgent.launchArgs !== "", ), }); const [customModelInputByProvider, setCustomModelInputByProvider] = useState< @@ -1325,6 +1397,34 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ + addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory, + }) + } + /> + ) : null + } + control={ + updateSettings({ addProjectBaseDirectory: event.target.value })} + placeholder="~/" + spellCheck={false} + aria-label="Add project base directory" + /> + } + /> + - - updateSettings({ - showTodosInComposer: DEFAULT_UNIFIED_SETTINGS.showTodosInComposer, - }) - } - /> - ) : null - } - control={ - - updateSettings({ showTodosInComposer: Boolean(checked) }) - } - aria-label="Show todo checklist in composer" - /> - } - /> - ) : null} + {providerCard.provider === "claudeAgent" ? ( +
+ +
+ ) : null} +
Models
diff --git a/apps/web/src/components/ui/combobox.tsx b/apps/web/src/components/ui/combobox.tsx index 8348d4c2fcf..52dd996ec56 100644 --- a/apps/web/src/components/ui/combobox.tsx +++ b/apps/web/src/components/ui/combobox.tsx @@ -275,6 +275,20 @@ function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) { ); } +/** + * A variant of `ComboboxList` without `ScrollArea`, for use when + * an external virtualizer (e.g. LegendList) owns the scroll container. + */ +function ComboboxListVirtualized({ className, ...props }: ComboboxPrimitive.List.Props) { + return ( + + ); +} + function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) { return ; } @@ -371,6 +385,7 @@ export { ComboboxEmpty, ComboboxValue, ComboboxList, + ComboboxListVirtualized, ComboboxClear, ComboboxStatus, ComboboxRow, diff --git a/apps/web/src/components/ui/select.tsx b/apps/web/src/components/ui/select.tsx index 5c423ef05ba..245fa6dd789 100644 --- a/apps/web/src/components/ui/select.tsx +++ b/apps/web/src/components/ui/select.tsx @@ -12,7 +12,7 @@ import { cn } from "~/lib/utils"; const Select = SelectPrimitive.Root; const selectTriggerVariants = cva( - "relative inline-flex select-none items-center justify-between gap-2 border rounded-lg text-left text-base outline-none transition-[color,box-shadow,background-color] data-disabled:pointer-events-none data-disabled:opacity-64 sm:text-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4", + "relative inline-flex cursor-pointer select-none items-center justify-between gap-2 border rounded-lg text-left text-base outline-none transition-[color,box-shadow,background-color] data-disabled:pointer-events-none data-disabled:opacity-64 sm:text-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4", { defaultVariants: { size: "default", diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index b079d3b8505..74572379e56 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -1890,13 +1890,18 @@ const composerDraftStore = create()( [draftId]: nextDraftThread, }; let nextDraftsByThreadKey = state.draftsByThreadKey; + const previousDraftThread = + previousThreadKeyForLogicalProject === undefined + ? undefined + : nextDraftThreadsByThreadKey[previousThreadKeyForLogicalProject]; if ( previousThreadKeyForLogicalProject && previousThreadKeyForLogicalProject !== draftId && !isComposerThreadKeyInUse( nextLogicalProjectDraftThreadKeyByLogicalProjectKey, previousThreadKeyForLogicalProject, - ) + ) && + !isDraftThreadPromoting(previousDraftThread) ) { delete nextDraftThreadsByThreadKey[previousThreadKeyForLogicalProject]; if (state.draftsByThreadKey[previousThreadKeyForLogicalProject] !== undefined) { diff --git a/apps/web/src/contextMenuFallback.test.ts b/apps/web/src/contextMenuFallback.test.ts new file mode 100644 index 00000000000..598d0d8bbed --- /dev/null +++ b/apps/web/src/contextMenuFallback.test.ts @@ -0,0 +1,221 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { showContextMenuFallback } from "./contextMenuFallback"; + +type FakeListener = (event: FakeDomEvent) => void; + +class FakeDomEvent { + defaultPrevented = false; + + constructor( + readonly type: string, + init: Record = {}, + ) { + Object.assign(this, init); + } + + preventDefault() { + this.defaultPrevented = true; + } +} + +class FakeElement { + children: FakeElement[] = []; + parent: FakeElement | null = null; + style: Record & { cssText?: string } = {}; + dataset: Record = {}; + className = ""; + disabled = false; + type = ""; + private textValue = ""; + private readonly listeners = new Map(); + + constructor(readonly tagName: string) {} + + appendChild(child: FakeElement) { + child.parent = this; + this.children.push(child); + return child; + } + + remove() { + if (!this.parent) { + return; + } + const index = this.parent.children.indexOf(this); + if (index >= 0) { + this.parent.children.splice(index, 1); + } + this.parent = null; + } + + addEventListener(type: string, listener: FakeListener) { + const existing = this.listeners.get(type) ?? []; + existing.push(listener); + this.listeners.set(type, existing); + } + + dispatchEvent(event: FakeDomEvent) { + for (const listener of this.listeners.get(event.type) ?? []) { + listener(event); + } + return true; + } + + set textContent(value: string) { + this.textValue = value; + } + + get textContent() { + return `${this.textValue}${this.children.map((child) => child.textContent).join("")}`; + } + + querySelectorAll(tagName: string): FakeElement[] { + const matches: FakeElement[] = []; + if (this.tagName === tagName) { + matches.push(this); + } + for (const child of this.children) { + matches.push(...child.querySelectorAll(tagName)); + } + return matches; + } + + getBoundingClientRect() { + const left = Number.parseInt(this.style.left ?? "0", 10) || 0; + const top = Number.parseInt(this.style.top ?? "0", 10) || 0; + const width = this.tagName === "div" ? 180 : 140; + const height = this.tagName === "div" ? 120 : 28; + return { + left, + top, + width, + height, + right: left + width, + bottom: top + height, + }; + } +} + +class FakeBody extends FakeElement { + private html = ""; + + constructor() { + super("body"); + } + + set innerHTML(value: string) { + this.html = value; + this.children = []; + } + + get innerHTML() { + return this.html; + } +} + +class FakeDocument { + body = new FakeBody(); + private readonly listeners = new Map(); + + createElement(tagName: string) { + return new FakeElement(tagName); + } + + addEventListener(type: string, listener: FakeListener) { + const existing = this.listeners.get(type) ?? []; + existing.push(listener); + this.listeners.set(type, existing); + } + + removeEventListener(type: string, listener: FakeListener) { + const existing = this.listeners.get(type); + if (!existing) { + return; + } + const index = existing.indexOf(listener); + if (index >= 0) { + existing.splice(index, 1); + } + } + + querySelectorAll(tagName: string) { + return this.body.querySelectorAll(tagName); + } +} + +function findButton(label: string): FakeElement | undefined { + return (document as unknown as FakeDocument) + .querySelectorAll("button") + .find((button) => button.textContent.includes(label)); +} + +beforeEach(() => { + vi.stubGlobal("document", new FakeDocument()); + vi.stubGlobal("window", { + innerWidth: 1280, + innerHeight: 800, + }); + vi.stubGlobal("requestAnimationFrame", (callback: (time: number) => void) => { + callback(0); + return 0; + }); + vi.stubGlobal( + "MouseEvent", + class extends FakeDomEvent { + constructor(type: string, init: Record = {}) { + super(type, init); + } + }, + ); + vi.stubGlobal( + "KeyboardEvent", + class extends FakeDomEvent { + constructor(type: string, init: Record = {}) { + super(type, init); + } + }, + ); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("showContextMenuFallback", () => { + it("resolves a clicked flat menu item", async () => { + const selectionPromise = showContextMenuFallback([ + { id: "rename", label: "Rename" }, + { id: "delete", label: "Delete", destructive: true }, + ]); + + const renameButton = findButton("Rename"); + expect(renameButton).toBeTruthy(); + renameButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + await expect(selectionPromise).resolves.toBe("rename"); + }); + + it("opens nested submenus and resolves the clicked leaf id", async () => { + const selectionPromise = showContextMenuFallback([ + { + id: "rename:submenu", + label: "Rename project", + children: [ + { id: "rename:project-a", label: "/tmp/project-a" }, + { id: "rename:project-b", label: "/tmp/project-b" }, + ], + }, + ]); + + const parentButton = findButton("Rename project"); + expect(parentButton).toBeTruthy(); + parentButton?.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + + const childButton = findButton("/tmp/project-b"); + expect(childButton).toBeTruthy(); + childButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + await expect(selectionPromise).resolves.toBe("rename:project-b"); + }); +}); diff --git a/apps/web/src/contextMenuFallback.ts b/apps/web/src/contextMenuFallback.ts index 6e50aa40105..38492afd48b 100644 --- a/apps/web/src/contextMenuFallback.ts +++ b/apps/web/src/contextMenuFallback.ts @@ -1,9 +1,22 @@ import type { ContextMenuItem } from "@marcode/contracts"; +function clampMenuPosition(menu: HTMLDivElement, preferredLeft: number, preferredTop: number) { + const rect = menu.getBoundingClientRect(); + const left = Math.min( + Math.max(4, preferredLeft), + Math.max(4, window.innerWidth - rect.width - 4), + ); + const top = Math.min( + Math.max(4, preferredTop), + Math.max(4, window.innerHeight - rect.height - 4), + ); + menu.style.left = `${left}px`; + menu.style.top = `${top}px`; +} + /** * Imperative DOM-based context menu for non-Electron environments. - * Shows a positioned dropdown and returns a promise that resolves - * with the clicked item id, or null if dismissed. + * Supports nested submenus and resolves with the clicked leaf item id. */ export function showContextMenuFallback( items: readonly ContextMenuItem[], @@ -13,62 +26,117 @@ export function showContextMenuFallback( const overlay = document.createElement("div"); overlay.style.cssText = "position:fixed;inset:0;z-index:9999"; - const menu = document.createElement("div"); - menu.className = - "fixed z-[10000] min-w-[140px] rounded-md border border-border bg-popover py-1 shadow-xl animate-in fade-in zoom-in-95"; - - const x = position?.x ?? 0; - const y = position?.y ?? 0; - menu.style.top = `${y}px`; - menu.style.left = `${x}px`; + const menuStack: HTMLDivElement[] = []; - function cleanup(result: T | null) { + const cleanup = (result: T | null) => { document.removeEventListener("keydown", onKeyDown); overlay.remove(); - menu.remove(); + for (const menu of menuStack) { + menu.remove(); + } resolve(result); - } + }; - function onKeyDown(e: KeyboardEvent) { - if (e.key === "Escape") { - e.preventDefault(); + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); cleanup(null); } - } + }; - overlay.addEventListener("mousedown", () => cleanup(null)); - document.addEventListener("keydown", onKeyDown); - - for (const item of items) { - const btn = document.createElement("button"); - btn.type = "button"; - btn.textContent = item.label; - const isDestructiveAction = item.destructive === true || item.id === "delete"; - const isDisabled = item.disabled === true; - btn.disabled = isDisabled; - btn.className = isDisabled - ? "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-muted-foreground/60 cursor-not-allowed" - : isDestructiveAction - ? "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-destructive hover:bg-accent cursor-default" - : "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-popover-foreground hover:bg-accent cursor-default"; - if (!isDisabled) { - btn.addEventListener("click", () => cleanup(item.id)); + const closeMenusFromLevel = (level: number) => { + while (menuStack.length > level) { + menuStack.pop()?.remove(); } - menu.appendChild(btn); - } + }; - document.body.appendChild(overlay); - document.body.appendChild(menu); + const openMenu = ( + entries: readonly ContextMenuItem[], + preferredLeft: number, + preferredTop: number, + level: number, + ) => { + closeMenusFromLevel(level); - // Adjust if menu overflows viewport - requestAnimationFrame(() => { - const rect = menu.getBoundingClientRect(); - if (rect.right > window.innerWidth) { - menu.style.left = `${window.innerWidth - rect.width - 4}px`; - } - if (rect.bottom > window.innerHeight) { - menu.style.top = `${window.innerHeight - rect.height - 4}px`; + const menu = document.createElement("div"); + menu.className = + "fixed z-[10000] min-w-[160px] rounded-md border border-border bg-popover py-1 shadow-xl animate-in fade-in zoom-in-95"; + menu.style.left = `${preferredLeft}px`; + menu.style.top = `${preferredTop}px`; + menu.dataset.level = String(level); + + for (const item of entries) { + const button = document.createElement("button"); + button.type = "button"; + const hasChildren = Array.isArray(item.children) && item.children.length > 0; + const isLeafDestructive = + !hasChildren && (item.destructive === true || item.id === ("delete" as T)); + const isDisabled = item.disabled === true; + button.disabled = isDisabled; + button.className = isDisabled + ? "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-muted-foreground/60 cursor-not-allowed" + : isLeafDestructive + ? "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-destructive hover:bg-accent cursor-default" + : "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-popover-foreground hover:bg-accent cursor-default"; + + const label = document.createElement("span"); + label.className = "min-w-0 flex-1 truncate"; + label.textContent = item.label; + button.appendChild(label); + + if (hasChildren) { + const chevron = document.createElement("span"); + chevron.className = "shrink-0 text-muted-foreground/70"; + chevron.textContent = "›"; + button.appendChild(chevron); + } + + if (!isDisabled) { + if (hasChildren) { + button.addEventListener("mouseenter", () => { + const rect = button.getBoundingClientRect(); + const nextLeft = rect.right + 4; + const nextTop = rect.top; + openMenu(item.children!, nextLeft, nextTop, level + 1); + + const childMenu = menuStack[level + 1]; + if (!childMenu) { + return; + } + const childRect = childMenu.getBoundingClientRect(); + if (childRect.right > window.innerWidth) { + clampMenuPosition(childMenu, rect.left - childRect.width - 4, rect.top); + } + }); + button.addEventListener("click", (event) => { + event.preventDefault(); + }); + } else { + button.addEventListener("mouseenter", () => { + closeMenusFromLevel(level + 1); + }); + button.addEventListener("click", () => cleanup(item.id)); + } + } + + menu.appendChild(button); } - }); + + menu.addEventListener("mouseenter", () => { + closeMenusFromLevel(level + 1); + }); + + document.body.appendChild(menu); + menuStack[level] = menu; + + requestAnimationFrame(() => { + clampMenuPosition(menu, preferredLeft, preferredTop); + }); + }; + + overlay.addEventListener("mousedown", () => cleanup(null)); + document.addEventListener("keydown", onKeyDown); + document.body.appendChild(overlay); + openMenu(items, position?.x ?? 0, position?.y ?? 0, 0); }); } diff --git a/apps/web/src/environmentApi.ts b/apps/web/src/environmentApi.ts index b90521bcd6b..4d331b629b4 100644 --- a/apps/web/src/environmentApi.ts +++ b/apps/web/src/environmentApi.ts @@ -3,6 +3,8 @@ import type { EnvironmentId, EnvironmentApi } from "@marcode/contracts"; import type { WsRpcClient } from "./rpc/wsRpcClient"; import { readEnvironmentConnection } from "./environments/runtime"; +const environmentApiOverridesForTests = new Map(); + export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi { return { terminal: { @@ -19,6 +21,9 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi { browseDirectories: rpcClient.projects.browseDirectories, writeFile: rpcClient.projects.writeFile, }, + filesystem: { + browse: rpcClient.filesystem.browse, + }, git: { pull: rpcClient.git.pull, refreshStatus: rpcClient.git.refreshStatus, @@ -40,12 +45,10 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi { dispatchCommand: rpcClient.orchestration.dispatchCommand, getTurnDiff: rpcClient.orchestration.getTurnDiff, getFullThreadDiff: rpcClient.orchestration.getFullThreadDiff, - replayEvents: (fromSequenceExclusive) => - rpcClient.orchestration - .replayEvents({ fromSequenceExclusive }) - .then((events) => [...events]), - onDomainEvent: (callback, options) => - rpcClient.orchestration.onDomainEvent(callback, options), + subscribeShell: (callback, options) => + rpcClient.orchestration.subscribeShell(callback, options), + subscribeThread: (input, callback, options) => + rpcClient.orchestration.subscribeThread(input, callback, options), }, jira: { getConnectionStatus: rpcClient.jira.getConnectionStatus, @@ -70,6 +73,11 @@ export function readEnvironmentApi(environmentId: EnvironmentId): EnvironmentApi return undefined; } + const overriddenApi = environmentApiOverridesForTests.get(environmentId); + if (overriddenApi) { + return overriddenApi; + } + const connection = readEnvironmentConnection(environmentId); return connection ? createEnvironmentApi(connection.client) : undefined; } @@ -81,3 +89,14 @@ export function ensureEnvironmentApi(environmentId: EnvironmentId): EnvironmentA } return api; } + +export function __setEnvironmentApiOverrideForTests( + environmentId: EnvironmentId, + api: EnvironmentApi, +): void { + environmentApiOverridesForTests.set(environmentId, api); +} + +export function __resetEnvironmentApiOverridesForTests(): void { + environmentApiOverridesForTests.clear(); +} diff --git a/apps/web/src/environmentGrouping.test.ts b/apps/web/src/environmentGrouping.test.ts index 8c216e5b38d..d730bb6efd4 100644 --- a/apps/web/src/environmentGrouping.test.ts +++ b/apps/web/src/environmentGrouping.test.ts @@ -10,7 +10,12 @@ import { type AppState, type EnvironmentState, } from "./store"; -import { deriveLogicalProjectKey } from "./logicalProject"; +import { + deriveLogicalProjectKey, + deriveLogicalProjectKeyFromSettings, + derivePhysicalProjectKey, + resolveProjectGroupingMode, +} from "./logicalProject"; import type { Project, SidebarThreadSummary } from "./types"; import { DEFAULT_INTERACTION_MODE } from "./types"; @@ -31,6 +36,10 @@ const threadL1 = ThreadId.make("thread-local-only-1"); const threadRO1 = ThreadId.make("thread-remote-only-1"); const SHARED_REPO_CANONICAL_KEY = "github.com/example/shared-repo"; +const DEFAULT_GROUPING_SETTINGS = { + sidebarProjectGroupingMode: "repository" as const, + sidebarProjectGroupingOverrides: {}, +}; // ── Factory Helpers ────────────────────────────────────────────────── @@ -239,9 +248,7 @@ describe("environment grouping", () => { environmentId: primaryEnvId, name: "local-only", }); - const key = deriveLogicalProjectKey(project); - expect(key).toContain(primaryEnvId); - expect(key).toContain(localOnlyProjectId); + expect(deriveLogicalProjectKey(project)).toBe(derivePhysicalProjectKey(project)); }); it("groups projects from different environments that share the same canonical key", () => { @@ -274,6 +281,134 @@ describe("environment grouping", () => { expect(deriveLogicalProjectKey(primary)).toBe(deriveLogicalProjectKey(remote)); }); + it("groups repo root and nested projects from the same repository by default", () => { + const rootProject = makeProject({ + id: sharedProjectPrimaryId, + environmentId: primaryEnvId, + name: "shared-repo", + cwd: "/workspace/repo", + repositoryIdentity: { + canonicalKey: SHARED_REPO_CANONICAL_KEY, + rootPath: "/workspace/repo", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://github.com/example/shared-repo.git", + }, + }, + }); + const nestedProject = makeProject({ + id: localOnlyProjectId, + environmentId: primaryEnvId, + name: "web", + cwd: "/workspace/repo/apps/web", + repositoryIdentity: { + canonicalKey: SHARED_REPO_CANONICAL_KEY, + rootPath: "/workspace/repo", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://github.com/example/shared-repo.git", + }, + }, + }); + + expect(deriveLogicalProjectKey(rootProject)).toBe(SHARED_REPO_CANONICAL_KEY); + expect(deriveLogicalProjectKey(nestedProject)).toBe(SHARED_REPO_CANONICAL_KEY); + }); + + it("uses repository path grouping when requested", () => { + const rootProject = makeProject({ + id: sharedProjectPrimaryId, + environmentId: primaryEnvId, + name: "shared-repo", + cwd: "/workspace/repo", + repositoryIdentity: { + canonicalKey: SHARED_REPO_CANONICAL_KEY, + rootPath: "/workspace/repo", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://github.com/example/shared-repo.git", + }, + }, + }); + const nestedProject = makeProject({ + id: localOnlyProjectId, + environmentId: primaryEnvId, + name: "web", + cwd: "/workspace/repo/apps/web", + repositoryIdentity: { + canonicalKey: SHARED_REPO_CANONICAL_KEY, + rootPath: "/workspace/repo", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://github.com/example/shared-repo.git", + }, + }, + }); + + expect( + deriveLogicalProjectKey(rootProject, { + groupingMode: "repository_path", + }), + ).toBe(SHARED_REPO_CANONICAL_KEY); + expect( + deriveLogicalProjectKey(nestedProject, { + groupingMode: "repository_path", + }), + ).toBe(`${SHARED_REPO_CANONICAL_KEY}::apps/web`); + }); + + it("groups matching nested project paths across environments when repo roots differ", () => { + const primary = makeProject({ + id: sharedProjectPrimaryId, + environmentId: primaryEnvId, + name: "web", + cwd: "/workspace/repo/apps/web", + repositoryIdentity: { + canonicalKey: SHARED_REPO_CANONICAL_KEY, + rootPath: "/workspace/repo", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://github.com/example/shared-repo.git", + }, + }, + }); + const remote = makeProject({ + id: sharedProjectRemoteId, + environmentId: remoteEnvId, + name: "web", + cwd: "/srv/checkout/apps/web", + repositoryIdentity: { + canonicalKey: SHARED_REPO_CANONICAL_KEY, + rootPath: "/srv/checkout", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://github.com/example/shared-repo.git", + }, + }, + }); + + expect( + deriveLogicalProjectKey(primary, { + groupingMode: "repository_path", + }), + ).toBe(`${SHARED_REPO_CANONICAL_KEY}::apps/web`); + expect( + deriveLogicalProjectKey(primary, { + groupingMode: "repository_path", + }), + ).toBe( + deriveLogicalProjectKey(remote, { + groupingMode: "repository_path", + }), + ); + }); + it("does NOT group projects without shared canonical key", () => { const local = makeProject({ id: localOnlyProjectId, @@ -287,6 +422,32 @@ describe("environment grouping", () => { }); expect(deriveLogicalProjectKey(local)).not.toBe(deriveLogicalProjectKey(remote)); }); + + it("uses per-project overrides from settings", () => { + const project = makeProject({ + id: sharedProjectPrimaryId, + environmentId: primaryEnvId, + name: "shared-repo", + repositoryIdentity: { + canonicalKey: SHARED_REPO_CANONICAL_KEY, + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://github.com/example/shared-repo.git", + }, + }, + }); + + expect(resolveProjectGroupingMode(project, DEFAULT_GROUPING_SETTINGS)).toBe("repository"); + expect( + deriveLogicalProjectKeyFromSettings(project, { + ...DEFAULT_GROUPING_SETTINGS, + sidebarProjectGroupingOverrides: { + [derivePhysicalProjectKey(project)]: "separate", + }, + }), + ).toBe(derivePhysicalProjectKey(project)); + }); }); describe("selectProjectsAcrossEnvironments", () => { diff --git a/apps/web/src/environments/primary/auth.ts b/apps/web/src/environments/primary/auth.ts index 7d0f41c5513..ebc5c5e384e 100644 --- a/apps/web/src/environments/primary/auth.ts +++ b/apps/web/src/environments/primary/auth.ts @@ -57,6 +57,7 @@ type ServerAuthGateState = }; let bootstrapPromise: Promise | null = null; +let resolvedAuthenticatedGateState: ServerAuthGateState | null = null; const AUTH_SESSION_ESTABLISH_TIMEOUT_MS = 2_000; const AUTH_SESSION_ESTABLISH_STEP_MS = 100; @@ -247,6 +248,7 @@ export async function submitServerAuthCredential(credential: string): Promise { } export async function resolveInitialServerAuthGateState(): Promise { + if (resolvedAuthenticatedGateState?.status === "authenticated") { + return resolvedAuthenticatedGateState; + } + if (bootstrapPromise) { return bootstrapPromise; } const nextPromise = bootstrapServerAuth(); bootstrapPromise = nextPromise; - return nextPromise.finally(() => { - if (bootstrapPromise === nextPromise) { - bootstrapPromise = null; - } - }); + return nextPromise + .then((result) => { + if (result.status === "authenticated") { + resolvedAuthenticatedGateState = result; + } + return result; + }) + .finally(() => { + if (bootstrapPromise === nextPromise) { + bootstrapPromise = null; + } + }); } export function __resetServerAuthBootstrapForTests() { bootstrapPromise = null; + resolvedAuthenticatedGateState = null; } diff --git a/apps/web/src/environments/runtime/connection.test.ts b/apps/web/src/environments/runtime/connection.test.ts index 716aade31d7..e08d535db95 100644 --- a/apps/web/src/environments/runtime/connection.test.ts +++ b/apps/web/src/environments/runtime/connection.test.ts @@ -4,29 +4,19 @@ import { describe, expect, it, vi } from "vitest"; import { createEnvironmentConnection } from "./connection"; import type { WsRpcClient } from "~/rpc/wsRpcClient"; -function createTestClient(options?: { - readonly getListingSnapshot?: () => Promise<{ readonly snapshotSequence: number }>; - readonly replayEvents?: () => Promise>; -}) { +function createTestClient() { const lifecycleListeners = new Set<(event: any) => void>(); const configListeners = new Set<(event: any) => void>(); const terminalListeners = new Set<(event: any) => void>(); - let domainResubscribe: (() => void) | undefined; - - const getListingSnapshot = vi.fn( - options?.getListingSnapshot ?? - (async () => - ({ - snapshotSequence: 1, - projects: [], - threads: [], - }) as any), - ); - const replayEvents = vi.fn(options?.replayEvents ?? (async () => [])); + const shellListeners = new Set<(event: any) => void>(); + let shellResubscribe: (() => void) | undefined; + let autoEmitInitialSnapshot = true; const client = { dispose: vi.fn(async () => undefined), - reconnect: vi.fn(async () => undefined), + reconnect: vi.fn(async () => { + shellResubscribe?.(); + }), server: { getConfig: vi.fn(async () => ({ environment: { @@ -49,19 +39,36 @@ function createTestClient(options?: { }, orchestration: { getSnapshot: vi.fn(async () => undefined), - getListingSnapshot, + getThread: vi.fn(async () => undefined), dispatchCommand: vi.fn(async () => undefined), getTurnDiff: vi.fn(async () => undefined), getFullThreadDiff: vi.fn(async () => undefined), - replayEvents, - onDomainEvent: vi.fn((_: (event: any) => void, options?: { onResubscribe?: () => void }) => { - domainResubscribe = options?.onResubscribe; - return () => { - if (domainResubscribe === options?.onResubscribe) { - domainResubscribe = undefined; + subscribeShell: vi.fn( + (listener: (event: any) => void, options?: { onResubscribe?: () => void }) => { + shellListeners.add(listener); + shellResubscribe = options?.onResubscribe; + if (autoEmitInitialSnapshot) { + queueMicrotask(() => { + listener({ + kind: "snapshot", + snapshot: { + snapshotSequence: 1, + projects: [], + threads: [], + updatedAt: "2026-04-12T00:00:00.000Z", + }, + }); + }); } - }; - }), + return () => { + shellListeners.delete(listener); + if (shellResubscribe === options?.onResubscribe) { + shellResubscribe = undefined; + } + }; + }, + ), + subscribeThread: vi.fn(() => () => undefined), }, terminal: { open: vi.fn(async () => undefined), @@ -100,8 +107,9 @@ function createTestClient(options?: { return { client, - getListingSnapshot, - replayEvents, + setAutoEmitInitialSnapshot: (value: boolean) => { + autoEmitInitialSnapshot = value; + }, emitWelcome: (environmentId: EnvironmentId) => { for (const listener of lifecycleListeners) { listener({ @@ -126,17 +134,37 @@ function createTestClient(options?: { }); } }, - triggerDomainResubscribe: () => { - domainResubscribe?.(); + emitShellSnapshot: (snapshotSequence: number) => { + for (const listener of shellListeners) { + listener({ + kind: "snapshot", + snapshot: { + snapshotSequence, + projects: [], + threads: [], + updatedAt: "2026-04-12T00:00:00.000Z", + }, + }); + } }, }; } +function baseHandlers() { + return { + applyEventBatch: vi.fn(), + syncListingSnapshot: vi.fn(), + applyShellEvent: vi.fn(), + syncShellSnapshot: vi.fn(), + applyTerminalEvent: vi.fn(), + }; +} + describe("createEnvironmentConnection", () => { - it("bootstraps a listing snapshot immediately for a new connection", async () => { + it("resolves ensureBootstrapped once the shell snapshot arrives", async () => { const environmentId = EnvironmentId.make("env-1"); - const { client, getListingSnapshot } = createTestClient(); - const syncListingSnapshot = vi.fn(); + const { client } = createTestClient(); + const handlers = baseHandlers(); const connection = createEnvironmentConnection({ kind: "saved", @@ -151,16 +179,12 @@ describe("createEnvironmentConnection", () => { environmentId, }, client, - applyEventBatch: vi.fn(), - syncListingSnapshot, - applyTerminalEvent: vi.fn(), + ...handlers, }); - await Promise.resolve(); - await Promise.resolve(); + await connection.ensureBootstrapped(); - expect(getListingSnapshot).toHaveBeenCalledTimes(1); - expect(syncListingSnapshot).toHaveBeenCalledWith( + expect(handlers.syncShellSnapshot).toHaveBeenCalledWith( expect.objectContaining({ snapshotSequence: 1 }), environmentId, ); @@ -185,9 +209,7 @@ describe("createEnvironmentConnection", () => { environmentId, }, client, - applyEventBatch: vi.fn(), - syncListingSnapshot: vi.fn(), - applyTerminalEvent: vi.fn(), + ...baseHandlers(), }); expect(() => emitWelcome(EnvironmentId.make("env-2"))).toThrow( @@ -197,14 +219,11 @@ describe("createEnvironmentConnection", () => { await connection.dispose(); }); - it("rejects ensureBootstrapped when snapshot recovery fails", async () => { + it("waits for a fresh shell snapshot after reconnect", async () => { const environmentId = EnvironmentId.make("env-1"); - const snapshotError = new Error("snapshot failed"); - const { client } = createTestClient({ - getListingSnapshot: async () => { - throw snapshotError; - }, - }); + const { client, setAutoEmitInitialSnapshot, emitShellSnapshot } = createTestClient(); + setAutoEmitInitialSnapshot(false); + const handlers = baseHandlers(); const connection = createEnvironmentConnection({ kind: "saved", @@ -219,95 +238,32 @@ describe("createEnvironmentConnection", () => { environmentId, }, client, - applyEventBatch: vi.fn(), - syncListingSnapshot: vi.fn(), - applyTerminalEvent: vi.fn(), + ...handlers, }); - await expect(connection.ensureBootstrapped()).rejects.toThrow("snapshot failed"); - - await connection.dispose(); - }); - - it("retries replay recovery after transport disconnects during resubscribe", async () => { - const environmentId = EnvironmentId.make("env-1"); - let replayAttempts = 0; - const applyEventBatch = vi.fn(); - const { client, replayEvents, triggerDomainResubscribe } = createTestClient({ - replayEvents: async () => { - replayAttempts += 1; - if (replayAttempts === 1) { - throw new Error("SocketCloseError: 1006"); - } - - return [ - { - sequence: 2, - type: "thread.created", - payload: {}, - }, - ]; - }, + let resolved = false; + const bootstrapPromise = connection.ensureBootstrapped().then(() => { + resolved = true; }); - const connection = createEnvironmentConnection({ - kind: "saved", - knownEnvironment: { - id: "env-1", - label: "Remote env", - source: "manual", - target: { - httpBaseUrl: "http://example.test", - wsBaseUrl: "ws://example.test", - }, - environmentId, - }, - client, - applyEventBatch, - syncListingSnapshot: vi.fn(), - applyTerminalEvent: vi.fn(), - }); - - await Promise.resolve(); await Promise.resolve(); + expect(resolved).toBe(false); - triggerDomainResubscribe(); + emitShellSnapshot(2); + await bootstrapPromise; - await vi.waitFor(() => { - expect(replayEvents).toHaveBeenCalledTimes(2); - expect(applyEventBatch).toHaveBeenCalledWith( - [ - expect.objectContaining({ - sequence: 2, - }), - ], - environmentId, - ); - }); + expect(handlers.syncShellSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ snapshotSequence: 2 }), + environmentId, + ); await connection.dispose(); }); - it("swallows replay recovery failures triggered by resubscribe", async () => { - const environmentId = EnvironmentId.make("env-1"); - const snapshotError = new Error("snapshot failed"); - let snapshotCalls = 0; - const { client, triggerDomainResubscribe } = createTestClient({ - getListingSnapshot: async () => { - snapshotCalls += 1; - if (snapshotCalls === 1) { - return { - snapshotSequence: 1, - projects: [], - threads: [], - } as any; - } - throw snapshotError; - }, - replayEvents: async () => { - throw new Error("SocketCloseError: 1006"); - }, - }); + it("forwards shell stream events to applyShellEvent", async () => { + const environmentId = EnvironmentId.make("env-1"); + const { client, emitShellSnapshot } = createTestClient(); + const handlers = baseHandlers(); const connection = createEnvironmentConnection({ kind: "saved", @@ -322,26 +278,14 @@ describe("createEnvironmentConnection", () => { environmentId, }, client, - applyEventBatch: vi.fn(), - syncListingSnapshot: vi.fn(), - applyTerminalEvent: vi.fn(), + ...handlers, }); - await Promise.resolve(); - await Promise.resolve(); - - const onUnhandledRejection = vi.fn(); - process.on("unhandledRejection", onUnhandledRejection); + await connection.ensureBootstrapped(); - try { - triggerDomainResubscribe(); - await new Promise((resolve) => setTimeout(resolve, 0)); - await new Promise((resolve) => setTimeout(resolve, 0)); - } finally { - process.off("unhandledRejection", onUnhandledRejection); - } + emitShellSnapshot(5); - expect(onUnhandledRejection).not.toHaveBeenCalled(); + expect(handlers.syncShellSnapshot).toHaveBeenCalledTimes(2); await connection.dispose(); }); diff --git a/apps/web/src/environments/runtime/connection.ts b/apps/web/src/environments/runtime/connection.ts index 7fca0f6383f..9f7617cf96b 100644 --- a/apps/web/src/environments/runtime/connection.ts +++ b/apps/web/src/environments/runtime/connection.ts @@ -2,28 +2,16 @@ import type { EnvironmentId, OrchestrationEvent, OrchestrationListingSnapshot, + OrchestrationShellSnapshot, + OrchestrationShellStreamEvent, ServerConfig, ServerLifecycleWelcomePayload, TerminalEvent, } from "@marcode/contracts"; import type { KnownEnvironment } from "@marcode/client-runtime"; -import { - deriveReplayRetryDecision, - type OrchestrationRecoveryReason, -} from "../../orchestrationRecovery"; -import { - createOrchestrationRecoveryCoordinator, - type ReplayRetryTracker, -} from "../../orchestrationRecovery"; -import { isTransportConnectionErrorMessage } from "~/rpc/transportError"; import type { WsRpcClient } from "~/rpc/wsRpcClient"; -const REPLAY_RECOVERY_RETRY_DELAY_MS = 100; -const MAX_NO_PROGRESS_REPLAY_RETRIES = 3; -const RECOVERY_TRANSPORT_RETRY_DELAY_MS = 250; -const MAX_RECOVERY_TRANSPORT_RETRIES = 20; - export interface EnvironmentConnection { readonly kind: "primary" | "saved"; readonly environmentId: EnvironmentId; @@ -43,6 +31,14 @@ interface OrchestrationHandlers { listing: OrchestrationListingSnapshot, environmentId: EnvironmentId, ) => void; + readonly applyShellEvent: ( + event: OrchestrationShellStreamEvent, + environmentId: EnvironmentId, + ) => void; + readonly syncShellSnapshot: ( + snapshot: OrchestrationShellSnapshot, + environmentId: EnvironmentId, + ) => void; readonly applyTerminalEvent: (event: TerminalEvent, environmentId: EnvironmentId) => void; } @@ -55,33 +51,31 @@ interface EnvironmentConnectionInput extends OrchestrationHandlers { readonly onWelcome?: (payload: ServerLifecycleWelcomePayload) => void; } -function createSnapshotBootstrapController(input: { - readonly isBootstrapped: () => boolean; - readonly runSnapshotRecovery: ( - reason: Extract, - ) => Promise; -}) { - let inFlight: Promise | null = null; +function createBootstrapGate() { + let resolve: (() => void) | null = null; + let reject: ((error: unknown) => void) | null = null; + let promise = new Promise((nextResolve, nextReject) => { + resolve = nextResolve; + reject = nextReject; + }); return { - ensureSnapshotRecovery( - reason: Extract, - ): Promise { - if (input.isBootstrapped()) { - return Promise.resolve(); - } - - if (inFlight !== null) { - return inFlight; - } - - const nextInFlight = input.runSnapshotRecovery(reason).finally(() => { - if (inFlight === nextInFlight) { - inFlight = null; - } + wait: () => promise, + resolve: () => { + resolve?.(); + resolve = null; + reject = null; + }, + reject: (error: unknown) => { + reject?.(error); + resolve = null; + reject = null; + }, + reset: () => { + promise = new Promise((nextResolve, nextReject) => { + resolve = nextResolve; + reject = nextReject; }); - inFlight = nextInFlight; - return inFlight; }, }; } @@ -89,10 +83,6 @@ function createSnapshotBootstrapController(input: { export function createEnvironmentConnection( input: EnvironmentConnectionInput, ): EnvironmentConnection { - const recovery = createOrchestrationRecoveryCoordinator(); - let replayRetryTracker: ReplayRetryTracker | null = null; - const pendingDomainEvents: OrchestrationEvent[] = []; - let flushPendingDomainEventsScheduled = false; const environmentId = input.knownEnvironment.environmentId; if (!environmentId) { @@ -102,6 +92,7 @@ export function createEnvironmentConnection( } let disposed = false; + const bootstrapGate = createBootstrapGate(); const observeEnvironmentIdentity = (nextEnvironmentId: EnvironmentId, source: string) => { if (environmentId !== nextEnvironmentId) { @@ -111,148 +102,6 @@ export function createEnvironmentConnection( } }; - const flushPendingDomainEvents = () => { - flushPendingDomainEventsScheduled = false; - if (disposed || pendingDomainEvents.length === 0) { - return; - } - - const events = pendingDomainEvents.splice(0, pendingDomainEvents.length); - const nextEvents = recovery.markEventBatchApplied(events); - if (nextEvents.length === 0) { - return; - } - input.applyEventBatch(nextEvents, environmentId); - }; - - const schedulePendingDomainEventFlush = () => { - if (flushPendingDomainEventsScheduled) { - return; - } - - flushPendingDomainEventsScheduled = true; - queueMicrotask(flushPendingDomainEvents); - }; - - const retryTransportRecoveryOperation = async (operation: () => Promise): Promise => { - for (let attempt = 0; ; attempt += 1) { - try { - return await operation(); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if ( - disposed || - !isTransportConnectionErrorMessage(message) || - attempt >= MAX_RECOVERY_TRANSPORT_RETRIES - 1 - ) { - throw error; - } - - await new Promise((resolve) => { - setTimeout(resolve, RECOVERY_TRANSPORT_RETRY_DELAY_MS); - }); - - if (disposed) { - throw error; - } - } - } - }; - const scheduleReplayRecovery = (reason: "sequence-gap" | "resubscribe") => { - void runReplayRecovery(reason).catch(() => undefined); - }; - - const runReplayRecovery = async (reason: "sequence-gap" | "resubscribe"): Promise => { - if (!recovery.beginReplayRecovery(reason)) { - return; - } - - const fromSequenceExclusive = recovery.getState().latestSequence; - try { - const events = await retryTransportRecoveryOperation(() => - input.client.orchestration.replayEvents({ fromSequenceExclusive }), - ); - if (!disposed) { - const nextEvents = recovery.markEventBatchApplied(events); - if (nextEvents.length > 0) { - input.applyEventBatch(nextEvents, environmentId); - } - } - } catch { - replayRetryTracker = null; - recovery.failReplayRecovery(); - if (disposed) { - return; - } - await snapshotBootstrap.ensureSnapshotRecovery("replay-failed"); - return; - } - - if (disposed) { - return; - } - - const replayCompletion = recovery.completeReplayRecovery(); - const retryDecision = deriveReplayRetryDecision({ - previousTracker: replayRetryTracker, - completion: replayCompletion, - recoveryState: recovery.getState(), - baseDelayMs: REPLAY_RECOVERY_RETRY_DELAY_MS, - maxNoProgressRetries: MAX_NO_PROGRESS_REPLAY_RETRIES, - }); - replayRetryTracker = retryDecision.tracker; - - if (retryDecision.shouldRetry) { - if (retryDecision.delayMs > 0) { - await new Promise((resolve) => { - setTimeout(resolve, retryDecision.delayMs); - }); - if (disposed) { - return; - } - } - scheduleReplayRecovery(reason); - } else if (replayCompletion.shouldReplay && import.meta.env.MODE !== "test") { - console.warn( - "[orchestration-recovery]", - "Stopping replay recovery after no-progress retries.", - { - environmentId, - state: recovery.getState(), - }, - ); - } - }; - - const runSnapshotRecovery = async ( - reason: Extract, - ): Promise => { - const started = recovery.beginSnapshotRecovery(reason); - if (!started) { - return; - } - - try { - const snapshot = await retryTransportRecoveryOperation(() => - input.client.orchestration.getListingSnapshot(), - ); - if (!disposed) { - input.syncListingSnapshot(snapshot, environmentId); - if (recovery.completeSnapshotRecovery(snapshot.snapshotSequence)) { - scheduleReplayRecovery("sequence-gap"); - } - } - } catch (error) { - recovery.failSnapshotRecovery(); - throw error; - } - }; - - const snapshotBootstrap = createSnapshotBootstrapController({ - isBootstrapped: () => recovery.getState().bootstrapped, - runSnapshotRecovery, - }); - const unsubLifecycle = input.client.server.subscribeLifecycle( (event: Parameters[0]>[0]) => { if (event.type !== "welcome") { @@ -276,26 +125,30 @@ export function createEnvironmentConnection( }, ); - const unsubDomainEvent = input.client.orchestration.onDomainEvent( - (event: Parameters[0]>[0]) => { - const action = recovery.classifyDomainEvent(event.sequence); - if (action === "apply") { - pendingDomainEvents.push(event); - schedulePendingDomainEventFlush(); + const runSnapshotRecovery = async (): Promise => { + const listing = await input.client.orchestration.getListingSnapshot(); + if (disposed) { + return; + } + input.syncListingSnapshot(listing, environmentId); + }; + + const unsubShell = input.client.orchestration.subscribeShell( + (item: Parameters[0]>[0]) => { + if (item.kind === "snapshot") { + input.syncShellSnapshot(item.snapshot, environmentId); + void runSnapshotRecovery().catch(() => undefined); + bootstrapGate.resolve(); return; } - if (action === "recover") { - flushPendingDomainEvents(); - scheduleReplayRecovery("sequence-gap"); - } + input.applyShellEvent(item, environmentId); }, { onResubscribe: () => { if (disposed) { return; } - flushPendingDomainEvents(); - scheduleReplayRecovery("resubscribe"); + bootstrapGate.reset(); }, }, ); @@ -306,13 +159,9 @@ export function createEnvironmentConnection( }, ); - void snapshotBootstrap.ensureSnapshotRecovery("bootstrap").catch(() => undefined); - const cleanup = () => { disposed = true; - flushPendingDomainEventsScheduled = false; - pendingDomainEvents.length = 0; - unsubDomainEvent(); + unsubShell(); unsubTerminalEvent(); unsubLifecycle(); unsubConfig(); @@ -323,11 +172,17 @@ export function createEnvironmentConnection( environmentId, knownEnvironment: input.knownEnvironment, client: input.client, - ensureBootstrapped: () => snapshotBootstrap.ensureSnapshotRecovery("bootstrap"), + ensureBootstrapped: () => bootstrapGate.wait(), reconnect: async () => { - await input.client.reconnect(); - await input.refreshMetadata?.(); - await snapshotBootstrap.ensureSnapshotRecovery("bootstrap"); + bootstrapGate.reset(); + try { + await input.client.reconnect(); + await input.refreshMetadata?.(); + await bootstrapGate.wait(); + } catch (error) { + bootstrapGate.reject(error); + throw error; + } }, dispose: async () => { cleanup(); diff --git a/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts b/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts new file mode 100644 index 00000000000..0dfd14244db --- /dev/null +++ b/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts @@ -0,0 +1,300 @@ +import { QueryClient } from "@tanstack/react-query"; +import { + EnvironmentId, + ProjectId, + ThreadId, + TurnId, + type OrchestrationShellSnapshot, +} from "@marcode/contracts"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mockSubscribeThread = vi.fn(); +const mockThreadUnsubscribe = vi.fn(); +const mockCreateEnvironmentConnection = vi.fn(); +const mockCreateWsRpcClient = vi.fn(); +const mockWaitForSavedEnvironmentRegistryHydration = vi.fn(); +const mockListSavedEnvironmentRecords = vi.fn(); +const mockSavedEnvironmentRegistrySubscribe = vi.fn(); + +function MockWsTransport() { + return undefined; +} + +vi.mock("../primary", () => ({ + getPrimaryKnownEnvironment: vi.fn(() => ({ + id: "env-1", + label: "Primary environment", + source: "window-origin", + target: { + httpBaseUrl: "http://127.0.0.1:3000/", + wsBaseUrl: "ws://127.0.0.1:3000/", + }, + environmentId: EnvironmentId.make("env-1"), + })), +})); + +vi.mock("./catalog", () => ({ + getSavedEnvironmentRecord: vi.fn(), + hasSavedEnvironmentRegistryHydrated: vi.fn(() => true), + listSavedEnvironmentRecords: mockListSavedEnvironmentRecords, + persistSavedEnvironmentRecord: vi.fn(), + readSavedEnvironmentBearerToken: vi.fn(), + removeSavedEnvironmentBearerToken: vi.fn(), + useSavedEnvironmentRegistryStore: { + subscribe: mockSavedEnvironmentRegistrySubscribe, + getState: () => ({ + upsert: vi.fn(), + remove: vi.fn(), + markConnected: vi.fn(), + }), + }, + useSavedEnvironmentRuntimeStore: { + getState: () => ({ + ensure: vi.fn(), + patch: vi.fn(), + clear: vi.fn(), + }), + }, + waitForSavedEnvironmentRegistryHydration: mockWaitForSavedEnvironmentRegistryHydration, + writeSavedEnvironmentBearerToken: vi.fn(), +})); + +vi.mock("./connection", () => ({ + createEnvironmentConnection: mockCreateEnvironmentConnection, +})); + +vi.mock("../../rpc/wsRpcClient", () => ({ + createWsRpcClient: mockCreateWsRpcClient, +})); + +vi.mock("../../rpc/wsTransport", () => ({ + WsTransport: MockWsTransport, +})); + +function makeThreadShellSnapshot(params: { + readonly threadId: ThreadId; + readonly sessionStatus?: + | "idle" + | "starting" + | "running" + | "ready" + | "interrupted" + | "stopped" + | "error"; + readonly hasPendingApprovals?: boolean; + readonly hasPendingUserInput?: boolean; + readonly hasActionableProposedPlan?: boolean; +}): OrchestrationShellSnapshot { + const projectId = ProjectId.make("project-1"); + const turnId = TurnId.make("turn-1"); + + return { + snapshotSequence: 1, + projects: [], + updatedAt: "2026-04-13T00:00:00.000Z", + threads: [ + { + id: params.threadId, + projectId, + title: "Thread", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: + params.sessionStatus === "running" + ? { + turnId, + state: "running", + requestedAt: "2026-04-13T00:00:00.000Z", + startedAt: "2026-04-13T00:00:01.000Z", + completedAt: null, + assistantMessageId: null, + } + : null, + createdAt: "2026-04-13T00:00:00.000Z", + updatedAt: "2026-04-13T00:00:00.000Z", + archivedAt: null, + deletedAt: null, + additionalDirectories: [], + session: params.sessionStatus + ? { + threadId: params.threadId, + status: params.sessionStatus, + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: params.sessionStatus === "running" ? turnId : null, + lastError: null, + updatedAt: "2026-04-13T00:00:00.000Z", + } + : null, + latestUserMessageAt: null, + hasPendingApprovals: params.hasPendingApprovals ?? false, + hasPendingUserInput: params.hasPendingUserInput ?? false, + hasActionableProposedPlan: params.hasActionableProposedPlan ?? false, + }, + ], + }; +} + +describe("retainThreadDetailSubscription", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.resetModules(); + vi.clearAllMocks(); + + mockThreadUnsubscribe.mockImplementation(() => undefined); + mockSubscribeThread.mockImplementation(() => mockThreadUnsubscribe); + mockCreateWsRpcClient.mockReturnValue({ + orchestration: { + subscribeThread: mockSubscribeThread, + }, + }); + mockCreateEnvironmentConnection.mockImplementation((input) => ({ + kind: input.kind, + environmentId: input.knownEnvironment.environmentId, + knownEnvironment: input.knownEnvironment, + client: input.client, + ensureBootstrapped: vi.fn(async () => undefined), + reconnect: vi.fn(async () => undefined), + dispose: vi.fn(async () => undefined), + })); + mockSavedEnvironmentRegistrySubscribe.mockReturnValue(() => undefined); + mockWaitForSavedEnvironmentRegistryHydration.mockResolvedValue(undefined); + mockListSavedEnvironmentRecords.mockReturnValue([]); + }); + + afterEach(async () => { + const { resetEnvironmentServiceForTests } = await import("./service"); + await resetEnvironmentServiceForTests(); + vi.useRealTimers(); + }); + + it("keeps thread detail subscriptions warm across releases until idle eviction", async () => { + const { + retainThreadDetailSubscription, + startEnvironmentConnectionService, + resetEnvironmentServiceForTests, + } = await import("./service"); + + const stop = startEnvironmentConnectionService(new QueryClient()); + const environmentId = EnvironmentId.make("env-1"); + const threadId = ThreadId.make("thread-1"); + + const releaseFirst = retainThreadDetailSubscription(environmentId, threadId); + expect(mockSubscribeThread).toHaveBeenCalledTimes(1); + + releaseFirst(); + expect(mockThreadUnsubscribe).not.toHaveBeenCalled(); + + const releaseSecond = retainThreadDetailSubscription(environmentId, threadId); + expect(mockSubscribeThread).toHaveBeenCalledTimes(1); + + releaseSecond(); + await vi.advanceTimersByTimeAsync(2 * 60 * 1000); + expect(mockThreadUnsubscribe).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(28 * 60 * 1000); + expect(mockThreadUnsubscribe).toHaveBeenCalledTimes(1); + + stop(); + await resetEnvironmentServiceForTests(); + }); + + it("keeps non-idle thread detail subscriptions attached until the thread becomes idle", async () => { + const { + retainThreadDetailSubscription, + startEnvironmentConnectionService, + resetEnvironmentServiceForTests, + } = await import("./service"); + + const stop = startEnvironmentConnectionService(new QueryClient()); + const environmentId = EnvironmentId.make("env-1"); + const threadId = ThreadId.make("thread-active"); + + const connectionInput = mockCreateEnvironmentConnection.mock.calls[0]?.[0]; + expect(connectionInput).toBeDefined(); + + connectionInput.syncShellSnapshot( + makeThreadShellSnapshot({ + threadId, + sessionStatus: "ready", + hasPendingApprovals: true, + }), + environmentId, + ); + + const release = retainThreadDetailSubscription(environmentId, threadId); + expect(mockSubscribeThread).toHaveBeenCalledTimes(1); + + release(); + await vi.advanceTimersByTimeAsync(30 * 60 * 1000); + expect(mockThreadUnsubscribe).not.toHaveBeenCalled(); + + connectionInput.applyShellEvent( + { + kind: "thread-upserted", + sequence: 2, + thread: makeThreadShellSnapshot({ + threadId, + sessionStatus: "idle", + }).threads[0]!, + }, + environmentId, + ); + + await vi.advanceTimersByTimeAsync(30 * 60 * 1000); + expect(mockThreadUnsubscribe).toHaveBeenCalledTimes(1); + + stop(); + await resetEnvironmentServiceForTests(); + }); + + it("allows a larger idle cache before capacity eviction starts", async () => { + const { + retainThreadDetailSubscription, + startEnvironmentConnectionService, + resetEnvironmentServiceForTests, + } = await import("./service"); + + const stop = startEnvironmentConnectionService(new QueryClient()); + const environmentId = EnvironmentId.make("env-1"); + + for (let index = 0; index < 12; index += 1) { + const release = retainThreadDetailSubscription( + environmentId, + ThreadId.make(`thread-${index + 1}`), + ); + release(); + } + + expect(mockThreadUnsubscribe).not.toHaveBeenCalled(); + + stop(); + await resetEnvironmentServiceForTests(); + }); + + it("disposes cached thread detail subscriptions when the environment service resets", async () => { + const { + retainThreadDetailSubscription, + startEnvironmentConnectionService, + resetEnvironmentServiceForTests, + } = await import("./service"); + + const stop = startEnvironmentConnectionService(new QueryClient()); + const environmentId = EnvironmentId.make("env-1"); + const threadId = ThreadId.make("thread-2"); + + const release = retainThreadDetailSubscription(environmentId, threadId); + release(); + + await resetEnvironmentServiceForTests(); + expect(mockThreadUnsubscribe).toHaveBeenCalledTimes(1); + + stop(); + }); +}); diff --git a/apps/web/src/environments/runtime/service.ts b/apps/web/src/environments/runtime/service.ts index fe840196004..0299b51e361 100644 --- a/apps/web/src/environments/runtime/service.ts +++ b/apps/web/src/environments/runtime/service.ts @@ -3,6 +3,8 @@ import { type EnvironmentId, type OrchestrationEvent, type OrchestrationListingSnapshot, + type OrchestrationShellSnapshot, + type OrchestrationShellStreamEvent, type ServerConfig, type TerminalEvent, ThreadId, @@ -12,9 +14,7 @@ import { Throttler } from "@tanstack/react-pacer"; import { createKnownEnvironment, getKnownEnvironmentWsBaseUrl, - scopedProjectKey, scopedThreadKey, - scopeProjectRef, scopeThreadRef, } from "@marcode/client-runtime"; @@ -60,6 +60,7 @@ import { useStore, selectEnvironmentState, selectProjectsAcrossEnvironments, + selectSidebarThreadSummaryByRef, selectThreadByRef, selectThreadsAcrossEnvironments, } from "~/store"; @@ -68,6 +69,7 @@ import { useTerminalStateStore } from "~/terminalStateStore"; import { useUiStateStore } from "~/uiStateStore"; import { WsTransport } from "../../rpc/wsTransport"; import { createWsRpcClient, type WsRpcClient } from "../../rpc/wsRpcClient"; +import { derivePhysicalProjectKey } from "../../logicalProject"; type EnvironmentServiceState = { readonly queryClient: QueryClient; @@ -76,12 +78,307 @@ type EnvironmentServiceState = { stop: () => void; }; +type ThreadDetailSubscriptionEntry = { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + unsubscribe: () => void; + unsubscribeConnectionListener: (() => void) | null; + refCount: number; + lastAccessedAt: number; + evictionTimeoutId: ReturnType | null; +}; + const environmentConnections = new Map(); const environmentConnectionListeners = new Set<() => void>(); +const threadDetailSubscriptions = new Map(); let activeService: EnvironmentServiceState | null = null; let needsProviderInvalidation = false; +// Thread detail subscription cache policy: +// - Active consumers keep a subscription retained via refCount. +// - Released subscriptions stay warm for a longer idle TTL to avoid churn +// while moving around the UI. +// - Threads with active work or pending user action are sticky and are never +// evicted while they remain non-idle. +// - Capacity eviction only targets idle cached subscriptions. +const THREAD_DETAIL_SUBSCRIPTION_IDLE_EVICTION_MS = 15 * 60 * 1000; +const MAX_CACHED_THREAD_DETAIL_SUBSCRIPTIONS = 32; +const NOOP = () => undefined; + +function getThreadDetailSubscriptionKey(environmentId: EnvironmentId, threadId: ThreadId): string { + return scopedThreadKey(scopeThreadRef(environmentId, threadId)); +} + +function clearThreadDetailSubscriptionEviction( + entry: ThreadDetailSubscriptionEntry, +): ThreadDetailSubscriptionEntry { + if (entry.evictionTimeoutId !== null) { + clearTimeout(entry.evictionTimeoutId); + entry.evictionTimeoutId = null; + } + return entry; +} + +function isNonIdleThreadDetailSubscription(entry: ThreadDetailSubscriptionEntry): boolean { + const threadRef = scopeThreadRef(entry.environmentId, entry.threadId); + const state = useStore.getState(); + const sidebarThread = selectSidebarThreadSummaryByRef(state, threadRef); + + // Prefer shell/sidebar state first because it carries the coarse thread + // readiness flags used throughout the UI (pending approvals/input/plan). + if (sidebarThread) { + if ( + sidebarThread.hasPendingApprovals || + sidebarThread.hasPendingUserInput || + sidebarThread.hasActionableProposedPlan + ) { + return true; + } + + const orchestrationStatus = sidebarThread.session?.orchestrationStatus; + if ( + orchestrationStatus && + orchestrationStatus !== "idle" && + orchestrationStatus !== "stopped" + ) { + return true; + } + + if (sidebarThread.latestTurn?.state === "running") { + return true; + } + } + + const thread = selectThreadByRef(state, threadRef); + if (!thread) { + return false; + } + + const orchestrationStatus = thread.session?.orchestrationStatus; + return ( + Boolean( + orchestrationStatus && orchestrationStatus !== "idle" && orchestrationStatus !== "stopped", + ) || + thread.latestTurn?.state === "running" || + thread.pendingSourceProposedPlan !== undefined + ); +} + +function shouldEvictThreadDetailSubscription(entry: ThreadDetailSubscriptionEntry): boolean { + return entry.refCount === 0 && !isNonIdleThreadDetailSubscription(entry); +} + +function attachThreadDetailSubscription(entry: ThreadDetailSubscriptionEntry): boolean { + if (entry.unsubscribeConnectionListener !== null) { + entry.unsubscribeConnectionListener(); + entry.unsubscribeConnectionListener = null; + } + if (entry.unsubscribe !== NOOP) { + return true; + } + + const connection = readEnvironmentConnection(entry.environmentId); + if (!connection) { + return false; + } + + entry.unsubscribe = connection.client.orchestration.subscribeThread( + { threadId: entry.threadId }, + (item) => { + if (item.kind === "snapshot") { + useStore.getState().syncServerThreadDetail(item.snapshot.thread, entry.environmentId); + return; + } + applyEnvironmentThreadDetailEvent(item.event, entry.environmentId); + }, + ); + return true; +} + +function watchThreadDetailSubscriptionConnection(entry: ThreadDetailSubscriptionEntry): void { + if (entry.unsubscribeConnectionListener !== null) { + return; + } + + entry.unsubscribeConnectionListener = subscribeEnvironmentConnections(() => { + if (attachThreadDetailSubscription(entry)) { + entry.lastAccessedAt = Date.now(); + } + }); + attachThreadDetailSubscription(entry); +} + +function disposeThreadDetailSubscriptionByKey(key: string): boolean { + const entry = threadDetailSubscriptions.get(key); + if (!entry) { + return false; + } + + clearThreadDetailSubscriptionEviction(entry); + entry.unsubscribeConnectionListener?.(); + entry.unsubscribeConnectionListener = null; + threadDetailSubscriptions.delete(key); + entry.unsubscribe(); + entry.unsubscribe = NOOP; + return true; +} + +function disposeThreadDetailSubscriptionsForEnvironment(environmentId: EnvironmentId): void { + for (const [key, entry] of threadDetailSubscriptions) { + if (entry.environmentId === environmentId) { + disposeThreadDetailSubscriptionByKey(key); + } + } +} + +function reconcileThreadDetailSubscriptionsForEnvironment( + environmentId: EnvironmentId, + threadIds: ReadonlyArray, +): void { + const activeThreadIds = new Set(threadIds); + for (const [key, entry] of threadDetailSubscriptions) { + if (entry.environmentId === environmentId && !activeThreadIds.has(entry.threadId)) { + disposeThreadDetailSubscriptionByKey(key); + } + } +} + +function scheduleThreadDetailSubscriptionEviction(entry: ThreadDetailSubscriptionEntry): void { + clearThreadDetailSubscriptionEviction(entry); + if (!shouldEvictThreadDetailSubscription(entry)) { + return; + } + + entry.evictionTimeoutId = setTimeout(() => { + const currentEntry = threadDetailSubscriptions.get( + getThreadDetailSubscriptionKey(entry.environmentId, entry.threadId), + ); + if (!currentEntry) { + return; + } + + currentEntry.evictionTimeoutId = null; + if (!shouldEvictThreadDetailSubscription(currentEntry)) { + return; + } + disposeThreadDetailSubscriptionByKey( + getThreadDetailSubscriptionKey(entry.environmentId, entry.threadId), + ); + }, THREAD_DETAIL_SUBSCRIPTION_IDLE_EVICTION_MS); +} + +function evictIdleThreadDetailSubscriptionsToCapacity(): void { + if (threadDetailSubscriptions.size <= MAX_CACHED_THREAD_DETAIL_SUBSCRIPTIONS) { + return; + } + + const idleEntries = [...threadDetailSubscriptions.entries()] + .filter(([, entry]) => shouldEvictThreadDetailSubscription(entry)) + .toSorted(([, left], [, right]) => left.lastAccessedAt - right.lastAccessedAt); + + for (const [key] of idleEntries) { + if (threadDetailSubscriptions.size <= MAX_CACHED_THREAD_DETAIL_SUBSCRIPTIONS) { + return; + } + disposeThreadDetailSubscriptionByKey(key); + } +} + +function reconcileThreadDetailSubscriptionEvictionState( + entry: ThreadDetailSubscriptionEntry, +): void { + clearThreadDetailSubscriptionEviction(entry); + if (!shouldEvictThreadDetailSubscription(entry)) { + return; + } + + scheduleThreadDetailSubscriptionEviction(entry); +} + +function reconcileThreadDetailSubscriptionEvictionForThread( + environmentId: EnvironmentId, + threadId: ThreadId, +): void { + const entry = threadDetailSubscriptions.get( + getThreadDetailSubscriptionKey(environmentId, threadId), + ); + if (!entry) { + return; + } + + reconcileThreadDetailSubscriptionEvictionState(entry); +} + +function reconcileThreadDetailSubscriptionEvictionForEnvironment( + environmentId: EnvironmentId, +): void { + for (const entry of threadDetailSubscriptions.values()) { + if (entry.environmentId === environmentId) { + reconcileThreadDetailSubscriptionEvictionState(entry); + } + } + evictIdleThreadDetailSubscriptionsToCapacity(); +} + +export function retainThreadDetailSubscription( + environmentId: EnvironmentId, + threadId: ThreadId, +): () => void { + const key = getThreadDetailSubscriptionKey(environmentId, threadId); + const existing = threadDetailSubscriptions.get(key); + if (existing) { + clearThreadDetailSubscriptionEviction(existing); + existing.refCount += 1; + existing.lastAccessedAt = Date.now(); + if (!attachThreadDetailSubscription(existing)) { + watchThreadDetailSubscriptionConnection(existing); + } + let released = false; + return () => { + if (released) { + return; + } + released = true; + existing.refCount = Math.max(0, existing.refCount - 1); + existing.lastAccessedAt = Date.now(); + if (existing.refCount === 0) { + reconcileThreadDetailSubscriptionEvictionState(existing); + evictIdleThreadDetailSubscriptionsToCapacity(); + } + }; + } + + const entry: ThreadDetailSubscriptionEntry = { + environmentId, + threadId, + unsubscribe: NOOP, + unsubscribeConnectionListener: null, + refCount: 1, + lastAccessedAt: Date.now(), + evictionTimeoutId: null, + }; + threadDetailSubscriptions.set(key, entry); + if (!attachThreadDetailSubscription(entry)) { + watchThreadDetailSubscriptionConnection(entry); + } + evictIdleThreadDetailSubscriptionsToCapacity(); + + let released = false; + return () => { + if (released) { + return; + } + released = true; + entry.refCount = Math.max(0, entry.refCount - 1); + entry.lastAccessedAt = Date.now(); + if (entry.refCount === 0) { + reconcileThreadDetailSubscriptionEvictionState(entry); + evictIdleThreadDetailSubscriptionsToCapacity(); + } + }; +} + function emitEnvironmentConnectionRegistryChange() { for (const listener of environmentConnectionListeners) { listener(); @@ -177,17 +474,18 @@ function coalesceOrchestrationUiEvents( return coalesced; } -function reconcileSnapshotDerivedState() { - const storeState = useStore.getState(); - const threads = selectThreadsAcrossEnvironments(storeState); - const projects = selectProjectsAcrossEnvironments(storeState); - +function syncProjectUiFromStore() { + const projects = selectProjectsAcrossEnvironments(useStore.getState()); useUiStateStore.getState().syncProjects( projects.map((project) => ({ - key: scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), + key: derivePhysicalProjectKey(project), cwd: project.cwd, })), ); +} + +function syncThreadUiFromStore() { + const threads = selectThreadsAcrossEnvironments(useStore.getState()); useUiStateStore.getState().syncThreads( threads.map((thread) => ({ key: scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), @@ -197,7 +495,13 @@ function reconcileSnapshotDerivedState() { markPromotedDraftThreadsByRef( threads.map((thread) => scopeThreadRef(thread.environmentId, thread.id)), ); +} +function reconcileSnapshotDerivedState() { + syncProjectUiFromStore(); + syncThreadUiFromStore(); + + const threads = selectThreadsAcrossEnvironments(useStore.getState()); const activeThreadKeys = collectActiveTerminalThreadIds({ snapshotThreads: threads.map((thread) => ({ key: scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), @@ -255,7 +559,7 @@ function applyRecoveredEventBatch( const projects = selectProjectsAcrossEnvironments(useStore.getState()); useUiStateStore.getState().syncProjects( projects.map((project) => ({ - key: scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), + key: derivePhysicalProjectKey(project), cwd: project.cwd, })), ); @@ -299,15 +603,74 @@ function applyRecoveredEventBatch( }; dispatchTurnNotifications(notificationTriggers, notificationSettings); } + + reconcileThreadDetailSubscriptionEvictionForEnvironment(environmentId); +} + +export function applyEnvironmentThreadDetailEvent( + event: OrchestrationEvent, + environmentId: EnvironmentId, +) { + applyRecoveredEventBatch([event], environmentId); +} + +function applyShellEvent(event: OrchestrationShellStreamEvent, environmentId: EnvironmentId) { + const threadId = + event.kind === "thread-upserted" + ? event.thread.id + : event.kind === "thread-removed" + ? event.threadId + : null; + const threadRef = threadId ? scopeThreadRef(environmentId, threadId) : null; + const previousThread = threadRef ? selectThreadByRef(useStore.getState(), threadRef) : undefined; + + useStore.getState().applyShellEvent(event, environmentId); + + switch (event.kind) { + case "project-upserted": + case "project-removed": + syncProjectUiFromStore(); + return; + case "thread-upserted": + syncThreadUiFromStore(); + if (!previousThread && threadRef) { + markPromotedDraftThreadByRef(threadRef); + } + if (previousThread?.archivedAt === null && event.thread.archivedAt !== null && threadRef) { + useTerminalStateStore.getState().removeTerminalState(threadRef); + } + reconcileThreadDetailSubscriptionEvictionForThread(environmentId, event.thread.id); + evictIdleThreadDetailSubscriptionsToCapacity(); + return; + case "thread-removed": + if (threadRef) { + disposeThreadDetailSubscriptionByKey(scopedThreadKey(threadRef)); + useComposerDraftStore.getState().clearDraftThread(threadRef); + useUiStateStore.getState().clearThreadUi(scopedThreadKey(threadRef)); + useTerminalStateStore.getState().removeTerminalState(threadRef); + } + syncThreadUiFromStore(); + return; + } } function createEnvironmentConnectionHandlers() { return { applyEventBatch: applyRecoveredEventBatch, + applyShellEvent, syncListingSnapshot: (listing: OrchestrationListingSnapshot, environmentId: EnvironmentId) => { useStore.getState().syncListingSnapshot(listing, environmentId); reconcileSnapshotDerivedState(); }, + syncShellSnapshot: (snapshot: OrchestrationShellSnapshot, environmentId: EnvironmentId) => { + useStore.getState().syncServerShellSnapshot(snapshot, environmentId); + reconcileThreadDetailSubscriptionsForEnvironment( + environmentId, + snapshot.threads.map((thread) => thread.id), + ); + reconcileThreadDetailSubscriptionEvictionForEnvironment(environmentId); + reconcileSnapshotDerivedState(); + }, applyTerminalEvent: (event: TerminalEvent, environmentId: EnvironmentId) => { const threadRef = scopeThreadRef(environmentId, ThreadId.make(event.threadId)); const serverThread = selectThreadByRef(useStore.getState(), threadRef); @@ -414,6 +777,7 @@ async function removeConnection(environmentId: EnvironmentId): Promise return false; } + disposeThreadDetailSubscriptionsForEnvironment(environmentId); environmentConnections.delete(environmentId); emitEnvironmentConnectionRegistryChange(); await connection.dispose(); @@ -742,6 +1106,9 @@ export function startEnvironmentConnectionService(queryClient: QueryClient): () export async function resetEnvironmentServiceForTests(): Promise { stopActiveService(); + for (const key of Array.from(threadDetailSubscriptions.keys())) { + disposeThreadDetailSubscriptionByKey(key); + } await Promise.all( [...environmentConnections.keys()].map((environmentId) => removeConnection(environmentId)), ); diff --git a/apps/web/src/filePathDisplay.test.ts b/apps/web/src/filePathDisplay.test.ts new file mode 100644 index 00000000000..c196b5677b5 --- /dev/null +++ b/apps/web/src/filePathDisplay.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; + +import { formatWorkspaceRelativePath } from "./filePathDisplay"; + +describe("formatWorkspaceRelativePath", () => { + it("formats absolute workspace paths from the workspace root", () => { + expect( + formatWorkspaceRelativePath( + "C:/Users/mike/dev-stuff/t3code/apps/web/src/session-logic.ts:501", + "C:/Users/mike/dev-stuff/t3code", + ), + ).toBe("t3code/apps/web/src/session-logic.ts:501"); + }); + + it("prefixes relative paths with the workspace root label", () => { + expect( + formatWorkspaceRelativePath( + "apps/web/src/session-logic.ts:501", + "C:/Users/mike/dev-stuff/t3code", + ), + ).toBe("t3code/apps/web/src/session-logic.ts:501"); + }); + + it("keeps paths already rooted at the workspace label stable", () => { + expect( + formatWorkspaceRelativePath( + "t3code/apps/web/src/session-logic.ts:501", + "C:/Users/mike/dev-stuff/t3code", + ), + ).toBe("t3code/apps/web/src/session-logic.ts:501"); + }); + + it("preserves columns when present", () => { + expect( + formatWorkspaceRelativePath( + "/C:/Users/mike/dev-stuff/t3code/apps/web/src/session-logic.ts:501:9", + "C:/Users/mike/dev-stuff/t3code", + ), + ).toBe("t3code/apps/web/src/session-logic.ts:501:9"); + }); +}); diff --git a/apps/web/src/filePathDisplay.ts b/apps/web/src/filePathDisplay.ts new file mode 100644 index 00000000000..5a6e2a02e10 --- /dev/null +++ b/apps/web/src/filePathDisplay.ts @@ -0,0 +1,57 @@ +import { splitPathAndPosition } from "./terminal-links"; + +function normalizePathSeparators(path: string): string { + return path.replaceAll("\\", "/"); +} + +function canonicalizeWindowsDrivePath(path: string): string { + return /^\/[A-Za-z]:\//.test(path) ? path.slice(1) : path; +} + +function trimTrailingPathSeparators(path: string): string { + return path.replace(/[\\/]+$/, ""); +} + +function basenameOfPath(path: string): string { + const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); + return separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path; +} + +function stripRelativePrefixes(path: string): string { + return path.replace(/^\.\/+/, "").replace(/^\/+/, ""); +} + +export function formatWorkspaceRelativePath( + pathWithPosition: string, + workspaceRoot: string | undefined, +): string { + const { path, line, column } = splitPathAndPosition(pathWithPosition); + const normalizedPath = canonicalizeWindowsDrivePath(normalizePathSeparators(path)); + + let displayPath = normalizedPath; + if (workspaceRoot) { + const normalizedWorkspaceRoot = canonicalizeWindowsDrivePath( + normalizePathSeparators(trimTrailingPathSeparators(workspaceRoot)), + ); + const workspaceLabel = basenameOfPath(normalizedWorkspaceRoot); + const pathForCompare = normalizedPath.toLowerCase(); + const workspaceForCompare = normalizedWorkspaceRoot.toLowerCase(); + const workspaceWithSeparator = `${workspaceForCompare}/`; + const workspaceLabelWithSeparator = `${workspaceLabel.toLowerCase()}/`; + + if (pathForCompare === workspaceForCompare) { + displayPath = workspaceLabel; + } else if (pathForCompare.startsWith(workspaceWithSeparator)) { + const relativeSuffix = normalizedPath.slice(normalizedWorkspaceRoot.length + 1); + displayPath = `${workspaceLabel}/${relativeSuffix}`; + } else if (!normalizedPath.startsWith("/")) { + const relativePath = stripRelativePrefixes(normalizedPath); + displayPath = pathForCompare.startsWith(workspaceLabelWithSeparator) + ? normalizedPath + : `${workspaceLabel}/${relativePath}`; + } + } + + if (!line) return displayPath; + return `${displayPath}:${line}${column ? `:${column}` : ""}`; +} diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index b84bedb6577..1406adcdc68 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -10,14 +10,19 @@ import { } from "../composerDraftStore"; import { newDraftId, newThreadId } from "../lib/utils"; import { orderItemsByPreferredIds } from "../components/Sidebar.logic"; -import { deriveLogicalProjectKey } from "../logicalProject"; +import { deriveLogicalProjectKeyFromSettings } from "../logicalProject"; import { selectProjectsAcrossEnvironments, useStore } from "../store"; import { createThreadSelectorByRef } from "../storeSelectors"; import { resolveThreadRouteTarget } from "../threadRoutes"; import { useUiStateStore } from "../uiStateStore"; +import { useSettings } from "./useSettings"; function useNewThreadState() { const projects = useStore(useShallow((store) => selectProjectsAcrossEnvironments(store))); + const projectGroupingSettings = useSettings((settings) => ({ + sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode, + sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides, + })); const router = useRouter(); const getCurrentRouteTarget = useCallback(() => { const currentRouteParams = router.state.matches[router.state.matches.length - 1]?.params ?? {}; @@ -48,7 +53,7 @@ function useNewThreadState() { candidate.environmentId === projectRef.environmentId, ); const logicalProjectKey = project - ? deriveLogicalProjectKey(project) + ? deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings) : scopedProjectKey(projectRef); const hasBranchOption = options?.branch !== undefined; const hasWorktreePathOption = options?.worktreePath !== undefined; @@ -87,7 +92,8 @@ function useNewThreadState() { if ( latestActiveDraftThread && currentRouteTarget?.kind === "draft" && - latestActiveDraftThread.logicalProjectKey === logicalProjectKey + latestActiveDraftThread.logicalProjectKey === logicalProjectKey && + latestActiveDraftThread.promotedTo == null ) { if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) { setDraftThreadContext(currentRouteTarget.draftId, { @@ -128,7 +134,7 @@ function useNewThreadState() { }); })(); }, - [getCurrentRouteTarget, router, projects], + [getCurrentRouteTarget, projectGroupingSettings, router, projects], ); } diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index ac23798d4fc..446075e92ca 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -32,6 +32,7 @@ import { normalizeCustomModelSlugs } from "~/modelSelection"; import { Predicate, Schema, Struct } from "effect"; import { DeepMutable } from "effect/Types"; import { deepMerge } from "@marcode/shared/Struct"; +import { applyServerSettingsPatch } from "@marcode/shared/serverSettings"; import { applySettingsUpdated, getServerConfig, useServerSettings } from "~/rpc/serverState"; const CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE = "[CLIENT_SETTINGS]"; @@ -77,7 +78,7 @@ async function hydrateClientSettings(): Promise { try { const persistedSettings = await ensureLocalApi().persistence.getClientSettings(); if (persistedSettings) { - replaceClientSettingsSnapshot(persistedSettings); + replaceClientSettingsSnapshot({ ...DEFAULT_CLIENT_SETTINGS, ...persistedSettings }); } } catch (error) { console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} hydrate failed`, error); @@ -167,7 +168,7 @@ export function useUpdateSettings() { if (Object.keys(serverPatch).length > 0) { const currentServerConfig = getServerConfig(); if (currentServerConfig) { - applySettingsUpdated(deepMerge(currentServerConfig.settings, serverPatch)); + applySettingsUpdated(applyServerSettingsPatch(currentServerConfig.settings, serverPatch)); } void ensureLocalApi().server.updateSettings(serverPatch); } diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 99425e046d0..898fd506ddc 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -2,7 +2,7 @@ import { parseScopedThreadKey, scopeProjectRef, scopeThreadRef } from "@marcode/ import { type ScopedThreadRef, ThreadId } from "@marcode/contracts"; import { useQueryClient } from "@tanstack/react-query"; import { useRouter } from "@tanstack/react-router"; -import { useCallback } from "react"; +import { useCallback, useRef } from "react"; import { getFallbackThreadIdAfterDelete } from "../components/Sidebar.logic"; import { useComposerDraftStore } from "../composerDraftStore"; @@ -33,6 +33,12 @@ export function useThreadActions() { const clearTerminalState = useTerminalStateStore((state) => state.clearTerminalState); const router = useRouter(); const { handleNewThread } = useNewThreadHandler(); + // Keep a ref so archiveThread can call handleNewThread without appearing in + // its dependency array — handleNewThread is inherently unstable (depends on + // the projects list) and would otherwise cascade new references into every + // sidebar row via archiveThread → attemptArchiveThread. + const handleNewThreadRef = useRef(handleNewThread); + handleNewThreadRef.current = handleNewThread; const queryClient = useQueryClient(); const resolveThreadTarget = useCallback((target: ScopedThreadRef) => { @@ -73,10 +79,10 @@ export function useThreadActions() { currentRouteThreadRef?.threadId === threadRef.threadId && currentRouteThreadRef.environmentId === threadRef.environmentId ) { - await handleNewThread(scopeProjectRef(thread.environmentId, thread.projectId)); + await handleNewThreadRef.current(scopeProjectRef(thread.environmentId, thread.projectId)); } }, - [getCurrentRouteThreadRef, handleNewThread, resolveThreadTarget], + [getCurrentRouteThreadRef, resolveThreadTarget], ); const unarchiveThread = useCallback(async (target: ScopedThreadRef) => { diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 9d0f7967e31..64a1c310806 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -25,6 +25,8 @@ } @custom-variant dark (&:is(.dark, .dark *)); +/* Window Controls Overlay: active when Electron exposes native titlebar control geometry. */ +@custom-variant wco (&:is(.wco, .wco *)); @theme inline { --font-heading: "Klaster Sans", sans-serif; @@ -371,6 +373,47 @@ label:has(> select#reasoning-effort) select { font-size: 0.75rem; } +.chat-markdown-file-link { + display: inline-flex; + align-items: center; + gap: 0.28rem; + border: 1px solid color-mix(in srgb, var(--border) 92%, transparent); + border-radius: 0.375rem; + background: color-mix(in srgb, var(--muted) 88%, var(--background)); + padding: 0.08rem 0.34rem; + color: var(--foreground); + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 0.75rem; + line-height: 1.15; + vertical-align: text-bottom; + transition: + background-color 120ms ease, + border-color 120ms ease, + color 120ms ease, + opacity 120ms ease; +} + +.chat-markdown-file-link:hover { + opacity: 1; + color: var(--foreground); + border-color: color-mix(in srgb, var(--border) 65%, var(--foreground)); + background: color-mix(in srgb, var(--muted) 72%, var(--background)); +} + +.chat-markdown-file-link:focus-visible { + outline: none; + box-shadow: 0 0 0 1px color-mix(in srgb, var(--ring) 70%, transparent); +} + +.chat-markdown-file-link-icon { + opacity: 0.72; +} + +.chat-markdown-file-link-label { + color: color-mix(in srgb, var(--foreground) 88%, transparent); + line-height: 1.1; +} + .chat-markdown pre { max-width: 100%; overflow-x: auto; @@ -387,13 +430,33 @@ label:has(> select#reasoning-effort) select { font-size: 0.75rem; } +.markdown-file-link-tooltip-scroll { + scrollbar-width: thin; + scrollbar-color: color-mix(in srgb, var(--border) 78%, transparent) transparent; +} + +.markdown-file-link-tooltip-scroll::-webkit-scrollbar { + height: 6px; +} + +.markdown-file-link-tooltip-scroll::-webkit-scrollbar-track { + background: transparent; +} + +.markdown-file-link-tooltip-scroll::-webkit-scrollbar-thumb { + border-radius: 999px; + background: color-mix(in srgb, var(--border) 78%, transparent); +} + .chat-markdown .chat-markdown-codeblock { + --chat-markdown-codeblock-copy-button-space: 1.5rem; position: relative; margin: 0.65rem 0; } .chat-markdown .chat-markdown-codeblock pre { margin: 0; + padding-right: calc(0.9rem + var(--chat-markdown-codeblock-copy-button-space)); } .chat-markdown .chat-markdown-copy-button { @@ -410,6 +473,7 @@ label:has(> select#reasoning-effort) select { border: 1px solid var(--border); background: color-mix(in srgb, var(--background) 82%, transparent); color: var(--muted-foreground); + cursor: pointer; opacity: 0; pointer-events: none; transition: diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index ef69ee193fb..ca4df20ef0e 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -20,6 +20,7 @@ import { resolveShortcutCommand, shouldShowThreadJumpHints, shortcutLabelForCommand, + terminalDeleteShortcutData, terminalNavigationShortcutData, threadJumpCommandForIndex, threadJumpIndexFromCommand, @@ -560,6 +561,34 @@ describe("isTerminalClearShortcut", () => { }); }); +describe("terminalDeleteShortcutData", () => { + it("maps Cmd+Backspace on macOS to delete-to-line-start", () => { + assert.strictEqual( + terminalDeleteShortcutData(event({ key: "Backspace", metaKey: true }), "MacIntel"), + "\u0015", + ); + }); + + it("ignores non-macOS platforms and modified variants", () => { + assert.isNull(terminalDeleteShortcutData(event({ key: "Backspace", metaKey: true }), "Linux")); + assert.isNull( + terminalDeleteShortcutData( + event({ key: "Backspace", metaKey: true, altKey: true }), + "MacIntel", + ), + ); + }); + + it("ignores non-keydown events", () => { + assert.isNull( + terminalDeleteShortcutData( + event({ type: "keyup", key: "Backspace", metaKey: true }), + "MacIntel", + ), + ); + }); +}); + describe("terminalNavigationShortcutData", () => { it("maps Option+Arrow on macOS to word movement", () => { assert.strictEqual( diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index 07debfae56d..f6c6e6d810d 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -37,6 +37,7 @@ const TERMINAL_WORD_BACKWARD = "\u001bb"; const TERMINAL_WORD_FORWARD = "\u001bf"; const TERMINAL_LINE_START = "\u0001"; const TERMINAL_LINE_END = "\u0005"; +const TERMINAL_DELETE_TO_LINE_START = "\u0015"; const EVENT_CODE_KEY_ALIASES: Readonly> = { BracketLeft: ["["], BracketRight: ["]"], @@ -370,6 +371,28 @@ export function isTerminalClearShortcut( ); } +export function terminalDeleteShortcutData( + event: ShortcutEventLike, + platform = navigator.platform, +): string | null { + if (event.type !== undefined && event.type !== "keydown") { + return null; + } + + if (!isMacPlatform(platform)) { + return null; + } + + const key = normalizeEventKey(event.key); + if (key !== "backspace") { + return null; + } + + return event.metaKey && !event.ctrlKey && !event.altKey && !event.shiftKey + ? TERMINAL_DELETE_TO_LINE_START + : null; +} + export function terminalNavigationShortcutData( event: ShortcutEventLike, platform = navigator.platform, diff --git a/apps/web/src/lib/desktopUpdateReactQuery.test.ts b/apps/web/src/lib/desktopUpdateReactQuery.test.ts index 7e498f138b8..f6a5ae78aed 100644 --- a/apps/web/src/lib/desktopUpdateReactQuery.test.ts +++ b/apps/web/src/lib/desktopUpdateReactQuery.test.ts @@ -10,6 +10,7 @@ import { const baseState: DesktopUpdateState = { enabled: true, status: "idle", + channel: "latest", currentVersion: "1.0.0", hostArch: "x64", appArch: "x64", diff --git a/apps/web/src/lib/gitStatusState.test.ts b/apps/web/src/lib/gitStatusState.test.ts index 06d917d7412..e668361890d 100644 --- a/apps/web/src/lib/gitStatusState.test.ts +++ b/apps/web/src/lib/gitStatusState.test.ts @@ -123,12 +123,11 @@ function createRegisteredGitStatusClient(environmentId: EnvironmentId) { subscribeAuthAccess: vi.fn(() => () => undefined), }, orchestration: { - getSnapshot: vi.fn(async () => ({ snapshotSequence: 1, projects: [], threads: [] }) as any), dispatchCommand: vi.fn(async () => undefined), getTurnDiff: vi.fn(async () => undefined), getFullThreadDiff: vi.fn(async () => undefined), - replayEvents: vi.fn(async () => []), - onDomainEvent: vi.fn(() => () => undefined), + subscribeShell: vi.fn(() => () => undefined), + subscribeThread: vi.fn(() => () => undefined), }, } as unknown as WsRpcClient; diff --git a/apps/web/src/lib/projectPaths.test.ts b/apps/web/src/lib/projectPaths.test.ts new file mode 100644 index 00000000000..7989622e1d5 --- /dev/null +++ b/apps/web/src/lib/projectPaths.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest"; + +import { + appendBrowsePathSegment, + canNavigateUp, + getBrowseDirectoryPath, + findProjectByPath, + getBrowseLeafPathSegment, + getBrowseParentPath, + hasTrailingPathSeparator, + inferProjectTitleFromPath, + isExplicitRelativeProjectPath, + isFilesystemBrowseQuery, + normalizeProjectPathForComparison, + normalizeProjectPathForDispatch, + isUnsupportedWindowsProjectPath, + resolveProjectPathForDispatch, +} from "./projectPaths"; + +describe("projectPaths", () => { + it("normalizes trailing separators for dispatch and comparison", () => { + expect(normalizeProjectPathForDispatch(" /repo/app/ ")).toBe("/repo/app"); + expect(normalizeProjectPathForComparison("/repo/app/")).toBe("/repo/app"); + }); + + it("normalizes windows-style paths for comparison", () => { + expect(normalizeProjectPathForComparison("C:/Work/Repo/")).toBe("c:\\work\\repo"); + expect(normalizeProjectPathForComparison("C:\\Work\\Repo\\")).toBe("c:\\work\\repo"); + }); + + it("finds existing projects even when the input formatting differs", () => { + const existing = findProjectByPath( + [ + { id: "project-1", cwd: "/repo/app" }, + { id: "project-2", cwd: "C:\\Work\\Repo" }, + ], + "C:/Work/Repo/", + ); + + expect(existing?.id).toBe("project-2"); + }); + + it("infers project titles from normalized paths", () => { + expect(inferProjectTitleFromPath("/repo/app/")).toBe("app"); + expect(inferProjectTitleFromPath("C:\\Work\\Repo\\")).toBe("Repo"); + expect(inferProjectTitleFromPath("/home/user\\project/")).toBe("user\\project"); + }); + + it("detects browse queries across supported path styles", () => { + expect(isFilesystemBrowseQuery(".")).toBe(false); + expect(isFilesystemBrowseQuery("..")).toBe(false); + expect(isFilesystemBrowseQuery("./")).toBe(true); + expect(isFilesystemBrowseQuery("../")).toBe(true); + expect(isFilesystemBrowseQuery("~/projects")).toBe(true); + expect(isFilesystemBrowseQuery("..\\docs")).toBe(true); + expect(isFilesystemBrowseQuery("notes")).toBe(false); + }); + + it("only treats windows-style paths as browse queries on windows", () => { + expect(isFilesystemBrowseQuery("C:\\Work\\Repo\\", "MacIntel")).toBe(false); + expect(isFilesystemBrowseQuery("C:\\Work\\Repo\\", "Win32")).toBe(true); + expect(isUnsupportedWindowsProjectPath("C:\\Work\\Repo\\", "MacIntel")).toBe(true); + expect(isUnsupportedWindowsProjectPath("C:\\Work\\Repo\\", "Win32")).toBe(false); + }); + + it("detects explicit relative project paths", () => { + expect(isExplicitRelativeProjectPath(".")).toBe(true); + expect(isExplicitRelativeProjectPath("..")).toBe(true); + expect(isExplicitRelativeProjectPath("./docs")).toBe(true); + expect(isExplicitRelativeProjectPath("..\\docs")).toBe(true); + expect(isExplicitRelativeProjectPath("/repo/docs")).toBe(false); + }); + + it("resolves explicit relative paths against the current project", () => { + expect(resolveProjectPathForDispatch(".", "/repo/app")).toBe("/repo/app"); + expect(resolveProjectPathForDispatch("..", "/repo/app")).toBe("/repo"); + expect(resolveProjectPathForDispatch("./docs", "/repo/app")).toBe("/repo/app/docs"); + expect(resolveProjectPathForDispatch("../docs", "/repo/app")).toBe("/repo/docs"); + expect(resolveProjectPathForDispatch("./Repo", "C:\\Work")).toBe("C:\\Work\\Repo"); + expect(resolveProjectPathForDispatch("./docs", "/home/user\\project")).toBe( + "/home/user\\project/docs", + ); + }); + + it("navigates browse paths with matching separators", () => { + expect(appendBrowsePathSegment("/repo/", "src")).toBe("/repo/src/"); + expect(appendBrowsePathSegment("C:\\Work\\", "Repo")).toBe("C:\\Work\\Repo\\"); + expect(appendBrowsePathSegment("/home/user\\project/", "docs")).toBe( + "/home/user\\project/docs/", + ); + expect(getBrowseParentPath("/repo/src/")).toBe("/repo/"); + expect(getBrowseParentPath("C:\\Work\\Repo\\")).toBe("C:\\Work\\"); + expect(getBrowseParentPath("\\\\server\\share\\")).toBeNull(); + expect(getBrowseParentPath("\\\\server\\share\\repo\\")).toBe("\\\\server\\share\\"); + expect(getBrowseParentPath("C:\\")).toBeNull(); + expect(getBrowseParentPath("/home/user\\project/docs/")).toBe("/home/user\\project/"); + }); + + it("detects browse path boundaries", () => { + expect(hasTrailingPathSeparator("/repo/src/")).toBe(true); + expect(hasTrailingPathSeparator("/repo/src")).toBe(false); + expect(getBrowseDirectoryPath("/repo/src")).toBe("/repo/"); + expect(getBrowseDirectoryPath("/repo/src/")).toBe("/repo/src/"); + expect(getBrowseLeafPathSegment("/repo/src")).toBe("src"); + expect(getBrowseLeafPathSegment("C:\\Work\\Repo\\Docs")).toBe("Docs"); + expect(getBrowseDirectoryPath("/home/user\\project/docs")).toBe("/home/user\\project/"); + expect(getBrowseLeafPathSegment("/home/user\\project/docs")).toBe("docs"); + }); + + it("only allows browse-up after entering a directory", () => { + expect(canNavigateUp("~/repo")).toBe(false); + expect(canNavigateUp("~/a")).toBe(false); + expect(canNavigateUp("~/repo/")).toBe(true); + expect(canNavigateUp("\\\\server\\share\\")).toBe(false); + expect(canNavigateUp("\\\\server\\share\\repo\\")).toBe(true); + }); +}); diff --git a/apps/web/src/lib/projectPaths.ts b/apps/web/src/lib/projectPaths.ts new file mode 100644 index 00000000000..359316ec430 --- /dev/null +++ b/apps/web/src/lib/projectPaths.ts @@ -0,0 +1,259 @@ +import { + isExplicitRelativePath, + isUncPath, + isWindowsAbsolutePath, + isWindowsDrivePath, +} from "@marcode/shared/path"; +import { isWindowsPlatform } from "./utils"; + +function isRootPath(value: string): boolean { + return value === "/" || value === "\\" || /^[a-zA-Z]:[/\\]?$/.test(value); +} + +function getAbsolutePathKind(value: string): "unix" | "windows" | null { + if (isWindowsDrivePath(value) || isUncPath(value)) { + return "windows"; + } + + if (value.startsWith("/")) { + return "unix"; + } + + return null; +} + +function trimTrailingPathSeparators(value: string): string { + if (value.length === 0 || isRootPath(value)) { + return value; + } + + const trimmed = + getAbsolutePathKind(value) === "unix" + ? value.replace(/\/+$/g, "") + : value.replace(/[\\/]+$/g, ""); + if (trimmed.length === 0) { + return value; + } + + return /^[a-zA-Z]:$/.test(trimmed) ? `${trimmed}\\` : trimmed; +} + +function preferredPathSeparator(value: string): "/" | "\\" { + const absolutePathKind = getAbsolutePathKind(value); + if (absolutePathKind === "windows") { + return "\\"; + } + if (absolutePathKind === "unix") { + return "/"; + } + + return value.includes("\\") ? "\\" : "/"; +} + +export function hasTrailingPathSeparator(value: string): boolean { + return (getAbsolutePathKind(value) === "unix" ? /\/$/ : /[\\/]$/).test(value); +} + +export { isExplicitRelativePath as isExplicitRelativeProjectPath }; + +function splitPathSegments(value: string, separator: "/" | "\\"): string[] { + return value.split(separator === "/" ? /\/+/ : /[\\/]+/).filter(Boolean); +} + +function getLastPathSeparatorIndex(value: string): number { + if (getAbsolutePathKind(value) === "unix") { + return value.lastIndexOf("/"); + } + + return Math.max(value.lastIndexOf("/"), value.lastIndexOf("\\")); +} + +function splitAbsolutePath(value: string): { + root: string; + separator: "/" | "\\"; + segments: string[]; +} | null { + if (isWindowsDrivePath(value)) { + const root = `${value.slice(0, 2)}\\`; + const segments = splitPathSegments(value.slice(root.length), "\\"); + return { root, separator: "\\", segments }; + } + if (isUncPath(value)) { + const segments = splitPathSegments(value, "\\"); + const [server, share, ...rest] = segments; + if (!server || !share) { + return null; + } + return { + root: `\\\\${server}\\${share}\\`, + separator: "\\", + segments: rest, + }; + } + if (value.startsWith("/")) { + return { + root: "/", + separator: "/", + segments: splitPathSegments(value.slice(1), "/"), + }; + } + return null; +} + +export function isFilesystemBrowseQuery( + value: string, + platform = typeof navigator === "undefined" ? "" : navigator.platform, +): boolean { + const allowWindowsPaths = isWindowsPlatform(platform); + return ( + value.startsWith("./") || + value.startsWith("../") || + value.startsWith(".\\") || + value.startsWith("..\\") || + value.startsWith("/") || + value.startsWith("~/") || + (allowWindowsPaths && isWindowsAbsolutePath(value)) + ); +} + +export function isUnsupportedWindowsProjectPath(value: string, platform: string): boolean { + return isWindowsAbsolutePath(value) && !isWindowsPlatform(platform); +} + +export function normalizeProjectPathForDispatch(value: string): string { + return trimTrailingPathSeparators(value.trim()); +} + +export function resolveProjectPathForDispatch(value: string, cwd?: string | null): string { + const trimmedValue = value.trim(); + if (!isExplicitRelativePath(trimmedValue) || !cwd) { + return normalizeProjectPathForDispatch(trimmedValue); + } + + const absoluteBase = splitAbsolutePath(normalizeProjectPathForDispatch(cwd)); + if (!absoluteBase) { + return normalizeProjectPathForDispatch(trimmedValue); + } + + const nextSegments = [...absoluteBase.segments]; + for (const segment of trimmedValue.split(/[\\/]+/)) { + if (segment.length === 0 || segment === ".") { + continue; + } + if (segment === "..") { + nextSegments.pop(); + continue; + } + nextSegments.push(segment); + } + + const joinedPath = nextSegments.join(absoluteBase.separator); + if (joinedPath.length === 0) { + return normalizeProjectPathForDispatch(absoluteBase.root); + } + + return normalizeProjectPathForDispatch(`${absoluteBase.root}${joinedPath}`); +} + +export function normalizeProjectPathForComparison(value: string): string { + const normalized = normalizeProjectPathForDispatch(value); + if (isWindowsDrivePath(normalized) || normalized.startsWith("\\\\")) { + return normalized.replaceAll("/", "\\").toLowerCase(); + } + return normalized; +} + +export function findProjectByPath( + projects: ReadonlyArray, + candidatePath: string, +): T | undefined { + const normalizedCandidate = normalizeProjectPathForComparison(candidatePath); + if (normalizedCandidate.length === 0) { + return undefined; + } + + return projects.find( + (project) => normalizeProjectPathForComparison(project.cwd) === normalizedCandidate, + ); +} + +export function inferProjectTitleFromPath(value: string): string { + const normalized = normalizeProjectPathForDispatch(value); + const absolutePath = splitAbsolutePath(normalized); + if (absolutePath) { + return absolutePath.segments.findLast(Boolean) ?? normalized; + } + + const segments = normalized.split(/[/\\]/); + return segments.findLast(Boolean) ?? normalized; +} + +export function appendBrowsePathSegment(currentPath: string, segment: string): string { + const separator = preferredPathSeparator(currentPath); + return `${getBrowseDirectoryPath(currentPath)}${segment}${separator}`; +} + +export function getBrowseLeafPathSegment(currentPath: string): string { + const lastSeparatorIndex = getLastPathSeparatorIndex(currentPath); + return currentPath.slice(lastSeparatorIndex + 1); +} + +export function getBrowseDirectoryPath(currentPath: string): string { + if (hasTrailingPathSeparator(currentPath)) { + return currentPath; + } + + const lastSeparatorIndex = getLastPathSeparatorIndex(currentPath); + if (lastSeparatorIndex < 0) { + return currentPath; + } + + return currentPath.slice(0, lastSeparatorIndex + 1); +} + +export function ensureBrowseDirectoryPath(currentPath: string): string { + const trimmed = currentPath.trim(); + if (trimmed.length === 0) { + return trimmed; + } + + if (hasTrailingPathSeparator(trimmed)) { + return trimmed; + } + + return `${trimmed}${preferredPathSeparator(trimmed)}`; +} + +export function getBrowseParentPath(currentPath: string): string | null { + const trimmed = trimTrailingPathSeparators(currentPath); + const absolutePath = splitAbsolutePath(trimmed); + if (absolutePath) { + if (absolutePath.segments.length === 0) { + return null; + } + + if (absolutePath.segments.length === 1) { + return absolutePath.root; + } + + const parentSegments = absolutePath.segments.slice(0, -1).join(absolutePath.separator); + return `${absolutePath.root}${parentSegments}${absolutePath.separator}`; + } + + const separator = preferredPathSeparator(currentPath); + const lastSeparatorIndex = getLastPathSeparatorIndex(trimmed); + + if (lastSeparatorIndex < 0) { + return null; + } + + if (lastSeparatorIndex === 2 && /^[a-zA-Z]:/.test(trimmed)) { + return `${trimmed.slice(0, 2)}${separator}`; + } + + return trimmed.slice(0, lastSeparatorIndex + 1); +} + +export function canNavigateUp(currentPath: string): boolean { + return hasTrailingPathSeparator(currentPath) && getBrowseParentPath(currentPath) !== null; +} diff --git a/apps/web/src/lib/windowControlsOverlay.ts b/apps/web/src/lib/windowControlsOverlay.ts new file mode 100644 index 00000000000..8f9ae786b0e --- /dev/null +++ b/apps/web/src/lib/windowControlsOverlay.ts @@ -0,0 +1,40 @@ +const WCO_CLASS_NAME = "wco"; + +interface WindowControlsOverlayLike { + readonly visible: boolean; + addEventListener(type: "geometrychange", listener: EventListener): void; + removeEventListener(type: "geometrychange", listener: EventListener): void; +} + +interface NavigatorWithWindowControlsOverlay extends Navigator { + readonly windowControlsOverlay?: WindowControlsOverlayLike; +} + +function getWindowControlsOverlay(): WindowControlsOverlayLike | null { + if (typeof navigator === "undefined") { + return null; + } + + return (navigator as NavigatorWithWindowControlsOverlay).windowControlsOverlay ?? null; +} + +export function syncDocumentWindowControlsOverlayClass(): () => void { + if (typeof document === "undefined") { + return () => {}; + } + + const overlay = getWindowControlsOverlay(); + const update = () => { + document.documentElement.classList.toggle(WCO_CLASS_NAME, overlay !== null && overlay.visible); + }; + + update(); + if (!overlay) { + return () => {}; + } + + overlay.addEventListener("geometrychange", update); + return () => { + overlay.removeEventListener("geometrychange", update); + }; +} diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index bf6f5ff5530..4b9a624f5e8 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -3,10 +3,9 @@ import { DEFAULT_SERVER_SETTINGS, type DesktopBridge, EnvironmentId, - EventId, type GitStatusResult, ProjectId, - type OrchestrationEvent, + type OrchestrationShellStreamItem, type ServerConfig, type ServerProvider, type TerminalEvent, @@ -32,7 +31,7 @@ function registerListener(listeners: Set<(event: T) => void>, listener: (even } const terminalEventListeners = new Set<(event: TerminalEvent) => void>(); -const orchestrationEventListeners = new Set<(event: OrchestrationEvent) => void>(); +const shellStreamListeners = new Set<(event: OrchestrationShellStreamItem) => void>(); const gitStatusListeners = new Set<(event: GitStatusResult) => void>(); const rpcClientMock = { @@ -53,6 +52,9 @@ const rpcClientMock = { browseDirectories: vi.fn(), writeFile: vi.fn(), }, + filesystem: { + browse: vi.fn(), + }, shell: { openInEditor: vi.fn(), }, @@ -95,14 +97,13 @@ const rpcClientMock = { onConnectionStatusChanged: vi.fn(), }, orchestration: { - getSnapshot: vi.fn(), dispatchCommand: vi.fn(), getTurnDiff: vi.fn(), getFullThreadDiff: vi.fn(), - replayEvents: vi.fn(), - onDomainEvent: vi.fn((listener: (event: OrchestrationEvent) => void) => - registerListener(orchestrationEventListeners, listener), + subscribeShell: vi.fn((listener: (event: OrchestrationShellStreamItem) => void) => + registerListener(shellStreamListeners, listener), ), + subscribeThread: vi.fn(() => () => undefined), }, }; @@ -172,6 +173,7 @@ function createLocalStorageStub(): Storage { function makeDesktopBridge(overrides: Partial = {}): DesktopBridge { return { + getAppBranding: () => null, getLocalEnvironmentBootstrap: () => null, getClientSettings: async () => null, setClientSettings: async () => undefined, @@ -199,6 +201,9 @@ function makeDesktopBridge(overrides: Partial = {}): DesktopBridg getUpdateState: async () => { throw new Error("getUpdateState not implemented in test"); }, + setUpdateChannel: async () => { + throw new Error("setUpdateChannel not implemented in test"); + }, checkForUpdate: async () => { throw new Error("checkForUpdate not implemented in test"); }, @@ -283,7 +288,7 @@ beforeEach(() => { vi.clearAllMocks(); showContextMenuFallbackMock.mockReset(); terminalEventListeners.clear(); - orchestrationEventListeners.clear(); + shellStreamListeners.clear(); gitStatusListeners.clear(); const testWindow = getWindowForTest(); Reflect.deleteProperty(testWindow, "desktopBridge"); @@ -310,15 +315,15 @@ describe("wsApi", () => { expect(rpcClientMock.server.subscribeLifecycle).not.toHaveBeenCalled(); }); - it("forwards terminal and orchestration stream events", async () => { + it("forwards terminal and shell stream events", async () => { const { createEnvironmentApi } = await import("./environmentApi"); const api = createEnvironmentApi(rpcClientMock as never); const onTerminalEvent = vi.fn(); - const onDomainEvent = vi.fn(); + const onShellEvent = vi.fn(); api.terminal.onEvent(onTerminalEvent); - api.orchestration.onDomainEvent(onDomainEvent); + api.orchestration.subscribeShell(onShellEvent); const terminalEvent = { threadId: "thread-1", @@ -329,19 +334,11 @@ describe("wsApi", () => { } as const; emitEvent(terminalEventListeners, terminalEvent); - const orchestrationEvent = { + const shellEvent = { + kind: "project-upserted" as const, sequence: 1, - eventId: EventId.make("event-1"), - aggregateKind: "project", - aggregateId: ProjectId.make("project-1"), - occurredAt: "2026-02-24T00:00:00.000Z", - commandId: null, - causationEventId: null, - correlationId: null, - metadata: {}, - type: "project.created", - payload: { - projectId: ProjectId.make("project-1"), + project: { + id: ProjectId.make("project-1"), title: "Project", workspaceRoot: "/tmp/workspace", defaultModelSelection: { @@ -349,15 +346,14 @@ describe("wsApi", () => { model: "gpt-5-codex", }, scripts: [], - jiraBoard: null, createdAt: "2026-02-24T00:00:00.000Z", updatedAt: "2026-02-24T00:00:00.000Z", }, - } satisfies Extract; - emitEvent(orchestrationEventListeners, orchestrationEvent); + } satisfies OrchestrationShellStreamItem; + emitEvent(shellStreamListeners, shellEvent); expect(onTerminalEvent).toHaveBeenCalledWith(terminalEvent); - expect(onDomainEvent).toHaveBeenCalledWith(orchestrationEvent); + expect(onShellEvent).toHaveBeenCalledWith(shellEvent); }); it("forwards git status stream events", async () => { @@ -386,16 +382,16 @@ describe("wsApi", () => { expect(rpcClientMock.git.refreshStatus).toHaveBeenCalledWith({ cwd: "/repo" }); }); - it("forwards orchestration stream subscription options to the RPC client", async () => { + it("forwards shell stream subscription options to the RPC client", async () => { const { createEnvironmentApi } = await import("./environmentApi"); const api = createEnvironmentApi(rpcClientMock as never); - const onDomainEvent = vi.fn(); + const onShellEvent = vi.fn(); const onResubscribe = vi.fn(); - api.orchestration.onDomainEvent(onDomainEvent, { onResubscribe }); + api.orchestration.subscribeShell(onShellEvent, { onResubscribe }); - expect(rpcClientMock.orchestration.onDomainEvent).toHaveBeenCalledWith(onDomainEvent, { + expect(rpcClientMock.orchestration.subscribeShell).toHaveBeenCalledWith(onShellEvent, { onResubscribe, }); }); @@ -440,6 +436,25 @@ describe("wsApi", () => { }); }); + it("forwards filesystem browse requests to the RPC client", async () => { + rpcClientMock.filesystem.browse.mockResolvedValue({ + parentPath: "/tmp/project/", + entries: [], + }); + const { createEnvironmentApi } = await import("./environmentApi"); + + const api = createEnvironmentApi(rpcClientMock as never); + await api.filesystem.browse({ + partialPath: "/tmp/project/", + cwd: "/tmp/project", + }); + + expect(rpcClientMock.filesystem.browse).toHaveBeenCalledWith({ + partialPath: "/tmp/project/", + cwd: "/tmp/project", + }); + }); + it("forwards full-thread diff requests to the orchestration RPC", async () => { rpcClientMock.orchestration.getFullThreadDiff.mockResolvedValue({ diff: "patch" }); const { createEnvironmentApi } = await import("./environmentApi"); @@ -502,6 +517,19 @@ describe("wsApi", () => { expect(showContextMenu).toHaveBeenCalledWith(items, undefined); }); + it("forwards folder picker options to the desktop bridge", async () => { + const pickFolder = vi.fn().mockResolvedValue("/tmp/project"); + getWindowForTest().desktopBridge = makeDesktopBridge({ pickFolder }); + + const { createLocalApi } = await import("./localApi"); + const api = createLocalApi(rpcClientMock as never); + + await expect(api.dialogs.pickFolder({ initialPath: "/tmp/workspace" })).resolves.toBe( + "/tmp/project", + ); + expect(pickFolder).toHaveBeenCalledWith({ initialPath: "/tmp/workspace" }); + }); + it("falls back to the browser context menu helper when the desktop bridge is missing", async () => { showContextMenuFallbackMock.mockResolvedValue("rename"); const { createLocalApi } = await import("./localApi"); @@ -514,13 +542,20 @@ describe("wsApi", () => { }); it("reads and writes persistence through the desktop bridge when available", async () => { - const getClientSettings = vi.fn().mockResolvedValue({ + const clientSettings = { confirmThreadArchive: true, confirmThreadDelete: false, diffWordWrap: true, - sidebarProjectSortOrder: "manual", - sidebarThreadSortOrder: "created_at", - timestampFormat: "24-hour", + sidebarProjectGroupingMode: "repository_path" as const, + sidebarProjectGroupingOverrides: { + "environment-local:/tmp/project": "separate" as const, + }, + sidebarProjectSortOrder: "manual" as const, + sidebarThreadSortOrder: "created_at" as const, + timestampFormat: "24-hour" as const, + }; + const getClientSettings = vi.fn().mockResolvedValue({ + ...clientSettings, }); const setClientSettings = vi.fn().mockResolvedValue(undefined); const getSavedEnvironmentRegistry = vi.fn().mockResolvedValue([]); @@ -546,10 +581,11 @@ describe("wsApi", () => { confirmThreadArchive: true, confirmThreadDelete: false, diffWordWrap: true, + sidebarProjectGroupingMode: "repository", + sidebarProjectGroupingOverrides: {}, sidebarProjectSortOrder: "manual", sidebarThreadSortOrder: "created_at", timestampFormat: "24-hour", - showTodosInComposer: true, turnNotificationMode: "off", turnNotificationSoundId: "default", turnNotificationCustomSounds: [], @@ -574,10 +610,11 @@ describe("wsApi", () => { confirmThreadArchive: true, confirmThreadDelete: false, diffWordWrap: true, + sidebarProjectGroupingMode: "repository", + sidebarProjectGroupingOverrides: {}, sidebarProjectSortOrder: "manual", sidebarThreadSortOrder: "created_at", timestampFormat: "24-hour", - showTodosInComposer: true, turnNotificationMode: "off", turnNotificationSoundId: "default", turnNotificationCustomSounds: [], @@ -603,10 +640,11 @@ describe("wsApi", () => { confirmThreadArchive: true, confirmThreadDelete: false, diffWordWrap: true, + sidebarProjectGroupingMode: "repository", + sidebarProjectGroupingOverrides: {}, sidebarProjectSortOrder: "manual", sidebarThreadSortOrder: "created_at", timestampFormat: "24-hour", - showTodosInComposer: true, turnNotificationMode: "off", turnNotificationSoundId: "default", turnNotificationCustomSounds: [], @@ -636,7 +674,8 @@ describe("wsApi", () => { confirmThreadArchive: true, confirmThreadDelete: false, diffWordWrap: true, - showTodosInComposer: true, + sidebarProjectGroupingMode: "repository", + sidebarProjectGroupingOverrides: {}, sidebarProjectSortOrder: "manual", sidebarThreadSortOrder: "created_at", timestampFormat: "24-hour", diff --git a/apps/web/src/localApi.ts b/apps/web/src/localApi.ts index eb9ae91a44f..581043855e7 100644 --- a/apps/web/src/localApi.ts +++ b/apps/web/src/localApi.ts @@ -29,9 +29,9 @@ let cachedApi: LocalApi | undefined; export function createLocalApi(rpcClient: WsRpcClient): LocalApi { return { dialogs: { - pickFolder: async () => { + pickFolder: async (options) => { if (!window.desktopBridge) return null; - return window.desktopBridge.pickFolder(); + return window.desktopBridge.pickFolder(options); }, confirm: async (message) => { if (window.desktopBridge) { diff --git a/apps/web/src/logicalProject.ts b/apps/web/src/logicalProject.ts index be0363fa5e1..e48b1775b35 100644 --- a/apps/web/src/logicalProject.ts +++ b/apps/web/src/logicalProject.ts @@ -1,19 +1,158 @@ import { scopedProjectKey, scopeProjectRef } from "@marcode/client-runtime"; import type { ScopedProjectRef } from "@marcode/contracts"; +import { SidebarProjectGroupingMode } from "@marcode/contracts/settings"; +import { normalizeProjectPathForComparison } from "./lib/projectPaths"; import type { Project } from "./types"; +export interface ProjectGroupingSettings { + sidebarProjectGroupingMode: SidebarProjectGroupingMode; + sidebarProjectGroupingOverrides: Record; +} + +export type ProjectGroupingMode = SidebarProjectGroupingMode; + +function uniqueNonEmptyValues(values: ReadonlyArray): string[] { + const seen = new Set(); + const unique: string[] = []; + for (const value of values) { + const trimmed = value?.trim(); + if (!trimmed || seen.has(trimmed)) { + continue; + } + seen.add(trimmed); + unique.push(trimmed); + } + return unique; +} + +function deriveRepositoryRelativeProjectPath( + project: Pick, +): string | null { + const rootPath = project.repositoryIdentity?.rootPath?.trim(); + if (!rootPath) { + return null; + } + + const normalizedProjectPath = normalizeProjectPathForComparison(project.cwd); + const normalizedRootPath = normalizeProjectPathForComparison(rootPath); + if (normalizedProjectPath.length === 0 || normalizedRootPath.length === 0) { + return null; + } + + if (normalizedProjectPath === normalizedRootPath) { + return ""; + } + + const separator = normalizedRootPath.includes("\\") ? "\\" : "/"; + const rootPrefix = `${normalizedRootPath}${separator}`; + if (!normalizedProjectPath.startsWith(rootPrefix)) { + return null; + } + + return normalizedProjectPath.slice(rootPrefix.length).replaceAll("\\", "/"); +} + +export function derivePhysicalProjectKeyFromPath(environmentId: string, cwd: string): string { + return `${environmentId}:${normalizeProjectPathForComparison(cwd)}`; +} + +export function derivePhysicalProjectKey(project: Pick): string { + return derivePhysicalProjectKeyFromPath(project.environmentId, project.cwd); +} + +export function deriveProjectGroupingOverrideKey( + project: Pick, +): string { + return derivePhysicalProjectKey(project); +} + +export function resolveProjectGroupingMode( + project: Pick, + settings: ProjectGroupingSettings, +): SidebarProjectGroupingMode { + return ( + settings.sidebarProjectGroupingOverrides?.[deriveProjectGroupingOverrideKey(project)] ?? + settings.sidebarProjectGroupingMode + ); +} + +function deriveRepositoryScopedKey( + project: Pick, + groupingMode: SidebarProjectGroupingMode, +): string | null { + const canonicalKey = project.repositoryIdentity?.canonicalKey; + if (!canonicalKey) { + return null; + } + + if (groupingMode === "repository") { + return canonicalKey; + } + + const relativeProjectPath = deriveRepositoryRelativeProjectPath(project); + if (relativeProjectPath === null) { + return canonicalKey; + } + + return relativeProjectPath.length === 0 + ? canonicalKey + : `${canonicalKey}::${relativeProjectPath}`; +} + export function deriveLogicalProjectKey( - project: Pick, + project: Pick, + options?: { + groupingMode?: SidebarProjectGroupingMode; + }, ): string { + const groupingMode = options?.groupingMode ?? "repository"; + if (groupingMode === "separate") { + return derivePhysicalProjectKey(project); + } + return ( - project.repositoryIdentity?.canonicalKey ?? + deriveRepositoryScopedKey(project, groupingMode) ?? + derivePhysicalProjectKey(project) ?? scopedProjectKey(scopeProjectRef(project.environmentId, project.id)) ); } +export function deriveLogicalProjectKeyFromSettings( + project: Pick, + settings: ProjectGroupingSettings, +): string { + return deriveLogicalProjectKey(project, { + groupingMode: resolveProjectGroupingMode(project, settings), + }); +} + export function deriveLogicalProjectKeyFromRef( projectRef: ScopedProjectRef, - project: Pick | null | undefined, + project: Pick | null | undefined, + options?: { + groupingMode?: SidebarProjectGroupingMode; + }, ): string { - return project?.repositoryIdentity?.canonicalKey ?? scopedProjectKey(projectRef); + return project ? deriveLogicalProjectKey(project, options) : scopedProjectKey(projectRef); +} + +export function deriveProjectGroupLabel(input: { + representative: Pick; + members: ReadonlyArray>; +}): string { + const sharedDisplayNames = uniqueNonEmptyValues( + input.members.map((member) => member.repositoryIdentity?.displayName), + ); + if (sharedDisplayNames.length === 1) { + return sharedDisplayNames[0]!; + } + + const sharedRepositoryNames = uniqueNonEmptyValues( + input.members.map((member) => member.repositoryIdentity?.name), + ); + if (sharedRepositoryNames.length === 1) { + return sharedRepositoryNames[0]!; + } + + return input.representative.name; } diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index fda5913c977..68a7dfaa931 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -9,12 +9,17 @@ import "./index.css"; import { isElectron } from "./env"; import { getRouter } from "./router"; import { APP_DISPLAY_NAME } from "./branding"; +import { syncDocumentWindowControlsOverlayClass } from "./lib/windowControlsOverlay"; // Electron loads the app from a file-backed shell, so hash history avoids path resolution issues. const history = isElectron ? createHashHistory() : createBrowserHistory(); const router = getRouter(history); +if (isElectron) { + syncDocumentWindowControlsOverlayClass(); +} + document.title = APP_DISPLAY_NAME; ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( diff --git a/apps/web/src/markdown-links.test.ts b/apps/web/src/markdown-links.test.ts index d3ca8bc99a1..a49512d8ece 100644 --- a/apps/web/src/markdown-links.test.ts +++ b/apps/web/src/markdown-links.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; -import { resolveMarkdownFileLinkTarget, rewriteMarkdownFileUriHref } from "./markdown-links"; +import { + resolveMarkdownFileLinkMeta, + resolveMarkdownFileLinkTarget, + rewriteMarkdownFileUriHref, +} from "./markdown-links"; describe("rewriteMarkdownFileUriHref", () => { it("rewrites file uri hrefs into direct path hrefs", () => { @@ -57,6 +61,29 @@ describe("resolveMarkdownFileLinkTarget", () => { ); }); + it("formats tooltip display paths relative to the cwd when possible", () => { + expect( + resolveMarkdownFileLinkMeta( + "file:///C:/Users/mike/dev-stuff/t3code/apps/web/src/session-logic.ts#L501", + "C:/Users/mike/dev-stuff/t3code", + ), + ).toMatchObject({ + displayPath: "t3code/apps/web/src/session-logic.ts:501", + }); + }); + + it("formats tooltip display paths relative to the cwd for slash-prefixed windows paths", () => { + expect( + resolveMarkdownFileLinkMeta( + "/C:/Users/mike/dev-stuff/t3code/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx", + "C:/Users/mike/dev-stuff/t3code", + ), + ).toMatchObject({ + displayPath: + "t3code/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx", + }); + }); + it("does not treat app routes as file links", () => { expect(resolveMarkdownFileLinkTarget("/chat/settings")).toBeNull(); }); diff --git a/apps/web/src/markdown-links.ts b/apps/web/src/markdown-links.ts index b5dcab01006..003fb0409d2 100644 --- a/apps/web/src/markdown-links.ts +++ b/apps/web/src/markdown-links.ts @@ -1,4 +1,5 @@ -import { resolvePathLinkTarget } from "./terminal-links"; +import { formatWorkspaceRelativePath } from "./filePathDisplay"; +import { resolvePathLinkTarget, splitPathAndPosition } from "./terminal-links"; const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/; const WINDOWS_UNC_PATH_PATTERN = /^\\\\/; @@ -21,6 +22,15 @@ const POSIX_FILE_ROOT_PREFIXES = [ "/root/", ] as const; +export interface MarkdownFileLinkMeta { + filePath: string; + targetPath: string; + displayPath: string; + basename: string; + line?: number; + column?: number; +} + function safeDecode(value: string): string { try { return decodeURIComponent(value); @@ -143,3 +153,31 @@ export function resolveMarkdownFileLinkTarget( if (!cwd) return null; return resolvePathLinkTarget(pathWithPosition, cwd); } + +function basenameOfPath(path: string): string { + const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); + return separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path; +} + +export function resolveMarkdownFileLinkMeta( + href: string | undefined, + cwd?: string, +): MarkdownFileLinkMeta | null { + const targetPath = resolveMarkdownFileLinkTarget(href, cwd); + if (!targetPath) return null; + + const { path, line, column } = splitPathAndPosition(targetPath); + const parsedLine = line ? Number.parseInt(line, 10) : Number.NaN; + const parsedColumn = column ? Number.parseInt(column, 10) : Number.NaN; + const lineNumber = Number.isFinite(parsedLine) ? parsedLine : undefined; + const columnNumber = Number.isFinite(parsedColumn) ? parsedColumn : undefined; + + return { + filePath: path, + targetPath, + displayPath: formatWorkspaceRelativePath(targetPath, cwd), + basename: basenameOfPath(path), + ...(lineNumber !== undefined ? { line: lineNumber } : {}), + ...(columnNumber !== undefined ? { column: columnNumber } : {}), + }; +} diff --git a/apps/web/src/rightPanelLayout.ts b/apps/web/src/rightPanelLayout.ts new file mode 100644 index 00000000000..c94f52a9cb2 --- /dev/null +++ b/apps/web/src/rightPanelLayout.ts @@ -0,0 +1,2 @@ +export const RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY = "(max-width: 1180px)"; +export const RIGHT_PANEL_SHEET_CLASS_NAME = "w-[min(88vw,820px)] max-w-[820px] p-0"; diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 3dc512040c3..45a5a3cd300 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -24,6 +24,11 @@ import { AnchoredToastProvider, ToastProvider, toastManager } from "../component import { resolveAndPersistPreferredEditor } from "../editorPreferences"; import { readLocalApi } from "../localApi"; import { useRuntimeToolOutputStore } from "../runtimeToolOutputStore"; +import { useSettings } from "../hooks/useSettings"; +import { + deriveLogicalProjectKeyFromSettings, + derivePhysicalProjectKeyFromPath, +} from "../logicalProject"; import { getServerConfigUpdatedNotification, ServerConfigUpdatedNotification, @@ -226,6 +231,10 @@ function EventRouter() { const setActiveEnvironmentId = useStore((store) => store.setActiveEnvironmentId); const navigate = useNavigate(); const pathname = useLocation({ select: (loc) => loc.pathname }); + const projectGroupingSettings = useSettings((settings) => ({ + sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode, + sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides, + })); const readPathname = useEffectEvent(() => pathname); const handledBootstrapThreadIdRef = useRef(null); const seenServerConfigUpdateIdRef = useRef(getServerConfigUpdatedNotification()?.id ?? 0); @@ -248,14 +257,21 @@ function EventRouter() { if (!payload.bootstrapProjectId || !payload.bootstrapThreadId) { return; } - useUiStateStore - .getState() - .setProjectExpanded( - scopedProjectKey( - scopeProjectRef(payload.environment.environmentId, payload.bootstrapProjectId), - ), - true, + const bootstrapEnvironmentState = + useStore.getState().environmentStateById[payload.environment.environmentId]; + const bootstrapProject = + bootstrapEnvironmentState?.projectById[payload.bootstrapProjectId] ?? null; + const bootstrapProjectKey = + (bootstrapProject + ? deriveLogicalProjectKeyFromSettings(bootstrapProject, projectGroupingSettings) + : null) ?? + (serverConfig?.cwd + ? derivePhysicalProjectKeyFromPath(payload.environment.environmentId, serverConfig.cwd) + : null) ?? + scopedProjectKey( + scopeProjectRef(payload.environment.environmentId, payload.bootstrapProjectId), ); + useUiStateStore.getState().setProjectExpanded(bootstrapProjectKey, true); if (readPathname() !== "/") { return; diff --git a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx index d2527c492f6..d2fd675d0a9 100644 --- a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx +++ b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx @@ -36,11 +36,11 @@ import { } from "../store"; import { createThreadSelectorByRef } from "../storeSelectors"; import { resolveThreadRouteRef, buildThreadRouteParams } from "../threadRoutes"; -import { Sheet, SheetPopup } from "../components/ui/sheet"; +import { RightPanelSheet } from "../components/RightPanelSheet"; +import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; import { Sidebar, SidebarInset, SidebarProvider, SidebarRail } from "~/components/ui/sidebar"; const DiffPanel = lazy(() => import("../components/DiffPanel")); -const DIFF_INLINE_LAYOUT_MEDIA_QUERY = "(max-width: 1180px)"; const DIFF_INLINE_SIDEBAR_WIDTH_STORAGE_KEY = "chat_diff_sidebar_width"; const DIFF_INLINE_DEFAULT_WIDTH = "clamp(28rem,48vw,44rem)"; const DIFF_INLINE_SIDEBAR_MIN_WIDTH = 26 * 16; @@ -48,32 +48,6 @@ const COMPOSER_COMPACT_MIN_LEFT_CONTROLS_WIDTH_PX = 208; const HYDRATION_RETRY_DELAY_MS = 1000; const MAX_HYDRATION_RETRIES = 5; -const DiffPanelSheet = (props: { - children: ReactNode; - diffOpen: boolean; - onCloseDiff: () => void; -}) => { - return ( - { - if (!open) { - props.onCloseDiff(); - } - }} - > - - {props.children} - - - ); -}; - const DiffLoadingFallback = (props: { mode: DiffPanelMode }) => { return ( }> @@ -215,7 +189,7 @@ function ChatThreadRouteView() { const serverThreadStarted = threadHasStarted(serverThread); const environmentHasAnyThreads = environmentHasServerThreads || environmentHasDraftThreads; const diffOpen = search.diff === "1"; - const shouldUseDiffSheet = useMediaQuery(DIFF_INLINE_LAYOUT_MEDIA_QUERY); + const shouldUseDiffSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); const currentThreadKey = threadRef ? `${threadRef.environmentId}:${threadRef.threadId}` : null; const [diffPanelMountState, setDiffPanelMountState] = useState(() => ({ threadKey: currentThreadKey, @@ -334,7 +308,11 @@ function ChatThreadRouteView() { return ( <> - + - + - + {shouldRenderDiffContent ? : null} - + ); } diff --git a/apps/web/src/routes/_chat.draft.$draftId.tsx b/apps/web/src/routes/_chat.draft.$draftId.tsx index 0fd1f7fee6a..791d915417e 100644 --- a/apps/web/src/routes/_chat.draft.$draftId.tsx +++ b/apps/web/src/routes/_chat.draft.$draftId.tsx @@ -56,7 +56,11 @@ function DraftChatThreadRouteView() { if (canonicalThreadRef) { return ( - + ); } @@ -67,7 +71,12 @@ function DraftChatThreadRouteView() { return ( - + ); } diff --git a/apps/web/src/routes/pair.tsx b/apps/web/src/routes/pair.tsx index 053816bd773..6925dac69cc 100644 --- a/apps/web/src/routes/pair.tsx +++ b/apps/web/src/routes/pair.tsx @@ -1,17 +1,10 @@ import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"; import { PairingPendingSurface, PairingRouteSurface } from "../components/auth/PairingRouteSurface"; -import { - ensurePrimaryEnvironmentReady, - resolveInitialServerAuthGateState, -} from "../environments/primary"; export const Route = createFileRoute("/pair")({ - beforeLoad: async () => { - const [, authGateState] = await Promise.all([ - ensurePrimaryEnvironmentReady(), - resolveInitialServerAuthGateState(), - ]); + beforeLoad: async ({ context }) => { + const { authGateState } = context; if (authGateState.status === "authenticated") { throw redirect({ to: "/", replace: true }); } diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index dbde1fa6991..482a56b6851 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -52,7 +52,7 @@ function SettingsContentLayout() { )} {isElectron && ( -
+
Settings @@ -83,7 +83,7 @@ function SettingsRouteLayout() { } export const Route = createFileRoute("/settings")({ - beforeLoad: ({ context, location }) => { + beforeLoad: async ({ context, location }) => { if (context.authGateState.status !== "authenticated") { throw redirect({ to: "/pair", replace: true }); } diff --git a/apps/web/src/rpc/wsRpcClient.ts b/apps/web/src/rpc/wsRpcClient.ts index 084190835a4..95e9fde8de3 100644 --- a/apps/web/src/rpc/wsRpcClient.ts +++ b/apps/web/src/rpc/wsRpcClient.ts @@ -39,6 +39,15 @@ type RpcStreamMethod = ? (listener: (event: TEvent) => void, options?: StreamSubscriptionOptions) => () => void : never; +type RpcInputStreamMethod = + RpcMethod extends (input: any, options?: any) => Stream.Stream + ? ( + input: RpcInput, + listener: (event: TEvent) => void, + options?: StreamSubscriptionOptions, + ) => () => void + : never; + interface GitRunStackedActionOptions { readonly onProgress?: (event: GitActionProgressEvent) => void; } @@ -60,6 +69,9 @@ export interface WsRpcClient { readonly browseDirectories: RpcUnaryMethod; readonly writeFile: RpcUnaryMethod; }; + readonly filesystem: { + readonly browse: RpcUnaryMethod; + }; readonly shell: { readonly openInEditor: (input: { readonly cwd: Parameters[0]; @@ -111,8 +123,8 @@ export interface WsRpcClient { readonly dispatchCommand: RpcUnaryMethod; readonly getTurnDiff: RpcUnaryMethod; readonly getFullThreadDiff: RpcUnaryMethod; - readonly replayEvents: RpcUnaryMethod; - readonly onDomainEvent: RpcStreamMethod; + readonly subscribeShell: RpcStreamMethod; + readonly subscribeThread: RpcInputStreamMethod; }; readonly jira: { readonly getConnectionStatus: RpcUnaryNoArgMethod; @@ -158,6 +170,9 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { writeFile: (input) => transport.request((client) => client[WS_METHODS.projectsWriteFile](input)), }, + filesystem: { + browse: (input) => transport.request((client) => client[WS_METHODS.filesystemBrowse](input)), + }, shell: { openInEditor: (input) => transport.request((client) => client[WS_METHODS.shellOpenInEditor](input)), @@ -254,13 +269,15 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { transport.request((client) => client[ORCHESTRATION_WS_METHODS.getTurnDiff](input)), getFullThreadDiff: (input) => transport.request((client) => client[ORCHESTRATION_WS_METHODS.getFullThreadDiff](input)), - replayEvents: (input) => - transport - .request((client) => client[ORCHESTRATION_WS_METHODS.replayEvents](input)) - .then((events) => [...events]), - onDomainEvent: (listener, options) => + subscribeShell: (listener, options) => + transport.subscribe( + (client) => client[ORCHESTRATION_WS_METHODS.subscribeShell]({}), + listener, + options, + ), + subscribeThread: (input, listener, options) => transport.subscribe( - (client) => client[WS_METHODS.subscribeOrchestrationDomainEvents]({}), + (client) => client[ORCHESTRATION_WS_METHODS.subscribeThread](input), listener, options, ), diff --git a/apps/web/src/rpc/wsTransport.test.ts b/apps/web/src/rpc/wsTransport.test.ts index eb50d3aed66..be7babfea77 100644 --- a/apps/web/src/rpc/wsTransport.test.ts +++ b/apps/web/src/rpc/wsTransport.test.ts @@ -421,6 +421,56 @@ describe("WsTransport", () => { await transport.dispose(); }, 5_000); + it("clears slow unary request tracking when the transport reconnects", async () => { + const slowAckThresholdMs = 25; + setSlowRpcAckThresholdMsForTests(slowAckThresholdMs); + const transport = createTransport("ws://localhost:3020"); + + const requestPromise = transport.request((client) => + client[WS_METHODS.serverUpsertKeybinding]({ + command: "terminal.toggle", + key: "ctrl+k", + }), + ); + + await waitFor(() => { + expect(sockets).toHaveLength(1); + }); + + const firstSocket = getSocket(); + firstSocket.open(); + + await waitFor(() => { + expect(firstSocket.sent).toHaveLength(1); + }); + + const firstRequest = JSON.parse(firstSocket.sent[0] ?? "{}") as { id: string }; + + await waitFor(() => { + expect(getSlowRpcAckRequests()).toMatchObject([ + { + requestId: firstRequest.id, + tag: WS_METHODS.serverUpsertKeybinding, + }, + ]); + }, 1_000); + + void requestPromise.catch(() => undefined); + + await transport.reconnect(); + + expect(getSlowRpcAckRequests()).toEqual([]); + + await waitFor(() => { + expect(sockets).toHaveLength(2); + }); + + const secondSocket = getSocket(); + secondSocket.open(); + + await transport.dispose(); + }, 5_000); + it("sends unary RPC requests and resolves successful exits", async () => { const transport = createTransport("ws://localhost:3020"); @@ -643,6 +693,115 @@ describe("WsTransport", () => { await transport.dispose(); }); + it("re-subscribes live stream listeners after an explicit transport reconnect", async () => { + const transport = createTransport("ws://localhost:3020"); + const listener = vi.fn(); + const onResubscribe = vi.fn(); + + const unsubscribe = transport.subscribe( + (client) => client[WS_METHODS.subscribeServerLifecycle]({}), + listener, + { onResubscribe }, + ); + + await waitFor(() => { + expect(sockets).toHaveLength(1); + }); + + const firstSocket = getSocket(); + firstSocket.open(); + + await waitFor(() => { + expect(firstSocket.sent).toHaveLength(1); + }); + + const firstRequest = JSON.parse(firstSocket.sent[0] ?? "{}") as { id: string }; + const firstEvent = { + version: 1, + sequence: 1, + type: "welcome", + payload: { + environment: { + environmentId: "environment-local", + label: "Local environment", + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }, + cwd: "/tmp/one", + projectName: "one", + }, + }; + + firstSocket.serverMessage( + JSON.stringify({ + _tag: "Chunk", + requestId: firstRequest.id, + values: [firstEvent], + }), + ); + + await waitFor(() => { + expect(listener).toHaveBeenLastCalledWith(firstEvent); + }); + + await transport.reconnect(); + + await waitFor(() => { + expect(sockets).toHaveLength(2); + }); + + const secondSocket = getSocket(); + expect(secondSocket).not.toBe(firstSocket); + expect(firstSocket.readyState).toBe(MockWebSocket.CLOSED); + + secondSocket.open(); + + await waitFor(() => { + expect(secondSocket.sent).toHaveLength(1); + }); + + const secondRequest = JSON.parse(secondSocket.sent[0] ?? "{}") as { + id: string; + tag: string; + }; + expect(secondRequest.tag).toBe(WS_METHODS.subscribeServerLifecycle); + expect(secondRequest.id).not.toBe(firstRequest.id); + expect(onResubscribe).toHaveBeenCalledOnce(); + + const secondEvent = { + version: 1, + sequence: 2, + type: "welcome", + payload: { + environment: { + environmentId: "environment-local", + label: "Local environment", + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }, + cwd: "/tmp/two", + projectName: "two", + }, + }; + + secondSocket.serverMessage( + JSON.stringify({ + _tag: "Chunk", + requestId: secondRequest.id, + values: [secondEvent], + }), + ); + + await waitFor(() => { + expect(listener).toHaveBeenLastCalledWith(secondEvent); + }); + + unsubscribe(); + await transport.dispose(); + }); + it("does not fire onResubscribe when the first stream attempt exits before any value", async () => { const transport = createTransport("ws://localhost:3020"); const listener = vi.fn(); diff --git a/apps/web/src/rpc/wsTransport.ts b/apps/web/src/rpc/wsTransport.ts index 851cc5046cb..d9a50a9fad0 100644 --- a/apps/web/src/rpc/wsTransport.ts +++ b/apps/web/src/rpc/wsTransport.ts @@ -12,6 +12,7 @@ import { import { RpcClient } from "effect/unstable/rpc"; import { ClientTracingLive } from "../observability/clientTracing"; +import { clearAllTrackedRpcRequests } from "./requestLatencyState"; import { createWsRpcProtocolLayer, makeWsRpcProtocolClient, @@ -121,6 +122,7 @@ export class WsTransport { return; } + const session = this.session; try { if (hasReceivedValue) { try { @@ -130,7 +132,6 @@ export class WsTransport { } } - const session = this.session; const runningStream = this.runStreamOnSession( session, connect, @@ -150,6 +151,10 @@ export class WsTransport { return; } + if (session !== this.session) { + continue; + } + const formattedError = formatErrorMessage(error); if (!isTransportConnectionErrorMessage(formattedError)) { console.warn("WebSocket RPC subscription failed", { @@ -185,6 +190,7 @@ export class WsTransport { throw new Error("Transport disposed"); } + clearAllTrackedRpcRequests(); const previousSession = this.session; this.session = this.createSession(); await this.closeSession(previousSession); diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 3cc51da5bea..f1198156bc6 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -394,6 +394,30 @@ describe("deriveActivePlanState", () => { steps: [{ step: "Implement Codex user input", status: "inProgress" }], }); }); + + it("falls back to the most recent plan from a previous turn", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "plan-from-turn-1", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "turn.plan.updated", + summary: "Plan updated", + tone: "info", + turnId: "turn-1", + payload: { + plan: [{ step: "Write tests", status: "completed" }], + }, + }), + ]; + + // Current turn is turn-2, which has no plan activity — should fall back to turn-1's plan + const result = deriveActivePlanState(activities, TurnId.make("turn-2")); + expect(result).toEqual({ + createdAt: "2026-02-23T00:00:01.000Z", + turnId: "turn-1", + steps: [{ step: "Write tests", status: "completed" }], + }); + }); }); describe("findLatestProposedPlan", () => { @@ -2005,6 +2029,19 @@ describe("deriveActiveWorkStartedAt", () => { ).toBe("2026-02-27T21:10:00.000Z"); }); + it("uses the new send start while the session is running a different turn", () => { + expect( + deriveActiveWorkStartedAt( + latestTurn, + { + orchestrationStatus: "running", + activeTurnId: TurnId.make("turn-2"), + }, + "2026-02-27T21:11:00.000Z", + ), + ).toBe("2026-02-27T21:11:00.000Z"); + }); + it("falls back to sendStartedAt once the latest turn is settled", () => { expect( deriveActiveWorkStartedAt( diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 16cea0cdd29..51b9a1d54bd 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -189,6 +189,14 @@ export function deriveActiveWorkStartedAt( session: SessionActivityState | null, sendStartedAt: string | null, ): string | null { + const runningTurnId = + session?.orchestrationStatus === "running" ? (session.activeTurnId ?? null) : null; + if (runningTurnId !== null) { + if (latestTurn?.turnId === runningTurnId) { + return latestTurn.startedAt ?? sendStartedAt; + } + return sendStartedAt; + } if (!isLatestTurnSettled(latestTurn, session)) { return latestTurn?.startedAt ?? sendStartedAt; } @@ -386,16 +394,15 @@ export function deriveActivePlanState( latestTurnId: TurnId | undefined, ): ActivePlanState | null { const ordered = [...activities].toSorted(compareActivitiesByOrder); - const candidates = ordered.filter((activity) => { - if (activity.kind !== "turn.plan.updated") { - return false; - } - if (!latestTurnId) { - return true; - } - return activity.turnId === latestTurnId; - }); - const latest = candidates.at(-1); + const allPlanActivities = ordered.filter((activity) => activity.kind === "turn.plan.updated"); + // Prefer plan from the current turn; fall back to the most recent plan from any turn + // so that TodoWrite tasks persist across follow-up messages. + const latest = + (latestTurnId + ? allPlanActivities.filter((activity) => activity.turnId === latestTurnId).at(-1) + : undefined) ?? + allPlanActivities.at(-1) ?? + null; if (!latest) { return null; } @@ -502,48 +509,6 @@ export function hasActionableProposedPlan( return proposedPlan !== null && proposedPlan.implementedAt === null; } -export interface TodoItem { - content: string; - activeForm: string; - status: "in_progress" | "completed" | "pending"; -} - -export function deriveTodoItems( - activities: ReadonlyArray, - latestTurnId: TurnId | undefined, -): TodoItem[] { - const ordered = [...activities].toSorted(compareActivitiesByOrder); - const candidates = ordered.filter( - (activity) => - (latestTurnId ? activity.turnId === latestTurnId : true) && isTodoWriteActivity(activity), - ); - const latest = candidates.at(-1); - if (!latest) return []; - - const payload = asRecord(latest.payload); - const data = asRecord(payload?.data); - const input = asRecord(data?.input); - const rawTodos = input?.todos; - if (!Array.isArray(rawTodos)) return []; - - return rawTodos - .map((entry): TodoItem | null => { - const record = asRecord(entry); - if (!record) return null; - const content = asTrimmedString(record.content); - if (!content) return null; - const activeForm = asTrimmedString(record.activeForm) ?? content; - const status = - record.status === "in_progress" || - record.status === "completed" || - record.status === "pending" - ? record.status - : "pending"; - return { content, activeForm, status }; - }) - .filter((item): item is TodoItem => item !== null); -} - export function deriveWorkLogEntries( activities: ReadonlyArray, latestTurnId: TurnId | undefined, @@ -1002,11 +967,29 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo const commandPreview = extractToolCommand(payload); const changedFiles = extractChangedFiles(payload); const title = extractToolTitle(payload); + const isTaskActivity = activity.kind === "task.progress" || activity.kind === "task.completed"; + const taskSummary = + isTaskActivity && typeof payload?.summary === "string" && payload.summary.length > 0 + ? payload.summary + : null; + const taskDetailAsLabel = + isTaskActivity && + !taskSummary && + typeof payload?.detail === "string" && + payload.detail.length > 0 + ? payload.detail + : null; + const taskLabel = taskSummary || taskDetailAsLabel; const entry: DerivedWorkLogEntry = { id: activity.id, createdAt: activity.createdAt, - label: activity.summary, - tone: activity.tone === "approval" ? "info" : activity.tone, + label: taskLabel || activity.summary, + tone: + activity.kind === "task.progress" + ? "thinking" + : activity.tone === "approval" + ? "info" + : activity.tone, activityKind: activity.kind, ...(activity.kind === "tool.completed" ? { toolCompleted: true } : {}), }; @@ -1022,7 +1005,13 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo entry.exitCode = exitCode; } } - if (!entry.detail && payload && typeof payload.detail === "string" && payload.detail.length > 0) { + if ( + !entry.detail && + !taskDetailAsLabel && + payload && + typeof payload.detail === "string" && + payload.detail.length > 0 + ) { const { output: detail, exitCode } = stripTrailingExitCode(payload.detail); if (detail) { entry.detail = detail; diff --git a/apps/web/src/sidebarProjectGrouping.ts b/apps/web/src/sidebarProjectGrouping.ts new file mode 100644 index 00000000000..68cd84bf310 --- /dev/null +++ b/apps/web/src/sidebarProjectGrouping.ts @@ -0,0 +1,118 @@ +import { scopeProjectRef } from "@marcode/client-runtime"; +import type { EnvironmentId, ScopedProjectRef } from "@marcode/contracts"; +import { + deriveLogicalProjectKeyFromSettings, + derivePhysicalProjectKey, + deriveProjectGroupLabel, + type ProjectGroupingSettings, +} from "./logicalProject"; +import type { Project } from "./types"; + +export type EnvironmentPresence = "local-only" | "remote-only" | "mixed"; + +export interface SidebarProjectGroupMember extends Project { + physicalProjectKey: string; + environmentLabel: string | null; +} + +export interface SidebarProjectSnapshot extends Project { + projectKey: string; + displayName: string; + groupedProjectCount: number; + environmentPresence: EnvironmentPresence; + memberProjects: readonly SidebarProjectGroupMember[]; + memberProjectRefs: readonly ScopedProjectRef[]; + remoteEnvironmentLabels: readonly string[]; +} + +export function buildPhysicalToLogicalProjectKeyMap(input: { + projects: ReadonlyArray; + settings: ProjectGroupingSettings; +}): Map { + const mapping = new Map(); + for (const project of input.projects) { + mapping.set( + derivePhysicalProjectKey(project), + deriveLogicalProjectKeyFromSettings(project, input.settings), + ); + } + return mapping; +} + +export function buildSidebarProjectSnapshots(input: { + projects: ReadonlyArray; + settings: ProjectGroupingSettings; + primaryEnvironmentId: EnvironmentId | null; + resolveEnvironmentLabel: (environmentId: EnvironmentId) => string | null; +}): SidebarProjectSnapshot[] { + const groupedMembers = new Map(); + for (const project of input.projects) { + const logicalKey = deriveLogicalProjectKeyFromSettings(project, input.settings); + const member: SidebarProjectGroupMember = { + ...project, + physicalProjectKey: derivePhysicalProjectKey(project), + environmentLabel: input.resolveEnvironmentLabel(project.environmentId), + }; + const existing = groupedMembers.get(logicalKey); + if (existing) { + existing.push(member); + } else { + groupedMembers.set(logicalKey, [member]); + } + } + + const result: SidebarProjectSnapshot[] = []; + const seen = new Set(); + for (const project of input.projects) { + const logicalKey = deriveLogicalProjectKeyFromSettings(project, input.settings); + if (seen.has(logicalKey)) { + continue; + } + seen.add(logicalKey); + + const members = groupedMembers.get(logicalKey) ?? []; + const representative = + (input.primaryEnvironmentId + ? members.find((member) => member.environmentId === input.primaryEnvironmentId) + : null) ?? members[0]; + if (!representative) { + continue; + } + + const hasLocal = + input.primaryEnvironmentId !== null && + members.some((member) => member.environmentId === input.primaryEnvironmentId); + const hasRemote = + input.primaryEnvironmentId !== null + ? members.some((member) => member.environmentId !== input.primaryEnvironmentId) + : false; + const remoteEnvironmentLabels = members + .filter( + (member) => + input.primaryEnvironmentId !== null && + member.environmentId !== input.primaryEnvironmentId, + ) + .flatMap((member) => (member.environmentLabel ? [member.environmentLabel] : [])) + .filter((label, index, labels) => labels.indexOf(label) === index); + + result.push({ + ...representative, + projectKey: logicalKey, + displayName: + members.length > 1 + ? deriveProjectGroupLabel({ + representative, + members, + }) + : representative.name, + groupedProjectCount: members.length, + environmentPresence: + hasLocal && hasRemote ? "mixed" : hasRemote ? "remote-only" : "local-only", + memberProjects: members, + memberProjectRefs: members.map((member) => scopeProjectRef(member.environmentId, member.id)), + remoteEnvironmentLabels, + }); + } + + return result; +} diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 727cdf74848..ec2a9a6f0e8 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -22,10 +22,53 @@ import { selectThreadExistsByRef, setThreadBranch, selectThreadsAcrossEnvironments, - syncServerReadModel, + syncListingSnapshot, + syncServerThreadDetail, type AppState, type EnvironmentState, } from "./store"; + +function syncServerReadModel( + state: AppState, + readModel: OrchestrationReadModel, + environmentId: EnvironmentId, +): AppState { + const listingThreads = readModel.threads.map((thread) => ({ + id: thread.id, + projectId: thread.projectId, + title: thread.title, + modelSelection: thread.modelSelection, + runtimeMode: thread.runtimeMode, + interactionMode: thread.interactionMode, + branch: thread.branch, + worktreePath: thread.worktreePath, + additionalDirectories: thread.additionalDirectories, + latestTurn: thread.latestTurn, + session: thread.session, + createdAt: thread.createdAt, + updatedAt: thread.updatedAt, + archivedAt: thread.archivedAt, + deletedAt: thread.deletedAt, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + })); + let next = syncListingSnapshot( + state, + { + snapshotSequence: readModel.snapshotSequence, + projects: readModel.projects, + threads: listingThreads, + updatedAt: readModel.updatedAt, + }, + environmentId, + ); + for (const thread of readModel.threads) { + next = syncServerThreadDetail(next, thread, environmentId); + } + return next; +} import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "./types"; const localEnvironmentId = EnvironmentId.make("environment-local"); diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 40cfb45d057..455b3c5085f 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -3,13 +3,17 @@ import type { MessageId, OrchestrationCheckpointSummary, OrchestrationEvent, + OrchestrationLatestTurn, OrchestrationListingSnapshot, OrchestrationMessage, OrchestrationProposedPlan, OrchestrationReadModel, + OrchestrationShellSnapshot, + OrchestrationShellStreamEvent, OrchestrationSession, OrchestrationSessionStatus, OrchestrationThread, + OrchestrationThreadShell, OrchestrationThreadActivity, ProjectId, ProviderKind, @@ -20,12 +24,6 @@ import type { } from "@marcode/contracts"; import { resolveModelSlugForProvider } from "@marcode/shared/model"; import { create } from "zustand"; -import { - derivePendingApprovals, - derivePendingUserInputs, - findLatestProposedPlan, - hasActionableProposedPlan, -} from "./session-logic"; import { type ChatMessage, type Project, @@ -44,11 +42,33 @@ import { getThreadFromEnvironmentState } from "./threadDerivation"; export interface EnvironmentState { projectIds: ProjectId[]; projectById: Record; + + // --------------------------------------------------------------------------- + // Thread bookkeeping — written by BOTH shell stream and detail stream. + // Both streams ensure the thread is registered here; the bookkeeping is + // additive (append-only IDs) so concurrent writes are safe. + // --------------------------------------------------------------------------- threadIds: ThreadId[]; threadIdsByProjectId: Record; + + // --------------------------------------------------------------------------- + // Thread shell / session / turn — written by BOTH shell stream and detail + // stream. The shell stream is the *authoritative* source (server pre- + // computes these from the projection pipeline), but the detail stream also + // writes them so the active thread has up-to-date state even if the shell + // event hasn't arrived yet. Structural equality checks in both write + // functions prevent unnecessary React re-renders when both streams deliver + // equivalent data. + // --------------------------------------------------------------------------- threadShellById: Record; threadSessionById: Record; threadTurnStateById: Record; + + // --------------------------------------------------------------------------- + // Thread detail content — written ONLY by the detail stream + // (writeThreadState / syncServerThreadDetail). The shell stream never + // touches these. + // --------------------------------------------------------------------------- messageIdsByThreadId: Record; messageByThreadId: Record>; activityIdsByThreadId: Record; @@ -57,7 +77,16 @@ export interface EnvironmentState { proposedPlanByThreadId: Record>; turnDiffIdsByThreadId: Record; turnDiffSummaryByThreadId: Record>; + + // --------------------------------------------------------------------------- + // Sidebar summary — written ONLY by the shell stream + // (writeThreadShellState / mapThreadShell). Pre-computed server-side with + // fields like latestUserMessageAt, hasPendingApprovals, etc. The detail + // stream must NOT write here; the shell stream is the single source of + // truth for sidebar data. + // --------------------------------------------------------------------------- sidebarThreadSummaryById: Record; + bootstrapComplete: boolean; } @@ -176,7 +205,9 @@ function mapTurnDiffSummary(checkpoint: OrchestrationCheckpointSummary): TurnDif } function mapProject( - project: OrchestrationReadModel["projects"][number], + project: + | OrchestrationReadModel["projects"][number] + | OrchestrationShellSnapshot["projects"][number], environmentId: EnvironmentId, ): Project { return { @@ -191,7 +222,7 @@ function mapProject( createdAt: project.createdAt, updatedAt: project.updatedAt, scripts: mapProjectScripts(project.scripts), - jiraBoard: project.jiraBoard ?? null, + jiraBoard: "jiraBoard" in project ? (project.jiraBoard ?? null) : null, }; } @@ -223,6 +254,64 @@ function mapThread(thread: OrchestrationThread, environmentId: EnvironmentId): T }; } +function mapThreadShell( + thread: OrchestrationThreadShell, + environmentId: EnvironmentId, +): { + shell: ThreadShell; + session: ThreadSession | null; + turnState: ThreadTurnState; + summary: SidebarThreadSummary; +} { + const shell: ThreadShell = { + id: thread.id, + environmentId, + codexThreadId: null, + projectId: thread.projectId, + title: thread.title, + modelSelection: normalizeModelSelection(thread.modelSelection), + runtimeMode: thread.runtimeMode, + interactionMode: thread.interactionMode, + error: sanitizeThreadErrorMessage(thread.session?.lastError), + createdAt: thread.createdAt, + archivedAt: thread.archivedAt, + updatedAt: thread.updatedAt, + branch: thread.branch, + worktreePath: thread.worktreePath, + additionalDirectories: [...(thread.additionalDirectories ?? [])], + hydrated: false, + }; + const session = thread.session ? mapSession(thread.session) : null; + const turnState: ThreadTurnState = { + latestTurn: thread.latestTurn, + pendingSourceProposedPlan: thread.latestTurn?.sourceProposedPlan, + }; + const summary: SidebarThreadSummary = { + id: thread.id, + environmentId, + projectId: thread.projectId, + title: thread.title, + interactionMode: thread.interactionMode, + session, + createdAt: thread.createdAt, + archivedAt: thread.archivedAt, + updatedAt: thread.updatedAt, + latestTurn: thread.latestTurn, + branch: thread.branch, + worktreePath: thread.worktreePath, + latestUserMessageAt: thread.latestUserMessageAt, + hasPendingApprovals: thread.hasPendingApprovals, + hasPendingUserInput: thread.hasPendingUserInput, + hasActionableProposedPlan: thread.hasActionableProposedPlan, + }; + return { + shell, + session, + turnState, + summary, + }; +} + function toThreadShell(thread: Thread): ThreadShell { return { id: thread.id, @@ -253,40 +342,47 @@ function toThreadTurnState(thread: Thread): ThreadTurnState { }; } -function getLatestUserMessageAt(messages: ReadonlyArray): string | null { - let latestUserMessageAt: string | null = null; - for (const message of messages) { - if (message.role !== "user") { - continue; - } - if (latestUserMessageAt === null || message.createdAt > latestUserMessageAt) { - latestUserMessageAt = message.createdAt; - } - } - return latestUserMessageAt; +function sourceProposedPlansEqual( + left: OrchestrationLatestTurn["sourceProposedPlan"] | undefined, + right: OrchestrationLatestTurn["sourceProposedPlan"] | undefined, +): boolean { + if (left === right) return true; + if (left === undefined || right === undefined) return false; + return left.threadId === right.threadId && left.planId === right.planId; } -function buildSidebarThreadSummary(thread: Thread): SidebarThreadSummary { - return { - id: thread.id, - environmentId: thread.environmentId, - projectId: thread.projectId, - title: thread.title, - interactionMode: thread.interactionMode, - session: thread.session, - createdAt: thread.createdAt, - archivedAt: thread.archivedAt, - updatedAt: thread.updatedAt, - latestTurn: thread.latestTurn, - branch: thread.branch, - worktreePath: thread.worktreePath, - latestUserMessageAt: getLatestUserMessageAt(thread.messages), - hasPendingApprovals: derivePendingApprovals(thread.activities).length > 0, - hasPendingUserInput: derivePendingUserInputs(thread.activities).length > 0, - hasActionableProposedPlan: hasActionableProposedPlan( - findLatestProposedPlan(thread.proposedPlans, thread.latestTurn?.turnId ?? null), - ), - }; +function latestTurnsEqual( + left: OrchestrationLatestTurn | null | undefined, + right: OrchestrationLatestTurn | null | undefined, +): boolean { + if (left === right) return true; + if (left == null || right == null) return false; + return ( + left.turnId === right.turnId && + left.state === right.state && + left.requestedAt === right.requestedAt && + left.startedAt === right.startedAt && + left.completedAt === right.completedAt && + left.assistantMessageId === right.assistantMessageId && + sourceProposedPlansEqual(left.sourceProposedPlan, right.sourceProposedPlan) + ); +} + +function threadSessionsEqual( + left: ThreadSession | null | undefined, + right: ThreadSession | null | undefined, +): boolean { + if (left === right) return true; + if (left == null || right == null) return false; + return ( + left.provider === right.provider && + left.status === right.status && + left.orchestrationStatus === right.orchestrationStatus && + left.activeTurnId === right.activeTurnId && + left.createdAt === right.createdAt && + left.updatedAt === right.updatedAt && + left.lastError === right.lastError + ); } function sidebarThreadSummariesEqual( @@ -299,11 +395,11 @@ function sidebarThreadSummariesEqual( left.projectId === right.projectId && left.title === right.title && left.interactionMode === right.interactionMode && - left.session === right.session && + threadSessionsEqual(left.session, right.session) && left.createdAt === right.createdAt && left.archivedAt === right.archivedAt && left.updatedAt === right.updatedAt && - left.latestTurn === right.latestTurn && + latestTurnsEqual(left.latestTurn, right.latestTurn) && left.branch === right.branch && left.worktreePath === right.worktreePath && left.latestUserMessageAt === right.latestUserMessageAt && @@ -338,8 +434,8 @@ function threadShellsEqual(left: ThreadShell | undefined, right: ThreadShell): b function threadTurnStatesEqual(left: ThreadTurnState | undefined, right: ThreadTurnState): boolean { return ( left !== undefined && - left.latestTurn === right.latestTurn && - left.pendingSourceProposedPlan === right.pendingSourceProposedPlan + latestTurnsEqual(left.latestTurn, right.latestTurn) && + sourceProposedPlansEqual(left.pendingSourceProposedPlan, right.pendingSourceProposedPlan) ); } @@ -413,34 +509,32 @@ function getThreads(state: EnvironmentState): Thread[] { }); } -function writeThreadState( +/** + * Ensure a thread is registered in the bookkeeping indices (threadIds, + * threadIdsByProjectId). Shared by both the shell stream and detail stream + * write paths — the bookkeeping is additive (append-only IDs) so concurrent + * writes from both streams are safe. + */ +function ensureThreadRegistered( state: EnvironmentState, - nextThread: Thread, - previousThread?: Thread, + threadId: ThreadId, + nextProjectId: ProjectId, + previousProjectId: ProjectId | undefined, ): EnvironmentState { - const nextShell = toThreadShell(nextThread); - const nextTurnState = toThreadTurnState(nextThread); - const previousShell = state.threadShellById[nextThread.id]; - const previousTurnState = state.threadTurnStateById[nextThread.id]; - const previousSummary = state.sidebarThreadSummaryById[nextThread.id]; - const nextSummary = buildSidebarThreadSummary(nextThread); - let nextState = state; - if (!state.threadIds.includes(nextThread.id)) { + if (!state.threadIds.includes(threadId)) { nextState = { ...nextState, - threadIds: [...nextState.threadIds, nextThread.id], + threadIds: [...nextState.threadIds, threadId], }; } - const previousProjectId = previousThread?.projectId; - const nextProjectId = nextThread.projectId; if (previousProjectId !== nextProjectId) { let threadIdsByProjectId = nextState.threadIdsByProjectId; if (previousProjectId) { const previousIds = threadIdsByProjectId[previousProjectId] ?? EMPTY_THREAD_IDS; - const nextIds = removeId(previousIds, nextThread.id); + const nextIds = removeId(previousIds, threadId); if (nextIds.length === 0) { const { [previousProjectId]: _removed, ...rest } = threadIdsByProjectId; threadIdsByProjectId = rest as Record; @@ -452,7 +546,7 @@ function writeThreadState( } } const projectThreadIds = threadIdsByProjectId[nextProjectId] ?? EMPTY_THREAD_IDS; - const nextProjectThreadIds = appendId(projectThreadIds, nextThread.id); + const nextProjectThreadIds = appendId(projectThreadIds, threadId); if (!arraysEqual(projectThreadIds, nextProjectThreadIds)) { threadIdsByProjectId = { ...threadIdsByProjectId, @@ -467,6 +561,36 @@ function writeThreadState( } } + return nextState; +} + +/** + * Write thread state from the **detail stream** (per-thread subscription). + * + * Owns: messages, activities, proposed plans, turn diff summaries. + * Also writes threadShellById / threadSessionById / threadTurnStateById so + * the active thread has up-to-date state even if the shell stream event + * hasn't arrived yet (both streams use structural equality checks to avoid + * unnecessary re-renders when delivering equivalent data). + * Does NOT write sidebarThreadSummaryById — that is shell-stream-only. + */ +function writeThreadState( + state: EnvironmentState, + nextThread: Thread, + previousThread?: Thread, +): EnvironmentState { + const nextShell = toThreadShell(nextThread); + const nextTurnState = toThreadTurnState(nextThread); + const previousShell = state.threadShellById[nextThread.id]; + const previousTurnState = state.threadTurnStateById[nextThread.id]; + + let nextState = ensureThreadRegistered( + state, + nextThread.id, + nextThread.projectId, + previousThread?.projectId, + ); + if (!threadShellsEqual(previousShell, nextShell)) { nextState = { ...nextState, @@ -477,7 +601,7 @@ function writeThreadState( }; } - if ((previousThread?.session ?? null) !== nextThread.session) { + if (!threadSessionsEqual(previousThread?.session ?? null, nextThread.session)) { nextState = { ...nextState, threadSessionById: { @@ -557,12 +681,92 @@ function writeThreadState( }; } - if (!sidebarThreadSummariesEqual(previousSummary, nextSummary)) { + return nextState; +} + +/** + * Write thread state from the **shell stream** (all-threads subscription). + * + * Owns: sidebarThreadSummaryById (pre-computed server-side sidebar data). + * Also writes threadShellById / threadSessionById / threadTurnStateById as + * the authoritative source for these fields. The detail stream may also + * write them for the focused thread (see writeThreadState); structural + * equality checks prevent unnecessary re-renders. + * Does NOT write message/activity/proposedPlan/turnDiff content — that is + * detail-stream-only. + */ +function writeThreadShellState( + state: EnvironmentState, + nextThread: { + shell: ThreadShell; + session: ThreadSession | null; + turnState: ThreadTurnState; + summary: SidebarThreadSummary; + }, +): EnvironmentState { + const previousShell = state.threadShellById[nextThread.shell.id]; + + let nextState = ensureThreadRegistered( + state, + nextThread.shell.id, + nextThread.shell.projectId, + previousShell?.projectId, + ); + + // The shell stream does not know whether detail data has been loaded; it + // always emits hydrated:false. If the detail stream has already hydrated + // this thread, preserve that so mid-turn shell updates (e.g. updatedAt + // ticks) don't reset the chat view to the hydration skeleton. + const mergedShell: ThreadShell = + previousShell?.hydrated && !nextThread.shell.hydrated + ? { ...nextThread.shell, hydrated: true } + : nextThread.shell; + + if (!threadShellsEqual(previousShell, mergedShell)) { + nextState = { + ...nextState, + threadShellById: { + ...nextState.threadShellById, + [nextThread.shell.id]: mergedShell, + }, + }; + } + + if ( + !threadSessionsEqual(state.threadSessionById[nextThread.shell.id] ?? null, nextThread.session) + ) { + nextState = { + ...nextState, + threadSessionById: { + ...nextState.threadSessionById, + [nextThread.shell.id]: nextThread.session, + }, + }; + } + + if ( + !threadTurnStatesEqual(state.threadTurnStateById[nextThread.shell.id], nextThread.turnState) + ) { + nextState = { + ...nextState, + threadTurnStateById: { + ...nextState.threadTurnStateById, + [nextThread.shell.id]: nextThread.turnState, + }, + }; + } + + if ( + !sidebarThreadSummariesEqual( + state.sidebarThreadSummaryById[nextThread.shell.id], + nextThread.summary, + ) + ) { nextState = { ...nextState, sidebarThreadSummaryById: { ...nextState.sidebarThreadSummaryById, - [nextThread.id]: nextSummary, + [nextThread.shell.id]: nextThread.summary, }, }; } @@ -570,6 +774,17 @@ function writeThreadState( return nextState; } +function retainThreadScopedRecord( + record: Record, + nextThreadIds: ReadonlySet, +): Record { + return Object.fromEntries( + Object.entries(record).flatMap(([threadId, value]) => + nextThreadIds.has(threadId as ThreadId) ? [[threadId, value] as const] : [], + ), + ) as Record; +} + function removeThreadState(state: EnvironmentState, threadId: ThreadId): EnvironmentState { const shell = state.threadShellById[threadId]; if (!shell) { @@ -842,82 +1057,6 @@ function buildProjectState( }; } -function buildThreadState( - threads: ReadonlyArray, -): Pick< - EnvironmentState, - | "threadIds" - | "threadIdsByProjectId" - | "threadShellById" - | "threadSessionById" - | "threadTurnStateById" - | "messageIdsByThreadId" - | "messageByThreadId" - | "activityIdsByThreadId" - | "activityByThreadId" - | "proposedPlanIdsByThreadId" - | "proposedPlanByThreadId" - | "turnDiffIdsByThreadId" - | "turnDiffSummaryByThreadId" - | "sidebarThreadSummaryById" -> { - const threadIds: ThreadId[] = []; - const threadIdsByProjectId: Record = {}; - const threadShellById: Record = {}; - const threadSessionById: Record = {}; - const threadTurnStateById: Record = {}; - const messageIdsByThreadId: Record = {}; - const messageByThreadId: Record> = {}; - const activityIdsByThreadId: Record = {}; - const activityByThreadId: Record> = {}; - const proposedPlanIdsByThreadId: Record = {}; - const proposedPlanByThreadId: Record> = {}; - const turnDiffIdsByThreadId: Record = {}; - const turnDiffSummaryByThreadId: Record> = {}; - const sidebarThreadSummaryById: Record = {}; - - for (const thread of threads) { - threadIds.push(thread.id); - threadIdsByProjectId[thread.projectId] = [ - ...(threadIdsByProjectId[thread.projectId] ?? EMPTY_THREAD_IDS), - thread.id, - ]; - threadShellById[thread.id] = toThreadShell(thread); - threadSessionById[thread.id] = thread.session; - threadTurnStateById[thread.id] = toThreadTurnState(thread); - const messageSlice = buildMessageSlice(thread); - messageIdsByThreadId[thread.id] = messageSlice.ids; - messageByThreadId[thread.id] = messageSlice.byId; - const activitySlice = buildActivitySlice(thread); - activityIdsByThreadId[thread.id] = activitySlice.ids; - activityByThreadId[thread.id] = activitySlice.byId; - const proposedPlanSlice = buildProposedPlanSlice(thread); - proposedPlanIdsByThreadId[thread.id] = proposedPlanSlice.ids; - proposedPlanByThreadId[thread.id] = proposedPlanSlice.byId; - const turnDiffSlice = buildTurnDiffSlice(thread); - turnDiffIdsByThreadId[thread.id] = turnDiffSlice.ids; - turnDiffSummaryByThreadId[thread.id] = turnDiffSlice.byId; - sidebarThreadSummaryById[thread.id] = buildSidebarThreadSummary(thread); - } - - return { - threadIds, - threadIdsByProjectId, - threadShellById, - threadSessionById, - threadTurnStateById, - messageIdsByThreadId, - messageByThreadId, - activityIdsByThreadId, - activityByThreadId, - proposedPlanIdsByThreadId, - proposedPlanByThreadId, - turnDiffIdsByThreadId, - turnDiffSummaryByThreadId, - sidebarThreadSummaryById, - }; -} - function getStoredEnvironmentState( state: AppState, environmentId: EnvironmentId, @@ -949,36 +1088,57 @@ function commitEnvironmentState( }; } -function syncEnvironmentReadModel( +function syncEnvironmentShellSnapshot( state: EnvironmentState, - readModel: OrchestrationReadModel, + snapshot: OrchestrationShellSnapshot, environmentId: EnvironmentId, ): EnvironmentState { - const projects = readModel.projects - .filter((project) => project.deletedAt === null) - .map((project) => mapProject(project, environmentId)); - const threads = readModel.threads - .filter((thread) => thread.deletedAt === null) - .map((thread) => mapThread(thread, environmentId)); - return { + const nextProjects = snapshot.projects.map((project) => mapProject(project, environmentId)); + const nextThreadIds = new Set(snapshot.threads.map((thread) => thread.id)); + let nextState: EnvironmentState = { ...state, - ...buildProjectState(projects), - ...buildThreadState(threads), + ...buildProjectState(nextProjects), + threadIds: [], + threadIdsByProjectId: {}, + threadShellById: {}, + threadSessionById: {}, + threadTurnStateById: {}, + sidebarThreadSummaryById: {}, + messageIdsByThreadId: retainThreadScopedRecord(state.messageIdsByThreadId, nextThreadIds), + messageByThreadId: retainThreadScopedRecord(state.messageByThreadId, nextThreadIds), + activityIdsByThreadId: retainThreadScopedRecord(state.activityIdsByThreadId, nextThreadIds), + activityByThreadId: retainThreadScopedRecord(state.activityByThreadId, nextThreadIds), + proposedPlanIdsByThreadId: retainThreadScopedRecord( + state.proposedPlanIdsByThreadId, + nextThreadIds, + ), + proposedPlanByThreadId: retainThreadScopedRecord(state.proposedPlanByThreadId, nextThreadIds), + turnDiffIdsByThreadId: retainThreadScopedRecord(state.turnDiffIdsByThreadId, nextThreadIds), + turnDiffSummaryByThreadId: retainThreadScopedRecord( + state.turnDiffSummaryByThreadId, + nextThreadIds, + ), bootstrapComplete: true, }; + + for (const thread of snapshot.threads) { + nextState = writeThreadShellState(nextState, mapThreadShell(thread, environmentId)); + } + + return nextState; } -export function syncServerReadModel( +export function syncServerShellSnapshot( state: AppState, - readModel: OrchestrationReadModel, + snapshot: OrchestrationShellSnapshot, environmentId: EnvironmentId, ): AppState { return commitEnvironmentState( state, environmentId, - syncEnvironmentReadModel( + syncEnvironmentShellSnapshot( getStoredEnvironmentState(state, environmentId), - readModel, + snapshot, environmentId, ), ); @@ -1049,14 +1209,40 @@ export function syncListingSnapshot( }; } - const nextEnvState: EnvironmentState = { + const nextThreadIds = new Set(threads.map((thread) => thread.id)); + let nextEnvState: EnvironmentState = { ...envState, ...buildProjectState(projects), - ...buildThreadState(threads), + threadIds: [], + threadIdsByProjectId: {}, + threadShellById: {}, + threadSessionById: {}, + threadTurnStateById: {}, sidebarThreadSummaryById, + messageIdsByThreadId: retainThreadScopedRecord(envState.messageIdsByThreadId, nextThreadIds), + messageByThreadId: retainThreadScopedRecord(envState.messageByThreadId, nextThreadIds), + activityIdsByThreadId: retainThreadScopedRecord(envState.activityIdsByThreadId, nextThreadIds), + activityByThreadId: retainThreadScopedRecord(envState.activityByThreadId, nextThreadIds), + proposedPlanIdsByThreadId: retainThreadScopedRecord( + envState.proposedPlanIdsByThreadId, + nextThreadIds, + ), + proposedPlanByThreadId: retainThreadScopedRecord( + envState.proposedPlanByThreadId, + nextThreadIds, + ), + turnDiffIdsByThreadId: retainThreadScopedRecord(envState.turnDiffIdsByThreadId, nextThreadIds), + turnDiffSummaryByThreadId: retainThreadScopedRecord( + envState.turnDiffSummaryByThreadId, + nextThreadIds, + ), bootstrapComplete: true, }; + for (const thread of threads) { + nextEnvState = writeThreadState(nextEnvState, thread); + } + return commitEnvironmentState(state, environmentId, nextEnvState); } @@ -1066,8 +1252,9 @@ export function hydrateThread( environmentId: EnvironmentId, ): AppState { const envState = getStoredEnvironmentState(state, environmentId); + const previousThread = getThreadFromEnvironmentState(envState, fullThread.id); const mapped = mapThread(fullThread, environmentId); - const nextEnvState = writeThreadState(envState, mapped); + const nextEnvState = writeThreadState(envState, mapped, previousThread); return commitEnvironmentState(state, environmentId, nextEnvState); } @@ -1075,6 +1262,20 @@ export function isThreadHydrated(thread: Thread): boolean { return thread.hydrated; } +export function syncServerThreadDetail( + state: AppState, + thread: OrchestrationThread, + environmentId: EnvironmentId, +): AppState { + const environmentState = getStoredEnvironmentState(state, environmentId); + const previousThread = getThreadFromEnvironmentState(environmentState, thread.id); + return commitEnvironmentState( + state, + environmentId, + writeThreadState(environmentState, mapThread(thread, environmentId), previousThread), + ); +} + function applyEnvironmentOrchestrationEvent( state: EnvironmentState, event: OrchestrationEvent, @@ -1573,6 +1774,67 @@ function applyEnvironmentOrchestrationEvent( return state; } +function applyEnvironmentShellEvent( + state: EnvironmentState, + event: OrchestrationShellStreamEvent, + environmentId: EnvironmentId, +): EnvironmentState { + switch (event.kind) { + case "project-upserted": { + const nextProject = mapProject(event.project, environmentId); + const existingProjectId = + state.projectIds.find( + (projectId) => + projectId === event.project.id || + state.projectById[projectId]?.cwd === event.project.workspaceRoot, + ) ?? null; + let projectById = state.projectById; + let projectIds = state.projectIds; + + if (existingProjectId !== null && existingProjectId !== nextProject.id) { + const { [existingProjectId]: _removedProject, ...restProjectById } = state.projectById; + projectById = { + ...restProjectById, + [nextProject.id]: nextProject, + }; + projectIds = state.projectIds.map((projectId) => + projectId === existingProjectId ? nextProject.id : projectId, + ); + } else { + projectById = { + ...state.projectById, + [nextProject.id]: nextProject, + }; + projectIds = + existingProjectId === null && !state.projectIds.includes(nextProject.id) + ? [...state.projectIds, nextProject.id] + : state.projectIds; + } + + return { + ...state, + projectById, + projectIds, + }; + } + case "project-removed": { + if (!state.projectById[event.projectId]) { + return state; + } + const { [event.projectId]: _removedProject, ...projectById } = state.projectById; + return { + ...state, + projectById, + projectIds: removeId(state.projectIds, event.projectId), + }; + } + case "thread-upserted": + return writeThreadShellState(state, mapThreadShell(event.thread, environmentId)); + case "thread-removed": + return removeThreadState(state, event.threadId); + } +} + export function applyOrchestrationEvents( state: AppState, events: ReadonlyArray, @@ -1756,6 +2018,22 @@ export function applyOrchestrationEvent( ); } +export function applyShellEvent( + state: AppState, + event: OrchestrationShellStreamEvent, + environmentId: EnvironmentId, +): AppState { + return commitEnvironmentState( + state, + environmentId, + applyEnvironmentShellEvent( + getStoredEnvironmentState(state, environmentId), + event, + environmentId, + ), + ); +} + export function setActiveEnvironmentId(state: AppState, environmentId: EnvironmentId): AppState { if (state.activeEnvironmentId === environmentId) { return state; @@ -1792,17 +2070,22 @@ export function setThreadBranch( interface AppStore extends AppState { setActiveEnvironmentId: (environmentId: EnvironmentId) => void; - syncServerReadModel: (readModel: OrchestrationReadModel, environmentId: EnvironmentId) => void; + syncServerShellSnapshot: ( + snapshot: OrchestrationShellSnapshot, + environmentId: EnvironmentId, + ) => void; syncListingSnapshot: ( listing: OrchestrationListingSnapshot, environmentId: EnvironmentId, ) => void; hydrateThread: (fullThread: OrchestrationThread, environmentId: EnvironmentId) => void; + syncServerThreadDetail: (thread: OrchestrationThread, environmentId: EnvironmentId) => void; applyOrchestrationEvent: (event: OrchestrationEvent, environmentId: EnvironmentId) => void; applyOrchestrationEvents: ( events: ReadonlyArray, environmentId: EnvironmentId, ) => void; + applyShellEvent: (event: OrchestrationShellStreamEvent, environmentId: EnvironmentId) => void; setError: (threadId: ThreadId, error: string | null) => void; setThreadBranch: ( threadRef: ScopedThreadRef, @@ -1815,16 +2098,20 @@ export const useStore = create((set) => ({ ...initialState, setActiveEnvironmentId: (environmentId) => set((state) => setActiveEnvironmentId(state, environmentId)), - syncServerReadModel: (readModel, environmentId) => - set((state) => syncServerReadModel(state, readModel, environmentId)), + syncServerShellSnapshot: (snapshot, environmentId) => + set((state) => syncServerShellSnapshot(state, snapshot, environmentId)), syncListingSnapshot: (listing, environmentId) => set((state) => syncListingSnapshot(state, listing, environmentId)), hydrateThread: (fullThread, environmentId) => set((state) => hydrateThread(state, fullThread, environmentId)), + syncServerThreadDetail: (thread, environmentId) => + set((state) => syncServerThreadDetail(state, thread, environmentId)), applyOrchestrationEvent: (event, environmentId) => set((state) => applyOrchestrationEvent(state, event, environmentId)), applyOrchestrationEvents: (events, environmentId) => set((state) => applyOrchestrationEvents(state, events, environmentId)), + applyShellEvent: (event, environmentId) => + set((state) => applyShellEvent(state, event, environmentId)), setError: (threadId, error) => set((state) => setError(state, threadId, error)), setThreadBranch: (threadRef, branch, worktreePath) => set((state) => setThreadBranch(state, threadRef, branch, worktreePath)), diff --git a/apps/web/src/storeSelectors.ts b/apps/web/src/storeSelectors.ts index af05503c499..7c12d26d588 100644 --- a/apps/web/src/storeSelectors.ts +++ b/apps/web/src/storeSelectors.ts @@ -23,15 +23,6 @@ export function createProjectSelectorByRef( ref ? selectEnvironmentState(state, ref.environmentId).projectById[ref.projectId] : undefined; } -export function createSidebarThreadSummarySelectorByRef( - ref: ScopedThreadRef | null | undefined, -): (state: AppState) => SidebarThreadSummary | undefined { - return (state) => - ref - ? selectEnvironmentState(state, ref.environmentId).sidebarThreadSummaryById[ref.threadId] - : undefined; -} - function createScopedThreadSelector( resolveRef: (state: AppState) => ScopedThreadRef | null | undefined, ): (state: AppState) => Thread | undefined { diff --git a/apps/web/src/terminal-links.ts b/apps/web/src/terminal-links.ts index 0d40a90e415..a4eeda4279c 100644 --- a/apps/web/src/terminal-links.ts +++ b/apps/web/src/terminal-links.ts @@ -137,7 +137,7 @@ function inferHomeFromCwd(cwd: string): string | undefined { return undefined; } -function splitPathAndPosition(value: string): { +export function splitPathAndPosition(value: string): { path: string; line: string | undefined; column: string | undefined; diff --git a/apps/web/src/timestampFormat.ts b/apps/web/src/timestampFormat.ts index ffa80e4589d..32294ccd947 100644 --- a/apps/web/src/timestampFormat.ts +++ b/apps/web/src/timestampFormat.ts @@ -57,8 +57,7 @@ export function formatRelativeTime(isoDate: string): { value: string; suffix: st const diffMs = Date.now() - new Date(isoDate).getTime(); if (diffMs < 0) return { value: "just now", suffix: null }; const seconds = Math.floor(diffMs / 1000); - if (seconds < 5) return { value: "just now", suffix: null }; - if (seconds < 60) return { value: `${seconds}s`, suffix: "ago" }; + if (seconds < 60) return { value: "just now", suffix: null }; const minutes = Math.floor(seconds / 60); if (minutes < 60) return { value: `${minutes}m`, suffix: "ago" }; const hours = Math.floor(minutes / 60); diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index 90285dfaa3a..64f0c283b0f 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -1,9 +1,4 @@ -import { - type ContextMenuItem, - type LocalApi, - type EnvironmentApi, - type OrchestrationEvent, -} from "@marcode/contracts"; +import { type ContextMenuItem, type LocalApi, type EnvironmentApi } from "@marcode/contracts"; import { resetGitStatusStateForTests } from "./lib/gitStatusState"; import { showContextMenuFallback } from "./contextMenuFallback"; @@ -69,6 +64,9 @@ export function createWsNativeApi(): MarCodeNativeApi { browseDirectories: rpcClient.projects.browseDirectories, writeFile: rpcClient.projects.writeFile, }, + filesystem: { + browse: rpcClient.filesystem.browse, + }, shell: { openInEditor: (cwd, editor) => rpcClient.shell.openInEditor({ cwd, editor }), openExternal: async (url) => { @@ -166,12 +164,10 @@ export function createWsNativeApi(): MarCodeNativeApi { dispatchCommand: rpcClient.orchestration.dispatchCommand, getTurnDiff: rpcClient.orchestration.getTurnDiff, getFullThreadDiff: rpcClient.orchestration.getFullThreadDiff, - replayEvents: (fromSequenceExclusive) => - rpcClient.orchestration - .replayEvents({ fromSequenceExclusive }) - .then((events: readonly OrchestrationEvent[]) => [...events]), - onDomainEvent: (callback, options) => - rpcClient.orchestration.onDomainEvent(callback, options), + subscribeShell: (callback, options) => + rpcClient.orchestration.subscribeShell(callback, options), + subscribeThread: (input, callback, options) => + rpcClient.orchestration.subscribeThread(input, callback, options), }, jira: { getConnectionStatus: rpcClient.jira.getConnectionStatus, diff --git a/apps/web/test/wsRpcHarness.ts b/apps/web/test/wsRpcHarness.ts index 0473d37fe57..f0604b2a097 100644 --- a/apps/web/test/wsRpcHarness.ts +++ b/apps/web/test/wsRpcHarness.ts @@ -1,5 +1,5 @@ import { Effect, Exit, PubSub, Scope, Stream } from "effect"; -import { WS_METHODS, WsRpcGroup } from "@marcode/contracts"; +import { ORCHESTRATION_WS_METHODS, WS_METHODS, WsRpcGroup } from "@marcode/contracts"; import { RpcMessage, RpcSerialization, RpcServer } from "effect/unstable/rpc"; type RpcServerInstance = RpcServer.RpcServer; @@ -23,9 +23,10 @@ interface BrowserWsRpcHarnessOptions { } const STREAM_METHODS = new Set([ + ORCHESTRATION_WS_METHODS.subscribeShell, + ORCHESTRATION_WS_METHODS.subscribeThread, WS_METHODS.gitRunStackedAction, WS_METHODS.subscribeGitStatus, - WS_METHODS.subscribeOrchestrationDomainEvents, WS_METHODS.subscribeTerminalEvents, WS_METHODS.subscribeServerConfig, WS_METHODS.subscribeServerLifecycle, diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 178f4bcbabf..4dd68d7213f 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -2,6 +2,10 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "composite": true, + "module": "Preserve", + "moduleResolution": "Bundler", + "erasableSyntaxOnly": false, + "verbatimModuleSyntax": false, "jsx": "react-jsx", "lib": ["ES2023", "DOM", "DOM.Iterable"], "types": ["vite/client"], diff --git a/apps/web/vitest.browser.config.ts b/apps/web/vitest.browser.config.ts index b152d822104..ca02f2e9f15 100644 --- a/apps/web/vitest.browser.config.ts +++ b/apps/web/vitest.browser.config.ts @@ -14,6 +14,11 @@ export default mergeConfig( "~": srcPath, }, }, + server: { + // The app dev server uses a fixed port, but browser tests need to allow + // concurrent runs to claim the next available port. + strictPort: false, + }, test: { include: ["src/components/**/*.browser.tsx"], browser: { @@ -21,6 +26,9 @@ export default mergeConfig( provider: playwright(), instances: [{ browser: "chromium" }], headless: true, + api: { + strictPort: false, + }, }, testTimeout: 30_000, hookTimeout: 30_000, diff --git a/assets/dev/blueprint-icon-composer.icon/Assets/T3.svg b/assets/dev/blueprint-icon-composer.icon/Assets/T3.svg new file mode 100644 index 00000000000..b12706fdfc2 --- /dev/null +++ b/assets/dev/blueprint-icon-composer.icon/Assets/T3.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/dev/blueprint-icon-composer.icon/Assets/Texturelabs_Paper_381XL.jpg b/assets/dev/blueprint-icon-composer.icon/Assets/Texturelabs_Paper_381XL.jpg new file mode 100644 index 00000000000..d98c41a4e3e Binary files /dev/null and b/assets/dev/blueprint-icon-composer.icon/Assets/Texturelabs_Paper_381XL.jpg differ diff --git a/assets/dev/blueprint-icon-composer.icon/Assets/gpt-image-1.5-kn76n9hynne5q3qxy3g91chp2d84t871.png b/assets/dev/blueprint-icon-composer.icon/Assets/gpt-image-1.5-kn76n9hynne5q3qxy3g91chp2d84t871.png new file mode 100644 index 00000000000..fa7700d01c7 Binary files /dev/null and b/assets/dev/blueprint-icon-composer.icon/Assets/gpt-image-1.5-kn76n9hynne5q3qxy3g91chp2d84t871.png differ diff --git a/assets/dev/blueprint-icon-composer.icon/icon.json b/assets/dev/blueprint-icon-composer.icon/icon.json new file mode 100644 index 00000000000..1c15123dab4 --- /dev/null +++ b/assets/dev/blueprint-icon-composer.icon/icon.json @@ -0,0 +1,47 @@ +{ + "fill": { + "solid": "display-p3:0.00000,0.00000,0.00000,1.00000" + }, + "groups": [ + { + "layers": [ + { + "blend-mode": "normal", + "fill": "automatic", + "glass": true, + "hidden": false, + "image-name": "gpt-image-1.5-kn76n9hynne5q3qxy3g91chp2d84t871.png", + "name": "gpt-image-1.5-kn76n9hynne5q3qxy3g91chp2d84t871", + "position": { + "scale": 1.05, + "translation-in-points": [0, 0] + } + }, + { + "image-name": "T3.svg", + "name": "T3", + "position": { + "scale": 10, + "translation-in-points": [0, 0] + } + }, + { + "hidden": false, + "image-name": "Texturelabs_Paper_381XL.jpg", + "name": "Texturelabs_Paper_381XL" + } + ], + "shadow": { + "kind": "neutral", + "opacity": 0.5 + }, + "translucency": { + "enabled": true, + "value": 0.5 + } + } + ], + "supported-platforms": { + "squares": ["iOS", "macOS"] + } +} diff --git a/assets/nightly/blueprint-ios-1024.png b/assets/nightly/blueprint-ios-1024.png new file mode 100644 index 00000000000..b33d6f337b0 Binary files /dev/null and b/assets/nightly/blueprint-ios-1024.png differ diff --git a/assets/nightly/blueprint-macos-1024.png b/assets/nightly/blueprint-macos-1024.png new file mode 100644 index 00000000000..8dba03e01fe Binary files /dev/null and b/assets/nightly/blueprint-macos-1024.png differ diff --git a/assets/nightly/blueprint-universal-1024.png b/assets/nightly/blueprint-universal-1024.png new file mode 100644 index 00000000000..b33d6f337b0 Binary files /dev/null and b/assets/nightly/blueprint-universal-1024.png differ diff --git a/assets/nightly/blueprint-web-apple-touch-180.png b/assets/nightly/blueprint-web-apple-touch-180.png new file mode 100644 index 00000000000..e0e1b9659b8 Binary files /dev/null and b/assets/nightly/blueprint-web-apple-touch-180.png differ diff --git a/assets/nightly/blueprint-web-favicon-16x16.png b/assets/nightly/blueprint-web-favicon-16x16.png new file mode 100644 index 00000000000..673d8459998 Binary files /dev/null and b/assets/nightly/blueprint-web-favicon-16x16.png differ diff --git a/assets/nightly/blueprint-web-favicon-32x32.png b/assets/nightly/blueprint-web-favicon-32x32.png new file mode 100644 index 00000000000..25bcc95d4aa Binary files /dev/null and b/assets/nightly/blueprint-web-favicon-32x32.png differ diff --git a/assets/nightly/blueprint-web-favicon.ico b/assets/nightly/blueprint-web-favicon.ico new file mode 100644 index 00000000000..36975b99782 Binary files /dev/null and b/assets/nightly/blueprint-web-favicon.ico differ diff --git a/assets/nightly/blueprint-windows.ico b/assets/nightly/blueprint-windows.ico new file mode 100644 index 00000000000..36975b99782 Binary files /dev/null and b/assets/nightly/blueprint-windows.ico differ diff --git a/bun.lock b/bun.lock index 0d580700db4..d111bf14177 100644 --- a/bun.lock +++ b/bun.lock @@ -100,7 +100,6 @@ "@tanstack/react-pacer": "^0.19.4", "@tanstack/react-query": "^5.90.0", "@tanstack/react-router": "^1.160.2", - "@tanstack/react-virtual": "^3.13.18", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", @@ -215,7 +214,7 @@ "@effect/platform-node-shared": "4.0.0-beta.45", "@effect/sql-sqlite-bun": "4.0.0-beta.45", "@effect/vitest": "4.0.0-beta.45", - "@types/bun": "^1.3.9", + "@types/bun": "^1.3.11", "@types/node": "^24.10.13", "effect": "4.0.0-beta.45", "tsdown": "^0.20.3", @@ -745,8 +744,6 @@ "@tanstack/react-store": ["@tanstack/react-store@0.8.1", "", { "dependencies": { "@tanstack/store": "0.8.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-XItJt+rG8c5Wn/2L/bnxys85rBpm0BfMbhb4zmPVLXAKY9POrp1xd6IbU4PKoOI+jSEGc3vntPRfLGSgXfE2Ig=="], - "@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.23", "", { "dependencies": { "@tanstack/virtual-core": "3.13.23" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-XnMRnHQ23piOVj2bzJqHrRrLg4r+F86fuBcwteKfbIjJrtGxb4z7tIvPVAe4B+4UVwo9G4Giuz5fmapcrnZ0OQ=="], - "@tanstack/router-core": ["@tanstack/router-core@1.167.3", "", { "dependencies": { "@tanstack/history": "1.161.6", "@tanstack/store": "^0.9.1", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-M/CxrTGKk1fsySJjd+Pzpbi3YLDz+cJSutDjSTMy12owWlOgHV/I6kzR0UxyaBlHraM6XgMHNA0XdgsS1fa4Nw=="], "@tanstack/router-generator": ["@tanstack/router-generator@1.166.11", "", { "dependencies": { "@tanstack/router-core": "1.167.3", "@tanstack/router-utils": "1.161.6", "@tanstack/virtual-file-routes": "1.161.6", "prettier": "^3.5.0", "recast": "^0.23.11", "source-map": "^0.7.4", "tsx": "^4.19.2", "zod": "^3.24.2" } }, "sha512-Q/49wxURbft1oNOvo/eVAWZq/lNLK3nBGlavqhLToAYXY6LCzfMtRlE/y3XPHzYC9pZc09u5jvBR1k1E4hyGDQ=="], @@ -757,8 +754,6 @@ "@tanstack/store": ["@tanstack/store@0.9.2", "", {}, "sha512-K013lUJEFJK2ofFQ/hZKJUmCnpcV00ebLyOyFOWQvyQHUOZp/iYO84BM6aOGiV81JzwbX0APTVmW8YI7yiG5oA=="], - "@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.23", "", {}, "sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg=="], - "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.161.6", "", {}, "sha512-EGWs9yvJA821pUkwkiZLQW89CzUumHyJy8NKq229BubyoWXfDw1oWnTJYSS/hhbLiwP9+KpopjeF5wWwnCCyeQ=="], "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], @@ -771,7 +766,7 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], - "@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="], + "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], "@types/cacheable-request": ["@types/cacheable-request@6.0.3", "", { "dependencies": { "@types/http-cache-semantics": "*", "@types/keyv": "^3.1.4", "@types/node": "*", "@types/responselike": "^1.0.0" } }, "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw=="], @@ -889,7 +884,7 @@ "builder-util-runtime": ["builder-util-runtime@9.5.1", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ=="], - "bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="], + "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], @@ -1795,8 +1790,6 @@ "@marcode/web/lucide-react": ["lucide-react@0.564.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-JJ8GVTQqFwuliifD48U6+h7DXEHdkhJ/E87kksGByII3qHxtPciVb8T8woQONHBQgHVOl7rSMrrip3SeVNy7Fg=="], - "@marcode/web/tailwindcss": ["tailwindcss@4.2.2", "", {}, "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="], - "@rolldown/plugin-babel/rolldown": ["rolldown@1.0.0-rc.9", "", { "dependencies": { "@oxc-project/types": "=0.115.0", "@rolldown/pluginutils": "1.0.0-rc.9" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-x64": "1.0.0-rc.9", "@rolldown/binding-freebsd-x64": "1.0.0-rc.9", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.9", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.9", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.9", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q=="], "@tailwindcss/node/tailwindcss": ["tailwindcss@4.2.2", "", {}, "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="], diff --git a/docs/observability.md b/docs/observability.md index 9792dea0f3f..3e38acd5526 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -69,11 +69,11 @@ npx marcode ``` ```bash -bun dev +node --run dev ``` ```bash -bun dev:desktop +node --run dev:desktop ``` ### Option 2: Run With A Local LGTM Stack @@ -122,13 +122,13 @@ npx marcode Monorepo web/server dev: ```bash -bun dev +node --run dev ``` Monorepo desktop dev: ```bash -bun dev:desktop +node --run dev:desktop ``` Packaged desktop app: diff --git a/docs/release.md b/docs/release.md index 0943a02707c..55ed8a9f962 100644 --- a/docs/release.md +++ b/docs/release.md @@ -1,23 +1,47 @@ # Release Checklist -This document covers how to run desktop releases from one tag, first without signing, then with signing. +This document covers the unified release workflow for stable and nightly desktop releases. ## What the workflow does -- Trigger: push tag matching `v*.*.*`. +- Workflow: `.github/workflows/release.yml` +- Triggers: + - push tag matching `v*.*.*` for stable releases + - scheduled nightly at `09:00 UTC` + - manual `workflow_dispatch` for either channel - Runs quality gates first: lint, typecheck, test. -- Builds four artifacts in parallel: +- Builds four artifacts in parallel for both channels: - macOS `arm64` DMG - macOS `x64` DMG - Linux `x64` AppImage - Windows `x64` NSIS installer - Publishes one GitHub Release with all produced files. - - Versions with a suffix after `X.Y.Z` (for example `1.2.3-alpha.1`) are published as GitHub prereleases. - - Only plain `X.Y.Z` releases are marked as the repository's latest release. -- Includes Electron auto-update metadata (for example `latest*.yml` and `*.blockmap`) in release assets. -- Publishes the CLI package (`apps/server`, npm package `marcode`) with OIDC trusted publishing. + - Stable tags with a suffix after `X.Y.Z` (for example `1.2.3-alpha.1`) are published as GitHub prereleases. + - Only plain stable `X.Y.Z` releases are marked as the repository's latest release. + - Nightly runs are always GitHub prereleases and never marked latest. + - Automatically generated release notes are pinned to the previous tag in the same channel, so stable compares to the previous stable tag and nightly compares to the previous nightly tag. +- Includes Electron auto-update metadata (for example `latest*.yml`, `nightly*.yml`, and `*.blockmap`) in release assets. +- Publishes the CLI package (`apps/server`, npm package `marcode`) with OIDC trusted publishing from the same workflow file: + - stable releases publish npm dist-tag `latest` + - nightly releases publish npm dist-tag `nightly` - Signing is optional and auto-detected per platform from secrets. +## Nightly builds + +- Workflow: `.github/workflows/release.yml` +- Triggers: + - scheduled every day at `09:00 UTC` + - manual `workflow_dispatch` with `channel=nightly` +- Runs the same desktop quality gates and artifact matrix as the tagged release flow. +- Publishes a GitHub prerelease only: + - tag format: `nightly-vX.Y.Z-nightly.YYYYMMDD.` + - release name includes the short commit SHA + - `make_latest` is always `false` +- Uses the next stable patch version as the nightly base. For example, `0.0.17` produces nightlies on `0.0.18-nightly.*`. +- Publishes Electron auto-update metadata to the dedicated `nightly` updater channel, so desktop users can opt into that track independently from stable. +- Publishes the CLI package (`apps/server`, npm package `t3`) to the `nightly` npm dist-tag using the same nightly version. +- Does not commit version bumps back to `main`. + ## Desktop auto-update notes - Runtime updater: `electron-updater` in `apps/desktop/src/main.ts`. @@ -34,15 +58,15 @@ This document covers how to run desktop releases from one tag, first without sig - the app forwards it as an `Authorization: Bearer ` request header for updater HTTP calls. - Required release assets for updater: - platform installers (`.exe`, `.dmg`, `.AppImage`, plus macOS `.zip` for Squirrel.Mac update payloads) - - `latest*.yml` metadata + - channel metadata: `latest*.yml` for stable releases, `nightly*.yml` for nightly releases - `*.blockmap` files (used for differential downloads) - macOS metadata note: - - `electron-updater` reads `latest-mac.yml` for both Intel and Apple Silicon. - - The workflow merges the per-arch mac manifests into one `latest-mac.yml` before publishing the GitHub Release. + - `electron-updater` reads `latest-mac.yml` on stable and `nightly-mac.yml` on nightly, for both Intel and Apple Silicon. + - The workflow merges the per-arch mac manifests into one channel-specific mac manifest before publishing the GitHub Release. ## 0) npm OIDC trusted publishing setup (CLI) -The workflow publishes the CLI with `bun publish` from `apps/server` after bumping +The workflow publishes the CLI with `npm publish` from `apps/server` after bumping the package version to the release tag version. Checklist: @@ -57,7 +81,8 @@ Checklist: 4. Create release tag `vX.Y.Z` and push; workflow will: - set `apps/server/package.json` version to `X.Y.Z` - build web + server - - run `bun publish --access public` + - run `npm publish --access public --tag latest` +5. Nightly runs from the same workflow file publish with `npm publish --access public --tag nightly`. ## 1) Dry-run release without signing diff --git a/package.json b/package.json index 10b33d5735d..9639e12e30e 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "@effect/sql-sqlite-bun": "4.0.0-beta.45", "@effect/vitest": "4.0.0-beta.45", "@effect/language-service": "0.84.2", - "@types/bun": "^1.3.9", + "@types/bun": "^1.3.11", "@types/node": "^24.10.13", "tsdown": "^0.20.3", "typescript": "^5.7.3", @@ -47,7 +47,9 @@ "dist:desktop:dmg:arm64": "node scripts/build-desktop-artifact.ts --platform mac --target dmg --arch arm64", "dist:desktop:dmg:x64": "node scripts/build-desktop-artifact.ts --platform mac --target dmg --arch x64", "dist:desktop:linux": "node scripts/build-desktop-artifact.ts --platform linux --target AppImage --arch x64", - "dist:desktop:win": "node scripts/build-desktop-artifact.ts --platform win --target nsis --arch x64", + "dist:desktop:win": "node scripts/build-desktop-artifact.ts --platform win --target nsis", + "dist:desktop:win:arm64": "node scripts/build-desktop-artifact.ts --platform win --target nsis --arch arm64", + "dist:desktop:win:x64": "node scripts/build-desktop-artifact.ts --platform win --target nsis --arch x64", "release:smoke": "node scripts/release-smoke.ts", "clean": "rm -rf node_modules apps/*/node_modules packages/*/node_modules apps/*/dist apps/*/dist-electron packages/*/dist .turbo apps/*/.turbo packages/*/.turbo", "sync:vscode-icons": "node scripts/sync-vscode-icons.mjs" @@ -70,10 +72,10 @@ "vite": "^8.0.0" }, "engines": { - "bun": "^1.3.9", + "bun": "^1.3.11", "node": "^24.13.1" }, - "packageManager": "bun@1.3.9", + "packageManager": "bun@1.3.11", "msw": { "workerDirectory": [ "apps/web/public" diff --git a/packages/client-runtime/src/index.ts b/packages/client-runtime/src/index.ts index 5dd6b9afa57..9ca76328a8e 100644 --- a/packages/client-runtime/src/index.ts +++ b/packages/client-runtime/src/index.ts @@ -1,2 +1,2 @@ -export * from "./knownEnvironment"; -export * from "./scoped"; +export * from "./knownEnvironment.ts"; +export * from "./scoped.ts"; diff --git a/packages/client-runtime/src/knownEnvironment.test.ts b/packages/client-runtime/src/knownEnvironment.test.ts index 694ff196be2..611628c01d7 100644 --- a/packages/client-runtime/src/knownEnvironment.test.ts +++ b/packages/client-runtime/src/knownEnvironment.test.ts @@ -1,7 +1,7 @@ import { EnvironmentId, ProjectId, ThreadId } from "@marcode/contracts"; import { describe, expect, it } from "vitest"; -import { createKnownEnvironment, getKnownEnvironmentHttpBaseUrl } from "./knownEnvironment"; +import { createKnownEnvironment, getKnownEnvironmentHttpBaseUrl } from "./knownEnvironment.ts"; import { parseScopedProjectKey, parseScopedThreadKey, @@ -10,7 +10,7 @@ import { scopedThreadKey, scopeProjectRef, scopeThreadRef, -} from "./scoped"; +} from "./scoped.ts"; describe("known environment bootstrap helpers", () => { it("creates known environments from explicit server base urls", () => { diff --git a/packages/contracts/src/auth.ts b/packages/contracts/src/auth.ts index 73327a45af1..8110104e198 100644 --- a/packages/contracts/src/auth.ts +++ b/packages/contracts/src/auth.ts @@ -1,6 +1,6 @@ import { Schema } from "effect"; -import { AuthSessionId, TrimmedNonEmptyString } from "./baseSchemas"; +import { AuthSessionId, TrimmedNonEmptyString } from "./baseSchemas.ts"; /** * Declares the server's overall authentication posture. diff --git a/packages/contracts/src/editor.ts b/packages/contracts/src/editor.ts index 2ffdf4c6dbe..8444e8b1068 100644 --- a/packages/contracts/src/editor.ts +++ b/packages/contracts/src/editor.ts @@ -1,5 +1,5 @@ import { Schema } from "effect"; -import { TrimmedNonEmptyString } from "./baseSchemas"; +import { TrimmedNonEmptyString } from "./baseSchemas.ts"; export const EditorLaunchStyle = Schema.Literals(["direct-path", "goto", "line-column"]); export type EditorLaunchStyle = typeof EditorLaunchStyle.Type; @@ -8,12 +8,14 @@ type EditorDefinition = { readonly id: string; readonly label: string; readonly commands: readonly [string, ...string[]] | null; + readonly baseArgs?: readonly string[]; readonly launchStyle: EditorLaunchStyle; }; export const EDITORS = [ { id: "cursor", label: "Cursor", commands: ["cursor"], launchStyle: "goto" }, { id: "trae", label: "Trae", commands: ["trae"], launchStyle: "goto" }, + { id: "kiro", label: "Kiro", commands: ["kiro"], baseArgs: ["ide"], launchStyle: "goto" }, { id: "vscode", label: "VS Code", commands: ["code"], launchStyle: "goto" }, { id: "vscode-insiders", diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index bc3b5459cd9..aa34c339a39 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -1,6 +1,6 @@ import { Effect, Schema } from "effect"; -import { EnvironmentId, ProjectId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas"; +import { EnvironmentId, ProjectId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; export const ExecutionEnvironmentPlatformOs = Schema.Literals([ "darwin", @@ -51,6 +51,7 @@ export type RepositoryIdentityLocator = typeof RepositoryIdentityLocator.Type; export const RepositoryIdentity = Schema.Struct({ canonicalKey: TrimmedNonEmptyString, locator: RepositoryIdentityLocator, + rootPath: Schema.optionalKey(TrimmedNonEmptyString), displayName: Schema.optionalKey(TrimmedNonEmptyString), provider: Schema.optionalKey(TrimmedNonEmptyString), owner: Schema.optionalKey(TrimmedNonEmptyString), diff --git a/packages/contracts/src/filesystem.ts b/packages/contracts/src/filesystem.ts new file mode 100644 index 00000000000..a518e2e9acd --- /dev/null +++ b/packages/contracts/src/filesystem.ts @@ -0,0 +1,30 @@ +import { Schema } from "effect"; +import { TrimmedNonEmptyString } from "./baseSchemas.ts"; + +const FILESYSTEM_PATH_MAX_LENGTH = 512; + +export const FilesystemBrowseInput = Schema.Struct({ + partialPath: TrimmedNonEmptyString.check(Schema.isMaxLength(FILESYSTEM_PATH_MAX_LENGTH)), + cwd: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(FILESYSTEM_PATH_MAX_LENGTH))), +}); +export type FilesystemBrowseInput = typeof FilesystemBrowseInput.Type; + +export const FilesystemBrowseEntry = Schema.Struct({ + name: TrimmedNonEmptyString, + fullPath: TrimmedNonEmptyString, +}); +export type FilesystemBrowseEntry = typeof FilesystemBrowseEntry.Type; + +export const FilesystemBrowseResult = Schema.Struct({ + parentPath: TrimmedNonEmptyString, + entries: Schema.Array(FilesystemBrowseEntry), +}); +export type FilesystemBrowseResult = typeof FilesystemBrowseResult.Type; + +export class FilesystemBrowseError extends Schema.TaggedErrorClass()( + "FilesystemBrowseError", + { + message: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect), + }, +) {} diff --git a/packages/contracts/src/git.test.ts b/packages/contracts/src/git.test.ts index 9e5d4510a36..dee9e715f9d 100644 --- a/packages/contracts/src/git.test.ts +++ b/packages/contracts/src/git.test.ts @@ -7,7 +7,7 @@ import { GitRunStackedActionResult, GitRunStackedActionInput, GitResolvePullRequestResult, -} from "./git"; +} from "./git.ts"; const decodeCreateWorktreeInput = Schema.decodeUnknownSync(GitCreateWorktreeInput); const decodePreparePullRequestThreadInput = Schema.decodeUnknownSync( diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 24e2e80de0c..e9f2654c10b 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -1,5 +1,5 @@ import { Schema } from "effect"; -import { NonNegativeInt, PositiveInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas"; +import { NonNegativeInt, PositiveInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; const GIT_LIST_BRANCHES_MAX_LIMIT = 200; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 865707c7594..cbaf80c905f 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -1,17 +1,18 @@ -export * from "./baseSchemas"; -export * from "./auth"; -export * from "./environment"; -export * from "./ipc"; -export * from "./terminal"; -export * from "./provider"; -export * from "./providerRuntime"; -export * from "./model"; -export * from "./keybindings"; -export * from "./server"; -export * from "./settings"; -export * from "./git"; -export * from "./orchestration"; -export * from "./editor"; -export * from "./project"; -export * from "./jira"; -export * from "./rpc"; +export * from "./baseSchemas.ts"; +export * from "./auth.ts"; +export * from "./environment.ts"; +export * from "./ipc.ts"; +export * from "./terminal.ts"; +export * from "./provider.ts"; +export * from "./providerRuntime.ts"; +export * from "./model.ts"; +export * from "./keybindings.ts"; +export * from "./server.ts"; +export * from "./settings.ts"; +export * from "./git.ts"; +export * from "./orchestration.ts"; +export * from "./editor.ts"; +export * from "./project.ts"; +export * from "./jira.ts"; +export * from "./filesystem.ts"; +export * from "./rpc.ts"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 3b3d030d9e6..f0c79b014ef 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -19,7 +19,8 @@ import type { GitCreateBranchResult, GitWorkingTreeDiffInput, GitWorkingTreeDiffResult, -} from "./git"; +} from "./git.ts"; +import type { FilesystemBrowseInput, FilesystemBrowseResult } from "./filesystem.ts"; import type { ProjectBrowseDirectoriesInput, ProjectBrowseDirectoriesResult, @@ -27,12 +28,12 @@ import type { ProjectSearchEntriesResult, ProjectWriteFileInput, ProjectWriteFileResult, -} from "./project"; +} from "./project.ts"; import type { ServerConfig, ServerProviderUpdatedPayload, ServerUpsertKeybindingResult, -} from "./server"; +} from "./server.ts"; import type { TerminalClearInput, TerminalCloseInput, @@ -42,8 +43,8 @@ import type { TerminalRestartInput, TerminalSessionSnapshot, TerminalWriteInput, -} from "./terminal"; -import type { ServerUpsertKeybindingInput } from "./server"; +} from "./terminal.ts"; +import type { ServerUpsertKeybindingInput } from "./server.ts"; import type { ClientOrchestrationCommand, OrchestrationGetFullThreadDiffInput, @@ -55,7 +56,10 @@ import type { OrchestrationListingSnapshot, OrchestrationReadModel, OrchestrationThread, -} from "./orchestration"; + OrchestrationShellStreamItem, + OrchestrationSubscribeThreadInput, + OrchestrationThreadStreamItem, +} from "./orchestration.ts"; import type { JiraConnectionStatus, JiraGetAttachmentInput, @@ -69,16 +73,17 @@ import type { JiraListSprintsInput, JiraListSprintsResult, JiraSite, -} from "./jira"; -import type { EnvironmentId } from "./baseSchemas"; -import { EditorId } from "./editor"; -import { ClientSettings, ServerSettings, ServerSettingsPatch } from "./settings"; +} from "./jira.ts"; +import type { EnvironmentId } from "./baseSchemas.ts"; +import { EditorId } from "./editor.ts"; +import { ServerSettings, type ClientSettings, type ServerSettingsPatch } from "./settings.ts"; export interface ContextMenuItem { id: T; label: string; destructive?: boolean; disabled?: boolean; + children?: readonly ContextMenuItem[]; } export type DesktopUpdateStatus = @@ -93,6 +98,14 @@ export type DesktopUpdateStatus = export type DesktopRuntimeArch = "arm64" | "x64" | "other"; export type DesktopTheme = "light" | "dark" | "system"; +export type DesktopUpdateChannel = "latest" | "nightly"; +export type DesktopAppStageLabel = "Alpha" | "Dev" | "Nightly"; + +export interface DesktopAppBranding { + baseName: string; + stageLabel: DesktopAppStageLabel; + displayName: string; +} export interface DesktopRuntimeInfo { hostArch: DesktopRuntimeArch; @@ -103,6 +116,7 @@ export interface DesktopRuntimeInfo { export interface DesktopUpdateState { enabled: boolean; status: DesktopUpdateStatus; + channel: DesktopUpdateChannel; currentVersion: string; hostArch: DesktopRuntimeArch; appArch: DesktopRuntimeArch; @@ -151,7 +165,12 @@ export interface DesktopServerExposureState { advertisedHost: string | null; } +export interface PickFolderOptions { + initialPath?: string | null; +} + export interface DesktopBridge { + getAppBranding: () => DesktopAppBranding | null; getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null; getClientSettings: () => Promise; setClientSettings: (settings: ClientSettings) => Promise; @@ -164,7 +183,7 @@ export interface DesktopBridge { removeSavedEnvironmentSecret: (environmentId: EnvironmentId) => Promise; getServerExposureState: () => Promise; setServerExposureMode: (mode: DesktopServerExposureMode) => Promise; - pickFolder: () => Promise; + pickFolder: (options?: PickFolderOptions) => Promise; confirm: (message: string) => Promise; setTheme: (theme: DesktopTheme) => Promise; showContextMenu: ( @@ -174,6 +193,7 @@ export interface DesktopBridge { openExternal: (url: string) => Promise; onMenuAction: (listener: (action: string) => void) => () => void; getUpdateState: () => Promise; + setUpdateChannel: (channel: DesktopUpdateChannel) => Promise; checkForUpdate: () => Promise; downloadUpdate: () => Promise; installUpdate: () => Promise; @@ -193,7 +213,7 @@ export interface DesktopBridge { */ export interface LocalApi { dialogs: { - pickFolder: () => Promise; + pickFolder: (options?: PickFolderOptions) => Promise; confirm: (message: string) => Promise; }; shell: { @@ -252,6 +272,9 @@ export interface EnvironmentApi { ) => Promise; writeFile: (input: ProjectWriteFileInput) => Promise; }; + filesystem: { + browse: (input: FilesystemBrowseInput) => Promise; + }; git: { listBranches: (input: GitListBranchesInput) => Promise; createWorktree: (input: GitCreateWorktreeInput) => Promise; @@ -283,9 +306,15 @@ export interface EnvironmentApi { getFullThreadDiff: ( input: OrchestrationGetFullThreadDiffInput, ) => Promise; - replayEvents: (fromSequenceExclusive: number) => Promise; - onDomainEvent: ( - callback: (event: OrchestrationEvent) => void, + subscribeShell: ( + callback: (event: OrchestrationShellStreamItem) => void, + options?: { + onResubscribe?: () => void; + }, + ) => () => void; + subscribeThread: ( + input: OrchestrationSubscribeThreadInput, + callback: (event: OrchestrationThreadStreamItem) => void, options?: { onResubscribe?: () => void; }, diff --git a/packages/contracts/src/jira.ts b/packages/contracts/src/jira.ts index cb44574c488..191fd4c2f7e 100644 --- a/packages/contracts/src/jira.ts +++ b/packages/contracts/src/jira.ts @@ -1,5 +1,5 @@ import { Schema } from "effect"; -import { IsoDateTime, NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas"; +import { IsoDateTime, NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; export const JiraCloudId = TrimmedNonEmptyString.pipe(Schema.brand("JiraCloudId")); export type JiraCloudId = typeof JiraCloudId.Type; diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index 092d5344f2b..79c2feb8baa 100644 --- a/packages/contracts/src/keybindings.test.ts +++ b/packages/contracts/src/keybindings.test.ts @@ -7,7 +7,7 @@ import { KeybindingRule, ResolvedKeybindingRule, ResolvedKeybindingsConfig, -} from "./keybindings"; +} from "./keybindings.ts"; const decode = ( schema: S, diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 72067eac8a8..1296e74c1b9 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -1,5 +1,5 @@ import { Schema } from "effect"; -import { TrimmedString } from "./baseSchemas"; +import { TrimmedString } from "./baseSchemas.ts"; export const MAX_KEYBINDING_VALUE_LENGTH = 64; const MAX_KEYBINDING_WHEN_LENGTH = 256; diff --git a/packages/contracts/src/model.test.ts b/packages/contracts/src/model.test.ts index 195e1b6befe..6e3e34e83f1 100644 --- a/packages/contracts/src/model.test.ts +++ b/packages/contracts/src/model.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { DEFAULT_MODEL, DEFAULT_MODEL_BY_PROVIDER } from "./model"; +import { DEFAULT_MODEL, DEFAULT_MODEL_BY_PROVIDER } from "./model.ts"; describe("model constants", () => { it("DEFAULT_MODEL equals claude-opus-4-6", () => { diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 671ab361dc1..dd3de1905b9 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -1,6 +1,6 @@ import { Schema } from "effect"; -import { TrimmedNonEmptyString } from "./baseSchemas"; -import type { ProviderKind } from "./orchestration"; +import { TrimmedNonEmptyString } from "./baseSchemas.ts"; +import type { ProviderKind } from "./orchestration.ts"; export const CodexReasoningEffort = Schema.Literals(["xhigh", "high", "medium", "low"]); export type CodexReasoningEffort = typeof CodexReasoningEffort.Type; diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 9c6a31d2d03..b7f1a7c844f 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -19,7 +19,7 @@ import { ThreadCreatedPayload, ThreadTurnDiff, ThreadTurnStartRequestedPayload, -} from "./orchestration"; +} from "./orchestration.ts"; const decodeTurnDiffInput = Schema.decodeUnknownEffect(OrchestrationGetTurnDiffInput); const decodeThreadTurnDiff = Schema.decodeUnknownEffect(ThreadTurnDiff); @@ -95,6 +95,7 @@ it.effect("trims branded ids and command string fields at decode boundaries", () assert.strictEqual(parsed.projectId, "project-1"); assert.strictEqual(parsed.title, "Project Title"); assert.strictEqual(parsed.workspaceRoot, "/tmp/workspace"); + assert.strictEqual(parsed.createWorkspaceRootIfMissing, undefined); assert.deepStrictEqual(parsed.defaultModelSelection, { provider: "codex", model: "gpt-5.2", @@ -102,6 +103,22 @@ it.effect("trims branded ids and command string fields at decode boundaries", () }), ); +it.effect("decodes project.create with createWorkspaceRootIfMissing enabled", () => + Effect.gen(function* () { + const parsed = yield* decodeProjectCreateCommand({ + type: "project.create", + commandId: "cmd-1", + projectId: "project-1", + title: "Project Title", + workspaceRoot: "/tmp/workspace", + createWorkspaceRootIfMissing: true, + createdAt: "2026-01-01T00:00:00.000Z", + }); + + assert.strictEqual(parsed.createWorkspaceRootIfMissing, true); + }), +); + it.effect("decodes historical project.created payloads with a default provider", () => Effect.gen(function* () { const parsed = yield* decodeProjectCreatedPayload({ diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index cb745468de0..e92cb01a444 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -1,7 +1,7 @@ import { Effect, Option, Schema, SchemaIssue, Struct } from "effect"; -import { ClaudeModelOptions, CodexModelOptions } from "./model"; -import { JiraBoardReference } from "./jira"; -import { RepositoryIdentity } from "./environment"; +import { ClaudeModelOptions, CodexModelOptions } from "./model.ts"; +import { JiraBoardReference } from "./jira.ts"; +import { RepositoryIdentity } from "./environment.ts"; import { ApprovalRequestId, CheckpointRef, @@ -15,7 +15,7 @@ import { ThreadId, TrimmedNonEmptyString, TurnId, -} from "./baseSchemas"; +} from "./baseSchemas.ts"; export const ORCHESTRATION_WS_METHODS = { getSnapshot: "orchestration.getSnapshot", @@ -25,6 +25,8 @@ export const ORCHESTRATION_WS_METHODS = { getTurnDiff: "orchestration.getTurnDiff", getFullThreadDiff: "orchestration.getFullThreadDiff", replayEvents: "orchestration.replayEvents", + subscribeShell: "orchestration.subscribeShell", + subscribeThread: "orchestration.subscribeThread", } as const; export const ORCHESTRATION_WS_CHANNELS = { @@ -320,7 +322,19 @@ export const OrchestrationReadModel = Schema.Struct({ }); export type OrchestrationReadModel = typeof OrchestrationReadModel.Type; -export const OrchestrationThreadSummary = Schema.Struct({ +export const OrchestrationProjectShell = Schema.Struct({ + id: ProjectId, + title: TrimmedNonEmptyString, + workspaceRoot: TrimmedNonEmptyString, + repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), + defaultModelSelection: Schema.NullOr(ModelSelection), + scripts: Schema.Array(ProjectScript), + createdAt: IsoDateTime, + updatedAt: IsoDateTime, +}); +export type OrchestrationProjectShell = typeof OrchestrationProjectShell.Type; + +export const OrchestrationThreadShell = Schema.Struct({ id: ThreadId, projectId: ProjectId, title: TrimmedNonEmptyString, @@ -345,6 +359,61 @@ export const OrchestrationThreadSummary = Schema.Struct({ hasPendingUserInput: Schema.Boolean, hasActionableProposedPlan: Schema.Boolean, }); +export type OrchestrationThreadShell = typeof OrchestrationThreadShell.Type; + +export const OrchestrationShellSnapshot = Schema.Struct({ + snapshotSequence: NonNegativeInt, + projects: Schema.Array(OrchestrationProjectShell), + threads: Schema.Array(OrchestrationThreadShell), + updatedAt: IsoDateTime, +}); +export type OrchestrationShellSnapshot = typeof OrchestrationShellSnapshot.Type; + +export const OrchestrationShellStreamEvent = Schema.Union([ + Schema.Struct({ + kind: Schema.Literal("project-upserted"), + sequence: NonNegativeInt, + project: OrchestrationProjectShell, + }), + Schema.Struct({ + kind: Schema.Literal("project-removed"), + sequence: NonNegativeInt, + projectId: ProjectId, + }), + Schema.Struct({ + kind: Schema.Literal("thread-upserted"), + sequence: NonNegativeInt, + thread: OrchestrationThreadShell, + }), + Schema.Struct({ + kind: Schema.Literal("thread-removed"), + sequence: NonNegativeInt, + threadId: ThreadId, + }), +]); +export type OrchestrationShellStreamEvent = typeof OrchestrationShellStreamEvent.Type; + +export const OrchestrationShellStreamItem = Schema.Union([ + Schema.Struct({ + kind: Schema.Literal("snapshot"), + snapshot: OrchestrationShellSnapshot, + }), + OrchestrationShellStreamEvent, +]); +export type OrchestrationShellStreamItem = typeof OrchestrationShellStreamItem.Type; + +export const OrchestrationSubscribeThreadInput = Schema.Struct({ + threadId: ThreadId, +}); +export type OrchestrationSubscribeThreadInput = typeof OrchestrationSubscribeThreadInput.Type; + +export const OrchestrationThreadDetailSnapshot = Schema.Struct({ + snapshotSequence: NonNegativeInt, + thread: OrchestrationThread, +}); +export type OrchestrationThreadDetailSnapshot = typeof OrchestrationThreadDetailSnapshot.Type; + +export const OrchestrationThreadSummary = OrchestrationThreadShell; export type OrchestrationThreadSummary = typeof OrchestrationThreadSummary.Type; export const OrchestrationListingSnapshot = Schema.Struct({ @@ -361,6 +430,7 @@ export const ProjectCreateCommand = Schema.Struct({ projectId: ProjectId, title: TrimmedNonEmptyString, workspaceRoot: TrimmedNonEmptyString, + createWorkspaceRootIfMissing: Schema.optional(Schema.Boolean), defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), jiraBoard: Schema.optional(Schema.NullOr(JiraBoardReference)), createdAt: IsoDateTime, @@ -1010,6 +1080,18 @@ export const OrchestrationEvent = Schema.Union([ ]); export type OrchestrationEvent = typeof OrchestrationEvent.Type; +export const OrchestrationThreadStreamItem = Schema.Union([ + Schema.Struct({ + kind: Schema.Literal("snapshot"), + snapshot: OrchestrationThreadDetailSnapshot, + }), + Schema.Struct({ + kind: Schema.Literal("event"), + event: OrchestrationEvent, + }), +]); +export type OrchestrationThreadStreamItem = typeof OrchestrationThreadStreamItem.Type; + export const OrchestrationCommandReceiptStatus = Schema.Literals(["accepted", "rejected"]); export type OrchestrationCommandReceiptStatus = typeof OrchestrationCommandReceiptStatus.Type; @@ -1144,6 +1226,14 @@ export const OrchestrationRpcSchemas = { input: OrchestrationReplayEventsInput, output: OrchestrationReplayEventsResult, }, + subscribeThread: { + input: OrchestrationSubscribeThreadInput, + output: OrchestrationThreadStreamItem, + }, + subscribeShell: { + input: Schema.Struct({}), + output: OrchestrationShellStreamItem, + }, } as const; export class OrchestrationGetSnapshotError extends Schema.TaggedErrorClass()( diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index ee0f811f1a4..05c5ddc0232 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -1,5 +1,5 @@ import { Schema } from "effect"; -import { PositiveInt, TrimmedNonEmptyString } from "./baseSchemas"; +import { PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; const PROJECT_SEARCH_ENTRIES_MAX_LIMIT = 200; const PROJECT_BROWSE_DIRECTORIES_MAX_LIMIT = 200; diff --git a/packages/contracts/src/provider.test.ts b/packages/contracts/src/provider.test.ts index 37469984de4..bd20b7e9b34 100644 --- a/packages/contracts/src/provider.test.ts +++ b/packages/contracts/src/provider.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { Schema } from "effect"; -import { ProviderSendTurnInput, ProviderSessionStartInput } from "./provider"; +import { ProviderSendTurnInput, ProviderSessionStartInput } from "./provider.ts"; const decodeProviderSessionStartInput = Schema.decodeUnknownSync(ProviderSessionStartInput); const decodeProviderSendTurnInput = Schema.decodeUnknownSync(ProviderSendTurnInput); diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index 15fa0ee2f8d..dcbc18eec4a 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -1,5 +1,5 @@ import { Schema } from "effect"; -import { TrimmedNonEmptyString } from "./baseSchemas"; +import { TrimmedNonEmptyString } from "./baseSchemas.ts"; import { ApprovalRequestId, EventId, @@ -7,7 +7,7 @@ import { ProviderItemId, ThreadId, TurnId, -} from "./baseSchemas"; +} from "./baseSchemas.ts"; import { ChatAttachment, ModelSelection, @@ -21,7 +21,7 @@ import { ProviderSandboxMode, ProviderUserInputAnswers, RuntimeMode, -} from "./orchestration"; +} from "./orchestration.ts"; const ProviderSessionStatus = Schema.Literals([ "connecting", diff --git a/packages/contracts/src/providerRuntime.test.ts b/packages/contracts/src/providerRuntime.test.ts index 9d9c395c3d5..7b822a2860b 100644 --- a/packages/contracts/src/providerRuntime.test.ts +++ b/packages/contracts/src/providerRuntime.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { Schema } from "effect"; -import { ProviderRuntimeEvent } from "./providerRuntime"; +import { ProviderRuntimeEvent } from "./providerRuntime.ts"; const decodeRuntimeEvent = Schema.decodeUnknownSync(ProviderRuntimeEvent); diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index 16e9d506575..9a898b99287 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -11,8 +11,8 @@ import { ThreadId, TrimmedNonEmptyString, TurnId, -} from "./baseSchemas"; -import { ProviderKind } from "./orchestration"; +} from "./baseSchemas.ts"; +import { ProviderKind } from "./orchestration.ts"; const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; const UnknownRecordSchema = Schema.Record(Schema.String, Schema.Unknown); diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 3e1f741511f..830b34d7670 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -2,10 +2,15 @@ import { Schema } from "effect"; import * as Rpc from "effect/unstable/rpc/Rpc"; import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; -import { RuntimeItemId, ThreadId } from "./baseSchemas"; +import { RuntimeItemId, ThreadId } from "./baseSchemas.ts"; -import { OpenError, OpenInEditorInput } from "./editor"; -import { AuthAccessStreamEvent } from "./auth"; +import { OpenError, OpenInEditorInput } from "./editor.ts"; +import { AuthAccessStreamEvent } from "./auth.ts"; +import { + FilesystemBrowseInput, + FilesystemBrowseResult, + FilesystemBrowseError, +} from "./filesystem.ts"; import { GitActionProgressEvent, GitCheckoutInput, @@ -32,17 +37,15 @@ import { GitStatusStreamEvent, GitWorkingTreeDiffInput, GitWorkingTreeDiffResult, -} from "./git"; -import { KeybindingsConfigError } from "./keybindings"; +} from "./git.ts"; +import { KeybindingsConfigError } from "./keybindings.ts"; import { ClientOrchestrationCommand, - OrchestrationEvent, ORCHESTRATION_WS_METHODS, OrchestrationDispatchCommandError, OrchestrationGetFullThreadDiffError, OrchestrationGetFullThreadDiffInput, OrchestrationGetSnapshotError, - OrchestrationGetSnapshotInput, OrchestrationGetTurnDiffError, OrchestrationGetTurnDiffInput, OrchestrationReplayEventsError, @@ -50,9 +53,10 @@ import { OrchestrationRpcSchemas, OrchestrationGetListingSnapshotError, OrchestrationGetListingSnapshotInput, + OrchestrationGetSnapshotInput, OrchestrationGetThreadError, OrchestrationGetThreadInput, -} from "./orchestration"; +} from "./orchestration.ts"; import { ProjectBrowseDirectoriesError, ProjectBrowseDirectoriesInput, @@ -63,7 +67,7 @@ import { ProjectWriteFileError, ProjectWriteFileInput, ProjectWriteFileResult, -} from "./project"; +} from "./project.ts"; import { TerminalClearInput, TerminalCloseInput, @@ -74,7 +78,7 @@ import { TerminalRestartInput, TerminalSessionSnapshot, TerminalWriteInput, -} from "./terminal"; +} from "./terminal.ts"; import { ServerConfigStreamEvent, ServerConfig, @@ -82,8 +86,8 @@ import { ServerProviderUpdatedPayload, ServerUpsertKeybindingInput, ServerUpsertKeybindingResult, -} from "./server"; -import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings"; +} from "./server.ts"; +import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings.ts"; import { JIRA_WS_CHANNELS, JIRA_WS_METHODS, @@ -100,7 +104,7 @@ import { JiraListSprintsResult, JiraRpcError, JiraSite, -} from "./jira"; +} from "./jira.ts"; export const WS_METHODS = { // Project registry methods @@ -114,6 +118,9 @@ export const WS_METHODS = { // Shell methods shellOpenInEditor: "shell.openInEditor", + // Filesystem methods + filesystemBrowse: "filesystem.browse", + // Git methods gitPull: "git.pull", gitRefreshStatus: "git.refreshStatus", @@ -155,7 +162,6 @@ export const WS_METHODS = { // Streaming subscriptions subscribeGitStatus: "subscribeGitStatus", - subscribeOrchestrationDomainEvents: "subscribeOrchestrationDomainEvents", subscribeTerminalEvents: "subscribeTerminalEvents", subscribeServerConfig: "subscribeServerConfig", subscribeServerLifecycle: "subscribeServerLifecycle", @@ -216,6 +222,12 @@ export const WsShellOpenInEditorRpc = Rpc.make(WS_METHODS.shellOpenInEditor, { error: OpenError, }); +export const WsFilesystemBrowseRpc = Rpc.make(WS_METHODS.filesystemBrowse, { + payload: FilesystemBrowseInput, + success: FilesystemBrowseResult, + error: FilesystemBrowseError, +}); + export const WsSubscribeGitStatusRpc = Rpc.make(WS_METHODS.subscribeGitStatus, { payload: GitStatusInput, success: GitStatusStreamEvent, @@ -377,11 +389,19 @@ export const WsOrchestrationReplayEventsRpc = Rpc.make(ORCHESTRATION_WS_METHODS. error: OrchestrationReplayEventsError, }); -export const WsSubscribeOrchestrationDomainEventsRpc = Rpc.make( - WS_METHODS.subscribeOrchestrationDomainEvents, +export const WsOrchestrationSubscribeShellRpc = Rpc.make(ORCHESTRATION_WS_METHODS.subscribeShell, { + payload: OrchestrationRpcSchemas.subscribeShell.input, + success: OrchestrationRpcSchemas.subscribeShell.output, + error: OrchestrationGetSnapshotError, + stream: true, +}); + +export const WsOrchestrationSubscribeThreadRpc = Rpc.make( + ORCHESTRATION_WS_METHODS.subscribeThread, { - payload: Schema.Struct({}), - success: OrchestrationEvent, + payload: OrchestrationRpcSchemas.subscribeThread.input, + success: OrchestrationRpcSchemas.subscribeThread.output, + error: OrchestrationGetSnapshotError, stream: true, }, ); @@ -491,6 +511,7 @@ export const WsRpcGroup = RpcGroup.make( WsProjectsBrowseDirectoriesRpc, WsProjectsWriteFileRpc, WsShellOpenInEditorRpc, + WsFilesystemBrowseRpc, WsSubscribeGitStatusRpc, WsGitPullRpc, WsGitRefreshStatusRpc, @@ -510,7 +531,6 @@ export const WsRpcGroup = RpcGroup.make( WsTerminalClearRpc, WsTerminalRestartRpc, WsTerminalCloseRpc, - WsSubscribeOrchestrationDomainEventsRpc, WsSubscribeTerminalEventsRpc, WsSubscribeServerConfigRpc, WsSubscribeServerLifecycleRpc, @@ -522,6 +542,8 @@ export const WsRpcGroup = RpcGroup.make( WsOrchestrationGetTurnDiffRpc, WsOrchestrationGetFullThreadDiffRpc, WsOrchestrationReplayEventsRpc, + WsOrchestrationSubscribeShellRpc, + WsOrchestrationSubscribeThreadRpc, WsJiraGetConnectionStatusRpc, WsJiraDisconnectRpc, WsJiraListSitesRpc, diff --git a/packages/contracts/src/server.test.ts b/packages/contracts/src/server.test.ts index 6e5f70c2e4d..2603d51d6a9 100644 --- a/packages/contracts/src/server.test.ts +++ b/packages/contracts/src/server.test.ts @@ -1,7 +1,7 @@ import { Schema } from "effect"; import { describe, expect, it } from "vitest"; -import { ServerProvider } from "./server"; +import { ServerProvider } from "./server.ts"; const decodeServerProvider = Schema.decodeUnknownSync(ServerProvider); diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 50db737c6ae..c08dfa6cd1c 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -1,18 +1,18 @@ import { Effect, Schema } from "effect"; -import { ExecutionEnvironmentDescriptor } from "./environment"; -import { ServerAuthDescriptor } from "./auth"; +import { ExecutionEnvironmentDescriptor } from "./environment.ts"; +import { ServerAuthDescriptor } from "./auth.ts"; import { IsoDateTime, NonNegativeInt, ProjectId, ThreadId, TrimmedNonEmptyString, -} from "./baseSchemas"; -import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings"; -import { EditorId } from "./editor"; -import { ModelCapabilities } from "./model"; -import { ProviderKind } from "./orchestration"; -import { ServerSettings } from "./settings"; +} from "./baseSchemas.ts"; +import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings.ts"; +import { EditorId } from "./editor.ts"; +import { ModelCapabilities } from "./model.ts"; +import { ProviderKind } from "./orchestration.ts"; +import { ServerSettings } from "./settings.ts"; const KeybindingsMalformedConfigIssue = Schema.Struct({ kind: Schema.Literal("keybindings.malformed-config"), diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 41465c10d72..24fbf4f0fed 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1,13 +1,13 @@ import { Effect } from "effect"; import * as Schema from "effect/Schema"; import * as SchemaTransformation from "effect/SchemaTransformation"; -import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas"; +import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; import { ClaudeModelOptions, CodexModelOptions, DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, -} from "./model"; -import { ModelSelection } from "./orchestration"; +} from "./model.ts"; +import { ModelSelection } from "./orchestration.ts"; // ── Client Settings (local-only) ─────────────────────────────── @@ -56,10 +56,25 @@ export const NotificationSoundMap = Schema.Struct({ }); export type NotificationSoundMap = typeof NotificationSoundMap.Type; +export const SidebarProjectGroupingMode = Schema.Literals([ + "repository", + "repository_path", + "separate", +]); +export type SidebarProjectGroupingMode = typeof SidebarProjectGroupingMode.Type; +export const DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE: SidebarProjectGroupingMode = "repository"; + export const ClientSettingsSchema = Schema.Struct({ confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), diffWordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)), + ), + sidebarProjectGroupingOverrides: Schema.Record( + TrimmedNonEmptyString, + SidebarProjectGroupingMode, + ).pipe(Schema.withDecodingDefault(Effect.succeed({}))), sidebarProjectSortOrder: SidebarProjectSortOrder.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_SORT_ORDER)), ), @@ -69,7 +84,6 @@ export const ClientSettingsSchema = Schema.Struct({ timestampFormat: TimestampFormat.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_TIMESTAMP_FORMAT)), ), - showTodosInComposer: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), turnNotificationMode: TurnNotificationMode.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_TURN_NOTIFICATION_MODE)), ), @@ -119,6 +133,7 @@ export const ClaudeSettings = Schema.Struct({ enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), binaryPath: makeBinaryPathSetting("claude"), customModels: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))), + launchArgs: Schema.String.pipe(Schema.withDecodingDefault(Effect.succeed(""))), }); export type ClaudeSettings = typeof ClaudeSettings.Type; @@ -133,6 +148,7 @@ export const ServerSettings = Schema.Struct({ defaultThreadEnvMode: ThreadEnvMode.pipe( Schema.withDecodingDefault(Effect.succeed("local" as const satisfies ThreadEnvMode)), ), + addProjectBaseDirectory: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), textGenerationModelSelection: ModelSelection.pipe( Schema.withDecodingDefault( Effect.succeed({ @@ -212,11 +228,13 @@ const ClaudeSettingsPatch = Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), binaryPath: Schema.optionalKey(Schema.String), customModels: Schema.optionalKey(Schema.Array(Schema.String)), + launchArgs: Schema.optionalKey(Schema.String), }); export const ServerSettingsPatch = Schema.Struct({ enableAssistantStreaming: Schema.optionalKey(Schema.Boolean), defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), + addProjectBaseDirectory: Schema.optionalKey(Schema.String), textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch), observability: Schema.optionalKey( Schema.Struct({ diff --git a/packages/contracts/src/terminal.test.ts b/packages/contracts/src/terminal.test.ts index fda56e44fba..a3ea96e849f 100644 --- a/packages/contracts/src/terminal.test.ts +++ b/packages/contracts/src/terminal.test.ts @@ -11,7 +11,7 @@ import { TerminalSessionSnapshot, TerminalThreadInput, TerminalWriteInput, -} from "./terminal"; +} from "./terminal.ts"; function decodeSync(schema: S, input: unknown): Schema.Schema.Type { return Schema.decodeUnknownSync(schema as never)(input) as Schema.Schema.Type; diff --git a/packages/contracts/src/terminal.ts b/packages/contracts/src/terminal.ts index 3fe883b4420..21bd74a0999 100644 --- a/packages/contracts/src/terminal.ts +++ b/packages/contracts/src/terminal.ts @@ -1,5 +1,5 @@ import { Effect, Schema } from "effect"; -import { TrimmedNonEmptyString } from "./baseSchemas"; +import { TrimmedNonEmptyString } from "./baseSchemas.ts"; export const DEFAULT_TERMINAL_ID = "default"; diff --git a/packages/shared/package.json b/packages/shared/package.json index 2bec15bf8be..4b76421a77e 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -59,6 +59,14 @@ "./qrCode": { "types": "./src/qrCode.ts", "import": "./src/qrCode.ts" + }, + "./cliArgs": { + "types": "./src/cliArgs.ts", + "import": "./src/cliArgs.ts" + }, + "./path": { + "types": "./src/path.ts", + "import": "./src/path.ts" } }, "scripts": { diff --git a/packages/shared/src/DrainableWorker.test.ts b/packages/shared/src/DrainableWorker.test.ts index 1d7a3a83c78..0033038d0c5 100644 --- a/packages/shared/src/DrainableWorker.test.ts +++ b/packages/shared/src/DrainableWorker.test.ts @@ -2,7 +2,7 @@ import { it } from "@effect/vitest"; import { describe, expect } from "vitest"; import { Deferred, Effect } from "effect"; -import { makeDrainableWorker } from "./DrainableWorker"; +import { makeDrainableWorker } from "./DrainableWorker.ts"; describe("makeDrainableWorker", () => { it.live("waits for work enqueued during active processing before draining", () => diff --git a/packages/shared/src/KeyedCoalescingWorker.test.ts b/packages/shared/src/KeyedCoalescingWorker.test.ts index 2226bbd003e..78c3a6b9102 100644 --- a/packages/shared/src/KeyedCoalescingWorker.test.ts +++ b/packages/shared/src/KeyedCoalescingWorker.test.ts @@ -2,7 +2,7 @@ import { it } from "@effect/vitest"; import { describe, expect } from "vitest"; import { Deferred, Effect } from "effect"; -import { makeKeyedCoalescingWorker } from "./KeyedCoalescingWorker"; +import { makeKeyedCoalescingWorker } from "./KeyedCoalescingWorker.ts"; describe("makeKeyedCoalescingWorker", () => { it.live("waits for latest work enqueued during active processing before draining the key", () => diff --git a/packages/shared/src/Net.test.ts b/packages/shared/src/Net.test.ts index 137a9416fd1..19033a082b4 100644 --- a/packages/shared/src/Net.test.ts +++ b/packages/shared/src/Net.test.ts @@ -3,7 +3,7 @@ import * as Net from "node:net"; import { assert, describe, it } from "@effect/vitest"; import { Effect } from "effect"; -import { NetError, NetService } from "./Net"; +import { NetError, NetService } from "./Net.ts"; const closeServer = (server: Net.Server) => Effect.sync(() => { diff --git a/packages/shared/src/String.test.ts b/packages/shared/src/String.test.ts index d70bfe840f2..92730cd596e 100644 --- a/packages/shared/src/String.test.ts +++ b/packages/shared/src/String.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { truncate } from "./String"; +import { truncate } from "./String.ts"; describe("truncate", () => { it("trims surrounding whitespace", () => { diff --git a/packages/shared/src/cliArgs.test.ts b/packages/shared/src/cliArgs.test.ts new file mode 100644 index 00000000000..62544c682c0 --- /dev/null +++ b/packages/shared/src/cliArgs.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from "vitest"; + +import { parseCliArgs } from "./cliArgs.ts"; + +describe("parseCliArgs", () => { + it("returns empty result for empty string", () => { + expect(parseCliArgs("")).toEqual({ flags: {}, positionals: [] }); + }); + + it("returns empty result for whitespace-only string", () => { + expect(parseCliArgs(" ")).toEqual({ flags: {}, positionals: [] }); + }); + + it("returns empty result for empty array", () => { + expect(parseCliArgs([])).toEqual({ flags: {}, positionals: [] }); + }); + + it("parses --chrome boolean flag", () => { + expect(parseCliArgs("--chrome")).toEqual({ + flags: { chrome: null }, + positionals: [], + }); + }); + + it("parses --chrome with --verbose", () => { + expect(parseCliArgs("--chrome --verbose")).toEqual({ + flags: { chrome: null, verbose: null }, + positionals: [], + }); + }); + + it("parses --effort with a value", () => { + expect(parseCliArgs("--effort high")).toEqual({ + flags: { effort: "high" }, + positionals: [], + }); + }); + + it("parses --chrome --effort high --debug", () => { + expect(parseCliArgs("--chrome --effort high --debug")).toEqual({ + flags: { chrome: null, effort: "high", debug: null }, + positionals: [], + }); + }); + + it("parses --model with full model name", () => { + expect(parseCliArgs("--model claude-sonnet-4-6")).toEqual({ + flags: { model: "claude-sonnet-4-6" }, + positionals: [], + }); + }); + + it("parses --append-system-prompt with value and --chrome", () => { + expect(parseCliArgs("--append-system-prompt always-think-step-by-step --chrome")).toEqual({ + flags: { "append-system-prompt": "always-think-step-by-step", chrome: null }, + positionals: [], + }); + }); + + it("parses --max-budget-usd with numeric value", () => { + expect(parseCliArgs("--chrome --max-budget-usd 5.00")).toEqual({ + flags: { chrome: null, "max-budget-usd": "5.00" }, + positionals: [], + }); + }); + + it("parses --effort=high syntax", () => { + expect(parseCliArgs("--effort=high")).toEqual({ + flags: { effort: "high" }, + positionals: [], + }); + }); + + it("parses --key=value mixed with boolean flags", () => { + expect(parseCliArgs("--chrome --model=claude-sonnet-4-6 --debug")).toEqual({ + flags: { chrome: null, model: "claude-sonnet-4-6", debug: null }, + positionals: [], + }); + }); + + it("collects positional arguments", () => { + expect(parseCliArgs("1.2.3")).toEqual({ + flags: {}, + positionals: ["1.2.3"], + }); + }); + + it("collects positionals mixed with flags (argv array)", () => { + expect(parseCliArgs(["1.2.3", "--root", "/path", "--github-output"])).toEqual({ + flags: { root: "/path", "github-output": null }, + positionals: ["1.2.3"], + }); + }); + + it("handles extra whitespace between tokens", () => { + expect(parseCliArgs(" --chrome --verbose ")).toEqual({ + flags: { chrome: null, verbose: null }, + positionals: [], + }); + }); + + it("ignores bare -- with no flag name", () => { + expect(parseCliArgs("--")).toEqual({ flags: {}, positionals: [] }); + }); + + it("boolean flag does not consume next token as value", () => { + expect(parseCliArgs(["--github-output", "1.2.3"], { booleanFlags: ["github-output"] })).toEqual( + { + flags: { "github-output": null }, + positionals: ["1.2.3"], + }, + ); + }); + + it("non-boolean flag still consumes next token", () => { + expect(parseCliArgs(["--root", "/path", "1.2.3"], { booleanFlags: ["github-output"] })).toEqual( + { + flags: { root: "/path" }, + positionals: ["1.2.3"], + }, + ); + }); + + it("mixes boolean and value flags with positionals", () => { + expect( + parseCliArgs(["--github-output", "--root", "/path", "1.2.3"], { + booleanFlags: ["github-output"], + }), + ).toEqual({ + flags: { "github-output": null, root: "/path" }, + positionals: ["1.2.3"], + }); + }); +}); diff --git a/packages/shared/src/cliArgs.ts b/packages/shared/src/cliArgs.ts new file mode 100644 index 00000000000..20920093302 --- /dev/null +++ b/packages/shared/src/cliArgs.ts @@ -0,0 +1,76 @@ +export interface ParsedCliArgs { + readonly flags: Record; + readonly positionals: string[]; +} + +export interface ParseCliArgsOptions { + readonly booleanFlags?: readonly string[]; +} + +/** + * Parse CLI-style arguments into flags and positionals. + * + * Accepts a string (split by whitespace) or a pre-split argv array. + * Supports `--key value`, `--key=value`, and `--flag` (boolean) syntax. + * + * parseCliArgs("") + * → { flags: {}, positionals: [] } + * + * parseCliArgs("--chrome") + * → { flags: { chrome: null }, positionals: [] } + * + * parseCliArgs("--chrome --effort high") + * → { flags: { chrome: null, effort: "high" }, positionals: [] } + * + * parseCliArgs("--effort=high") + * → { flags: { effort: "high" }, positionals: [] } + * + * parseCliArgs(["1.2.3", "--root", "/path", "--github-output"], { booleanFlags: ["github-output"] }) + * → { flags: { root: "/path", "github-output": null }, positionals: ["1.2.3"] } + */ +export function parseCliArgs( + args: string | readonly string[], + options?: ParseCliArgsOptions, +): ParsedCliArgs { + const tokens = + typeof args === "string" ? args.trim().split(/\s+/).filter(Boolean) : Array.from(args); + const booleanSet = options?.booleanFlags ? new Set(options.booleanFlags) : undefined; + + const flags: Record = {}; + const positionals: string[] = []; + + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]!; + + if (token.startsWith("--")) { + const rest = token.slice(2); + if (!rest) continue; + + // Handle --key=value syntax + const eqIndex = rest.indexOf("="); + if (eqIndex !== -1) { + flags[rest.slice(0, eqIndex)] = rest.slice(eqIndex + 1); + continue; + } + + // Known boolean flag — never consumes next token + if (booleanSet?.has(rest)) { + flags[rest] = null; + continue; + } + + // Handle --key value or --flag (boolean) + const next = tokens[i + 1]; + if (next !== undefined && !next.startsWith("--")) { + flags[rest] = next; + i++; + } else { + flags[rest] = null; + } + } else { + positionals.push(token); + } + } + + return { flags, positionals }; +} diff --git a/packages/shared/src/git.test.ts b/packages/shared/src/git.test.ts index 89919b81162..7a010355ccd 100644 --- a/packages/shared/src/git.test.ts +++ b/packages/shared/src/git.test.ts @@ -3,9 +3,12 @@ import { describe, expect, it } from "vitest"; import { applyGitStatusStreamEvent, + buildTemporaryWorktreeBranchName, + isTemporaryWorktreeBranch, normalizeGitRemoteUrl, parseGitHubRepositoryNameWithOwnerFromRemoteUrl, -} from "./git"; + WORKTREE_BRANCH_PREFIX, +} from "./git.ts"; describe("normalizeGitRemoteUrl", () => { it("canonicalizes equivalent GitHub remotes across protocol variants", () => { @@ -50,6 +53,24 @@ describe("parseGitHubRepositoryNameWithOwnerFromRemoteUrl", () => { }); }); +describe("isTemporaryWorktreeBranch", () => { + it("matches the generated temporary worktree branch format", () => { + expect(isTemporaryWorktreeBranch(buildTemporaryWorktreeBranchName())).toBe(true); + }); + + it("matches generated temporary worktree branches", () => { + expect(isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}/deadbeef`)).toBe(true); + expect(isTemporaryWorktreeBranch(` ${WORKTREE_BRANCH_PREFIX}/deadbeef `)).toBe(true); + expect(isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}/DEADBEEF`)).toBe(true); + }); + + it("rejects non-temporary branch names", () => { + expect(isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}/feature/demo`)).toBe(false); + expect(isTemporaryWorktreeBranch("main")).toBe(false); + expect(isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}/deadbeef-extra`)).toBe(false); + }); +}); + describe("applyGitStatusStreamEvent", () => { it("treats a remote-only update as a repository when local state is missing", () => { const remote: GitStatusRemoteResult = { diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index 0e2f01e8072..3020f717363 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -6,6 +6,11 @@ import type { GitStatusResult, GitStatusStreamEvent, } from "@marcode/contracts"; +import * as Effect from "effect/Effect"; +import * as Random from "effect/Random"; + +export const WORKTREE_BRANCH_PREFIX = "marcode"; +const TEMP_WORKTREE_BRANCH_PATTERN = new RegExp(`^${WORKTREE_BRANCH_PREFIX}\\/[0-9a-f]{8}$`); /** * Sanitize an arbitrary string into a valid, lowercase git branch fragment. @@ -80,6 +85,15 @@ export function deriveLocalBranchNameFromRemoteRef(branchName: string): string { return branchName.slice(firstSeparatorIndex + 1); } +export function buildTemporaryWorktreeBranchName(): string { + const token = Effect.runSync(Random.nextUUIDv4).replace(/-/g, "").slice(0, 8).toLowerCase(); + return `${WORKTREE_BRANCH_PREFIX}/${token}`; +} + +export function isTemporaryWorktreeBranch(branch: string): boolean { + return TEMP_WORKTREE_BRANCH_PATTERN.test(branch.trim().toLowerCase()); +} + /** * Normalize a git remote URL into a stable comparison key. */ diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index 0d590b2ff21..41adfba9fc7 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -18,7 +18,7 @@ import { resolveModelSlugForProvider, resolveSelectableModel, trimOrNull, -} from "./model"; +} from "./model.ts"; const codexCaps: ModelCapabilities = { reasoningEffortLevels: [ diff --git a/packages/shared/src/path.test.ts b/packages/shared/src/path.test.ts new file mode 100644 index 00000000000..1c74c59a36f --- /dev/null +++ b/packages/shared/src/path.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { + isExplicitRelativePath, + isUncPath, + isWindowsAbsolutePath, + isWindowsDrivePath, +} from "./path.ts"; + +describe("path helpers", () => { + it("detects windows drive paths", () => { + expect(isWindowsDrivePath("C:\\repo")).toBe(true); + expect(isWindowsDrivePath("D:/repo")).toBe(true); + expect(isWindowsDrivePath("/repo")).toBe(false); + }); + + it("detects UNC paths", () => { + expect(isUncPath("\\\\server\\share\\repo")).toBe(true); + expect(isUncPath("C:\\repo")).toBe(false); + }); + + it("detects windows absolute paths", () => { + expect(isWindowsAbsolutePath("C:\\repo")).toBe(true); + expect(isWindowsAbsolutePath("\\\\server\\share\\repo")).toBe(true); + expect(isWindowsAbsolutePath("./repo")).toBe(false); + }); + + it("detects explicit relative paths", () => { + expect(isExplicitRelativePath(".")).toBe(true); + expect(isExplicitRelativePath("..")).toBe(true); + expect(isExplicitRelativePath("./repo")).toBe(true); + expect(isExplicitRelativePath("..\\repo")).toBe(true); + expect(isExplicitRelativePath("~/repo")).toBe(false); + }); +}); diff --git a/packages/shared/src/path.ts b/packages/shared/src/path.ts new file mode 100644 index 00000000000..2bb2ca0238d --- /dev/null +++ b/packages/shared/src/path.ts @@ -0,0 +1,22 @@ +export function isWindowsDrivePath(value: string): boolean { + return /^[a-zA-Z]:([/\\]|$)/.test(value); +} + +export function isUncPath(value: string): boolean { + return value.startsWith("\\\\"); +} + +export function isWindowsAbsolutePath(value: string): boolean { + return isUncPath(value) || isWindowsDrivePath(value); +} + +export function isExplicitRelativePath(value: string): boolean { + return ( + value === "." || + value === ".." || + value.startsWith("./") || + value.startsWith("../") || + value.startsWith(".\\") || + value.startsWith("..\\") + ); +} diff --git a/packages/shared/src/qrCode.ts b/packages/shared/src/qrCode.ts index 678d38c1114..490e11fa04f 100644 --- a/packages/shared/src/qrCode.ts +++ b/packages/shared/src/qrCode.ts @@ -1,4 +1,3 @@ -// @ts-nocheck /* oxlint-disable eslint/no-useless-escape */ /* * QR Code generator library (TypeScript) @@ -25,960 +24,962 @@ "use strict"; -namespace qrcodegen { - type bit = number; - type byte = number; - type int = number; - - /*---- QR Code symbol class ----*/ - - /* - * A QR Code symbol, which is a type of two-dimension barcode. - * Invented by Denso Wave and described in the ISO/IEC 18004 standard. - * Instances of this class represent an immutable square grid of dark and light cells. - * The class provides static factory functions to create a QR Code from text or binary data. - * The class covers the QR Code Model 2 specification, supporting all versions (sizes) - * from 1 to 40, all 4 error correction levels, and 4 character encoding modes. - * - * Ways to create a QR Code object: - * - High level: Take the payload data and call QrCode.encodeText() or QrCode.encodeBinary(). - * - Mid level: Custom-make the list of segments and call QrCode.encodeSegments(). - * - Low level: Custom-make the array of data codeword bytes (including - * segment headers and final padding, excluding error correction codewords), - * supply the appropriate version number, and call the QrCode() constructor. - * (Note that all ways require supplying the desired error correction level.) - */ - export class QrCode { - /*-- Static factory functions (high level) --*/ - - // Returns a QR Code representing the given Unicode text string at the given error correction level. - // As a conservative upper bound, this function is guaranteed to succeed for strings that have 738 or fewer - // Unicode code points (not UTF-16 code units) if the low error correction level is used. The smallest possible - // QR Code version is automatically chosen for the output. The ECC level of the result may be higher than the - // ecl argument if it can be done without increasing the version. - public static encodeText(text: string, ecl: QrCode.Ecc): QrCode { - const segs: Array = qrcodegen.QrSegment.makeSegments(text); - return QrCode.encodeSegments(segs, ecl); - } +type bit = number; +type byte = number; +type int = number; - // Returns a QR Code representing the given binary data at the given error correction level. - // This function always encodes using the binary segment mode, not any text mode. The maximum number of - // bytes allowed is 2953. The smallest possible QR Code version is automatically chosen for the output. - // The ECC level of the result may be higher than the ecl argument if it can be done without increasing the version. - public static encodeBinary(data: Readonly>, ecl: QrCode.Ecc): QrCode { - const seg: QrSegment = qrcodegen.QrSegment.makeBytes(data); - return QrCode.encodeSegments([seg], ecl); - } +/*---- QR Code symbol class ----*/ - /*-- Static factory functions (mid level) --*/ - - // Returns a QR Code representing the given segments with the given encoding parameters. - // The smallest possible QR Code version within the given range is automatically - // chosen for the output. Iff boostEcl is true, then the ECC level of the result - // may be higher than the ecl argument if it can be done without increasing the - // version. The mask number is either between 0 to 7 (inclusive) to force that - // mask, or -1 to automatically choose an appropriate mask (which may be slow). - // This function allows the user to create a custom sequence of segments that switches - // between modes (such as alphanumeric and byte) to encode text in less space. - // This is a mid-level API; the high-level API is encodeText() and encodeBinary(). - public static encodeSegments( - segs: Readonly>, - ecl: QrCode.Ecc, - minVersion: int = 1, - maxVersion: int = 40, - mask: int = -1, - boostEcl: boolean = true, - ): QrCode { - if ( - !( - QrCode.MIN_VERSION <= minVersion && - minVersion <= maxVersion && - maxVersion <= QrCode.MAX_VERSION - ) || - mask < -1 || - mask > 7 - ) - throw new RangeError("Invalid value"); - - // Find the minimal version number to use - let version: int; - let dataUsedBits: int; - for (version = minVersion; ; version++) { - const dataCapacityBits: int = QrCode.getNumDataCodewords(version, ecl) * 8; // Number of data bits available - const usedBits: number = QrSegment.getTotalBits(segs, version); - if (usedBits <= dataCapacityBits) { - dataUsedBits = usedBits; - break; // This version number is found to be suitable - } - if (version >= maxVersion) - // All versions in the range could not fit the given data - throw new RangeError("Data too long"); - } +/* + * A QR Code symbol, which is a type of two-dimension barcode. + * Invented by Denso Wave and described in the ISO/IEC 18004 standard. + * Instances of this class represent an immutable square grid of dark and light cells. + * The class provides static factory functions to create a QR Code from text or binary data. + * The class covers the QR Code Model 2 specification, supporting all versions (sizes) + * from 1 to 40, all 4 error correction levels, and 4 character encoding modes. + * + * Ways to create a QR Code object: + * - High level: Take the payload data and call QrCode.encodeText() or QrCode.encodeBinary(). + * - Mid level: Custom-make the list of segments and call QrCode.encodeSegments(). + * - Low level: Custom-make the array of data codeword bytes (including + * segment headers and final padding, excluding error correction codewords), + * supply the appropriate version number, and call the QrCode() constructor. + * (Note that all ways require supplying the desired error correction level.) + */ +export class QrCode { + public static Ecc: typeof QrCodeEcc; + + /*-- Static factory functions (high level) --*/ + + // Returns a QR Code representing the given Unicode text string at the given error correction level. + // As a conservative upper bound, this function is guaranteed to succeed for strings that have 738 or fewer + // Unicode code points (not UTF-16 code units) if the low error correction level is used. The smallest possible + // QR Code version is automatically chosen for the output. The ECC level of the result may be higher than the + // ecl argument if it can be done without increasing the version. + public static encodeText(text: string, ecl: QrCodeEcc): QrCode { + const segs: Array = QrSegment.makeSegments(text); + return QrCode.encodeSegments(segs, ecl); + } - // Increase the error correction level while the data still fits in the current version number - for (const newEcl of [QrCode.Ecc.MEDIUM, QrCode.Ecc.QUARTILE, QrCode.Ecc.HIGH]) { - // From low to high - if (boostEcl && dataUsedBits <= QrCode.getNumDataCodewords(version, newEcl) * 8) - ecl = newEcl; - } + // Returns a QR Code representing the given binary data at the given error correction level. + // This function always encodes using the binary segment mode, not any text mode. The maximum number of + // bytes allowed is 2953. The smallest possible QR Code version is automatically chosen for the output. + // The ECC level of the result may be higher than the ecl argument if it can be done without increasing the version. + public static encodeBinary(data: Readonly>, ecl: QrCodeEcc): QrCode { + const seg: QrSegment = QrSegment.makeBytes(data); + return QrCode.encodeSegments([seg], ecl); + } - // Concatenate all segments to create the data bit string - let bb: Array = []; - for (const seg of segs) { - appendBits(seg.mode.modeBits, 4, bb); - appendBits(seg.numChars, seg.mode.numCharCountBits(version), bb); - for (const b of seg.getData()) bb.push(b); + /*-- Static factory functions (mid level) --*/ + + // Returns a QR Code representing the given segments with the given encoding parameters. + // The smallest possible QR Code version within the given range is automatically + // chosen for the output. Iff boostEcl is true, then the ECC level of the result + // may be higher than the ecl argument if it can be done without increasing the + // version. The mask number is either between 0 to 7 (inclusive) to force that + // mask, or -1 to automatically choose an appropriate mask (which may be slow). + // This function allows the user to create a custom sequence of segments that switches + // between modes (such as alphanumeric and byte) to encode text in less space. + // This is a mid-level API; the high-level API is encodeText() and encodeBinary(). + public static encodeSegments( + segs: Readonly>, + ecl: QrCodeEcc, + minVersion: int = 1, + maxVersion: int = 40, + mask: int = -1, + boostEcl: boolean = true, + ): QrCode { + if ( + !( + QrCode.MIN_VERSION <= minVersion && + minVersion <= maxVersion && + maxVersion <= QrCode.MAX_VERSION + ) || + mask < -1 || + mask > 7 + ) + throw new RangeError("Invalid value"); + + // Find the minimal version number to use + let version: int; + let dataUsedBits: int; + for (version = minVersion; ; version++) { + const dataCapacityBits: int = QrCode.getNumDataCodewords(version, ecl) * 8; // Number of data bits available + const usedBits: number = QrSegment.getTotalBits(segs, version); + if (usedBits <= dataCapacityBits) { + dataUsedBits = usedBits; + break; // This version number is found to be suitable } - assert(bb.length == dataUsedBits); - - // Add terminator and pad up to a byte if applicable - const dataCapacityBits: int = QrCode.getNumDataCodewords(version, ecl) * 8; - assert(bb.length <= dataCapacityBits); - appendBits(0, Math.min(4, dataCapacityBits - bb.length), bb); - appendBits(0, (8 - (bb.length % 8)) % 8, bb); - assert(bb.length % 8 == 0); - - // Pad with alternating bytes until data capacity is reached - for (let padByte = 0xec; bb.length < dataCapacityBits; padByte ^= 0xec ^ 0x11) - appendBits(padByte, 8, bb); - - // Pack bits into bytes in big endian - let dataCodewords: Array = []; - while (dataCodewords.length * 8 < bb.length) dataCodewords.push(0); - bb.forEach((b: bit, i: int) => (dataCodewords[i >>> 3] |= b << (7 - (i & 7)))); - - // Create the QR Code object - return new QrCode(version, ecl, dataCodewords, mask); + if (version >= maxVersion) + // All versions in the range could not fit the given data + throw new RangeError("Data too long"); } - /*-- Fields --*/ - - // The width and height of this QR Code, measured in modules, between - // 21 and 177 (inclusive). This is equal to version * 4 + 17. - public readonly size: int; - - // The index of the mask pattern used in this QR Code, which is between 0 and 7 (inclusive). - // Even if a QR Code is created with automatic masking requested (mask = -1), - // the resulting object still has a mask value between 0 and 7. - public readonly mask: int; - - // The modules of this QR Code (false = light, true = dark). - // Immutable after constructor finishes. Accessed through getModule(). - private readonly modules: Array> = []; - - // Indicates function modules that are not subjected to masking. Discarded when constructor finishes. - private readonly isFunction: Array> = []; - - /*-- Constructor (low level) and fields --*/ - - // Creates a new QR Code with the given version number, - // error correction level, data codeword bytes, and mask number. - // This is a low-level API that most users should not use directly. - // A mid-level API is the encodeSegments() function. - public constructor( - // The version number of this QR Code, which is between 1 and 40 (inclusive). - // This determines the size of this barcode. - public readonly version: int, - - // The error correction level used in this QR Code. - public readonly errorCorrectionLevel: QrCode.Ecc, - - dataCodewords: Readonly>, - - msk: int, - ) { - // Check scalar arguments - if (version < QrCode.MIN_VERSION || version > QrCode.MAX_VERSION) - throw new RangeError("Version value out of range"); - if (msk < -1 || msk > 7) throw new RangeError("Mask value out of range"); - this.size = version * 4 + 17; - - // Initialize both grids to be size*size arrays of Boolean false - let row: Array = []; - for (let i = 0; i < this.size; i++) row.push(false); - for (let i = 0; i < this.size; i++) { - this.modules.push(row.slice()); // Initially all light - this.isFunction.push(row.slice()); - } - - // Compute ECC, draw modules - this.drawFunctionPatterns(); - const allCodewords: Array = this.addEccAndInterleave(dataCodewords); - this.drawCodewords(allCodewords); - - // Do masking - if (msk == -1) { - // Automatically choose best mask - let minPenalty: int = 1000000000; - for (let i = 0; i < 8; i++) { - this.applyMask(i); - this.drawFormatBits(i); - const penalty: int = this.getPenaltyScore(); - if (penalty < minPenalty) { - msk = i; - minPenalty = penalty; - } - this.applyMask(i); // Undoes the mask due to XOR - } - } - assert(0 <= msk && msk <= 7); - this.mask = msk; - this.applyMask(msk); // Apply the final choice of mask - this.drawFormatBits(msk); // Overwrite old format bits + // Increase the error correction level while the data still fits in the current version number + for (const newEcl of [QrCode.Ecc.MEDIUM, QrCode.Ecc.QUARTILE, QrCode.Ecc.HIGH]) { + // From low to high + if (boostEcl && dataUsedBits <= QrCode.getNumDataCodewords(version, newEcl) * 8) ecl = newEcl; + } - this.isFunction = []; + // Concatenate all segments to create the data bit string + let bb: Array = []; + for (const seg of segs) { + appendBits(seg.mode.modeBits, 4, bb); + appendBits(seg.numChars, seg.mode.numCharCountBits(version), bb); + for (const b of seg.getData()) bb.push(b); } + assert(bb.length == dataUsedBits); - /*-- Accessor methods --*/ + // Add terminator and pad up to a byte if applicable + const dataCapacityBits: int = QrCode.getNumDataCodewords(version, ecl) * 8; + assert(bb.length <= dataCapacityBits); + appendBits(0, Math.min(4, dataCapacityBits - bb.length), bb); + appendBits(0, (8 - (bb.length % 8)) % 8, bb); + assert(bb.length % 8 == 0); - // Returns the color of the module (pixel) at the given coordinates, which is false - // for light or true for dark. The top left corner has the coordinates (x=0, y=0). - // If the given coordinates are out of bounds, then false (light) is returned. - public getModule(x: int, y: int): boolean { - return 0 <= x && x < this.size && 0 <= y && y < this.size && this.modules[y][x]; - } + // Pad with alternating bytes until data capacity is reached + for (let padByte = 0xec; bb.length < dataCapacityBits; padByte ^= 0xec ^ 0x11) + appendBits(padByte, 8, bb); - /*-- Private helper methods for constructor: Drawing function modules --*/ + // Pack bits into bytes in big endian + let dataCodewords: Array = []; + while (dataCodewords.length * 8 < bb.length) dataCodewords.push(0); + bb.forEach((b: bit, i: int) => (dataCodewords[i >>> 3]! |= b << (7 - (i & 7)))); - // Reads this object's version field, and draws and marks all function modules. - private drawFunctionPatterns(): void { - // Draw horizontal and vertical timing patterns - for (let i = 0; i < this.size; i++) { - this.setFunctionModule(6, i, i % 2 == 0); - this.setFunctionModule(i, 6, i % 2 == 0); - } + // Create the QR Code object + return new QrCode(version, ecl, dataCodewords, mask); + } - // Draw 3 finder patterns (all corners except bottom right; overwrites some timing modules) - this.drawFinderPattern(3, 3); - this.drawFinderPattern(this.size - 4, 3); - this.drawFinderPattern(3, this.size - 4); - - // Draw numerous alignment patterns - const alignPatPos: Array = this.getAlignmentPatternPositions(); - const numAlign: int = alignPatPos.length; - for (let i = 0; i < numAlign; i++) { - for (let j = 0; j < numAlign; j++) { - // Don't draw on the three finder corners - if ( - !((i == 0 && j == 0) || (i == 0 && j == numAlign - 1) || (i == numAlign - 1 && j == 0)) - ) - this.drawAlignmentPattern(alignPatPos[i], alignPatPos[j]); + /*-- Fields --*/ + + // The width and height of this QR Code, measured in modules, between + // 21 and 177 (inclusive). This is equal to version * 4 + 17. + public readonly version: int; + public readonly errorCorrectionLevel: QrCodeEcc; + public readonly size: int; + + // The index of the mask pattern used in this QR Code, which is between 0 and 7 (inclusive). + // Even if a QR Code is created with automatic masking requested (mask = -1), + // the resulting object still has a mask value between 0 and 7. + public readonly mask: int; + + // The modules of this QR Code (false = light, true = dark). + // Immutable after constructor finishes. Accessed through getModule(). + private readonly modules: Array> = []; + + // Indicates function modules that are not subjected to masking. Discarded when constructor finishes. + private readonly isFunction: Array> = []; + + /*-- Constructor (low level) and fields --*/ + + // Creates a new QR Code with the given version number, + // error correction level, data codeword bytes, and mask number. + // This is a low-level API that most users should not use directly. + // A mid-level API is the encodeSegments() function. + public constructor( + // The version number of this QR Code, which is between 1 and 40 (inclusive). + // This determines the size of this barcode. + version: int, + + // The error correction level used in this QR Code. + errorCorrectionLevel: QrCodeEcc, + + dataCodewords: Readonly>, + + msk: int, + ) { + this.version = version; + this.errorCorrectionLevel = errorCorrectionLevel; + + // Check scalar arguments + if (version < QrCode.MIN_VERSION || version > QrCode.MAX_VERSION) + throw new RangeError("Version value out of range"); + if (msk < -1 || msk > 7) throw new RangeError("Mask value out of range"); + this.size = version * 4 + 17; + + // Initialize both grids to be size*size arrays of Boolean false + let row: Array = []; + for (let i = 0; i < this.size; i++) row.push(false); + for (let i = 0; i < this.size; i++) { + this.modules.push(row.slice()); // Initially all light + this.isFunction.push(row.slice()); + } + + // Compute ECC, draw modules + this.drawFunctionPatterns(); + const allCodewords: Array = this.addEccAndInterleave(dataCodewords); + this.drawCodewords(allCodewords); + + // Do masking + if (msk == -1) { + // Automatically choose best mask + let minPenalty: int = 1000000000; + for (let i = 0; i < 8; i++) { + this.applyMask(i); + this.drawFormatBits(i); + const penalty: int = this.getPenaltyScore(); + if (penalty < minPenalty) { + msk = i; + minPenalty = penalty; } + this.applyMask(i); // Undoes the mask due to XOR } - - // Draw configuration data - this.drawFormatBits(0); // Dummy mask value; overwritten later in the constructor - this.drawVersion(); } + assert(0 <= msk && msk <= 7); + this.mask = msk; + this.applyMask(msk); // Apply the final choice of mask + this.drawFormatBits(msk); // Overwrite old format bits - // Draws two copies of the format bits (with its own error correction code) - // based on the given mask and this object's error correction level field. - private drawFormatBits(mask: int): void { - // Calculate error correction code and pack bits - const data: int = (this.errorCorrectionLevel.formatBits << 3) | mask; // errCorrLvl is uint2, mask is uint3 - let rem: int = data; - for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >>> 9) * 0x537); - const bits = ((data << 10) | rem) ^ 0x5412; // uint15 - assert(bits >>> 15 == 0); - - // Draw first copy - for (let i = 0; i <= 5; i++) this.setFunctionModule(8, i, getBit(bits, i)); - this.setFunctionModule(8, 7, getBit(bits, 6)); - this.setFunctionModule(8, 8, getBit(bits, 7)); - this.setFunctionModule(7, 8, getBit(bits, 8)); - for (let i = 9; i < 15; i++) this.setFunctionModule(14 - i, 8, getBit(bits, i)); - - // Draw second copy - for (let i = 0; i < 8; i++) this.setFunctionModule(this.size - 1 - i, 8, getBit(bits, i)); - for (let i = 8; i < 15; i++) this.setFunctionModule(8, this.size - 15 + i, getBit(bits, i)); - this.setFunctionModule(8, this.size - 8, true); // Always dark - } + this.isFunction = []; + } + + /*-- Accessor methods --*/ + + // Returns the color of the module (pixel) at the given coordinates, which is false + // for light or true for dark. The top left corner has the coordinates (x=0, y=0). + // If the given coordinates are out of bounds, then false (light) is returned. + public getModule(x: int, y: int): boolean { + return 0 <= x && x < this.size && 0 <= y && y < this.size && this.modules[y]![x]!; + } - // Draws two copies of the version bits (with its own error correction code), - // based on this object's version field, iff 7 <= version <= 40. - private drawVersion(): void { - if (this.version < 7) return; - - // Calculate error correction code and pack bits - let rem: int = this.version; // version is uint6, in the range [7, 40] - for (let i = 0; i < 12; i++) rem = (rem << 1) ^ ((rem >>> 11) * 0x1f25); - const bits: int = (this.version << 12) | rem; // uint18 - assert(bits >>> 18 == 0); - - // Draw two copies - for (let i = 0; i < 18; i++) { - const color: boolean = getBit(bits, i); - const a: int = this.size - 11 + (i % 3); - const b: int = Math.floor(i / 3); - this.setFunctionModule(a, b, color); - this.setFunctionModule(b, a, color); + /*-- Private helper methods for constructor: Drawing function modules --*/ + + // Reads this object's version field, and draws and marks all function modules. + private drawFunctionPatterns(): void { + // Draw horizontal and vertical timing patterns + for (let i = 0; i < this.size; i++) { + this.setFunctionModule(6, i, i % 2 == 0); + this.setFunctionModule(i, 6, i % 2 == 0); + } + + // Draw 3 finder patterns (all corners except bottom right; overwrites some timing modules) + this.drawFinderPattern(3, 3); + this.drawFinderPattern(this.size - 4, 3); + this.drawFinderPattern(3, this.size - 4); + + // Draw numerous alignment patterns + const alignPatPos: Array = this.getAlignmentPatternPositions(); + const numAlign: int = alignPatPos.length; + for (let i = 0; i < numAlign; i++) { + for (let j = 0; j < numAlign; j++) { + // Don't draw on the three finder corners + if (!((i == 0 && j == 0) || (i == 0 && j == numAlign - 1) || (i == numAlign - 1 && j == 0))) + this.drawAlignmentPattern(alignPatPos[i]!, alignPatPos[j]!); } } - // Draws a 9*9 finder pattern including the border separator, - // with the center module at (x, y). Modules can be out of bounds. - private drawFinderPattern(x: int, y: int): void { - for (let dy = -4; dy <= 4; dy++) { - for (let dx = -4; dx <= 4; dx++) { - const dist: int = Math.max(Math.abs(dx), Math.abs(dy)); // Chebyshev/infinity norm - const xx: int = x + dx; - const yy: int = y + dy; - if (0 <= xx && xx < this.size && 0 <= yy && yy < this.size) - this.setFunctionModule(xx, yy, dist != 2 && dist != 4); - } - } + // Draw configuration data + this.drawFormatBits(0); // Dummy mask value; overwritten later in the constructor + this.drawVersion(); + } + + // Draws two copies of the format bits (with its own error correction code) + // based on the given mask and this object's error correction level field. + private drawFormatBits(mask: int): void { + // Calculate error correction code and pack bits + const data: int = (this.errorCorrectionLevel.formatBits << 3) | mask; // errCorrLvl is uint2, mask is uint3 + let rem: int = data; + for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >>> 9) * 0x537); + const bits = ((data << 10) | rem) ^ 0x5412; // uint15 + assert(bits >>> 15 == 0); + + // Draw first copy + for (let i = 0; i <= 5; i++) this.setFunctionModule(8, i, getBit(bits, i)); + this.setFunctionModule(8, 7, getBit(bits, 6)); + this.setFunctionModule(8, 8, getBit(bits, 7)); + this.setFunctionModule(7, 8, getBit(bits, 8)); + for (let i = 9; i < 15; i++) this.setFunctionModule(14 - i, 8, getBit(bits, i)); + + // Draw second copy + for (let i = 0; i < 8; i++) this.setFunctionModule(this.size - 1 - i, 8, getBit(bits, i)); + for (let i = 8; i < 15; i++) this.setFunctionModule(8, this.size - 15 + i, getBit(bits, i)); + this.setFunctionModule(8, this.size - 8, true); // Always dark + } + + // Draws two copies of the version bits (with its own error correction code), + // based on this object's version field, iff 7 <= version <= 40. + private drawVersion(): void { + if (this.version < 7) return; + + // Calculate error correction code and pack bits + let rem: int = this.version; // version is uint6, in the range [7, 40] + for (let i = 0; i < 12; i++) rem = (rem << 1) ^ ((rem >>> 11) * 0x1f25); + const bits: int = (this.version << 12) | rem; // uint18 + assert(bits >>> 18 == 0); + + // Draw two copies + for (let i = 0; i < 18; i++) { + const color: boolean = getBit(bits, i); + const a: int = this.size - 11 + (i % 3); + const b: int = Math.floor(i / 3); + this.setFunctionModule(a, b, color); + this.setFunctionModule(b, a, color); } + } - // Draws a 5*5 alignment pattern, with the center module - // at (x, y). All modules must be in bounds. - private drawAlignmentPattern(x: int, y: int): void { - for (let dy = -2; dy <= 2; dy++) { - for (let dx = -2; dx <= 2; dx++) - this.setFunctionModule(x + dx, y + dy, Math.max(Math.abs(dx), Math.abs(dy)) != 1); + // Draws a 9*9 finder pattern including the border separator, + // with the center module at (x, y). Modules can be out of bounds. + private drawFinderPattern(x: int, y: int): void { + for (let dy = -4; dy <= 4; dy++) { + for (let dx = -4; dx <= 4; dx++) { + const dist: int = Math.max(Math.abs(dx), Math.abs(dy)); // Chebyshev/infinity norm + const xx: int = x + dx; + const yy: int = y + dy; + if (0 <= xx && xx < this.size && 0 <= yy && yy < this.size) + this.setFunctionModule(xx, yy, dist != 2 && dist != 4); } } + } - // Sets the color of a module and marks it as a function module. - // Only used by the constructor. Coordinates must be in bounds. - private setFunctionModule(x: int, y: int, isDark: boolean): void { - this.modules[y][x] = isDark; - this.isFunction[y][x] = true; + // Draws a 5*5 alignment pattern, with the center module + // at (x, y). All modules must be in bounds. + private drawAlignmentPattern(x: int, y: int): void { + for (let dy = -2; dy <= 2; dy++) { + for (let dx = -2; dx <= 2; dx++) + this.setFunctionModule(x + dx, y + dy, Math.max(Math.abs(dx), Math.abs(dy)) != 1); } + } - /*-- Private helper methods for constructor: Codewords and masking --*/ - - // Returns a new byte string representing the given data with the appropriate error correction - // codewords appended to it, based on this object's version and error correction level. - private addEccAndInterleave(data: Readonly>): Array { - const ver: int = this.version; - const ecl: QrCode.Ecc = this.errorCorrectionLevel; - if (data.length != QrCode.getNumDataCodewords(ver, ecl)) - throw new RangeError("Invalid argument"); - - // Calculate parameter numbers - const numBlocks: int = QrCode.NUM_ERROR_CORRECTION_BLOCKS[ecl.ordinal][ver]; - const blockEccLen: int = QrCode.ECC_CODEWORDS_PER_BLOCK[ecl.ordinal][ver]; - const rawCodewords: int = Math.floor(QrCode.getNumRawDataModules(ver) / 8); - const numShortBlocks: int = numBlocks - (rawCodewords % numBlocks); - const shortBlockLen: int = Math.floor(rawCodewords / numBlocks); - - // Split data into blocks and append ECC to each block - let blocks: Array> = []; - const rsDiv: Array = QrCode.reedSolomonComputeDivisor(blockEccLen); - for (let i = 0, k = 0; i < numBlocks; i++) { - let dat: Array = data.slice( - k, - k + shortBlockLen - blockEccLen + (i < numShortBlocks ? 0 : 1), - ); - k += dat.length; - const ecc: Array = QrCode.reedSolomonComputeRemainder(dat, rsDiv); - if (i < numShortBlocks) dat.push(0); - blocks.push(dat.concat(ecc)); - } + // Sets the color of a module and marks it as a function module. + // Only used by the constructor. Coordinates must be in bounds. + private setFunctionModule(x: int, y: int, isDark: boolean): void { + this.modules[y]![x] = isDark; + this.isFunction[y]![x] = true; + } - // Interleave (not concatenate) the bytes from every block into a single sequence - let result: Array = []; - for (let i = 0; i < blocks[0].length; i++) { - blocks.forEach((block, j) => { - // Skip the padding byte in short blocks - if (i != shortBlockLen - blockEccLen || j >= numShortBlocks) result.push(block[i]); - }); - } - assert(result.length == rawCodewords); - return result; - } + /*-- Private helper methods for constructor: Codewords and masking --*/ + + // Returns a new byte string representing the given data with the appropriate error correction + // codewords appended to it, based on this object's version and error correction level. + private addEccAndInterleave(data: Readonly>): Array { + const ver: int = this.version; + const ecl: QrCodeEcc = this.errorCorrectionLevel; + if (data.length != QrCode.getNumDataCodewords(ver, ecl)) + throw new RangeError("Invalid argument"); + + // Calculate parameter numbers + const numBlocks: int = QrCode.NUM_ERROR_CORRECTION_BLOCKS[ecl.ordinal]![ver]!; + const blockEccLen: int = QrCode.ECC_CODEWORDS_PER_BLOCK[ecl.ordinal]![ver]!; + const rawCodewords: int = Math.floor(QrCode.getNumRawDataModules(ver) / 8); + const numShortBlocks: int = numBlocks - (rawCodewords % numBlocks); + const shortBlockLen: int = Math.floor(rawCodewords / numBlocks); + + // Split data into blocks and append ECC to each block + let blocks: Array> = []; + const rsDiv: Array = QrCode.reedSolomonComputeDivisor(blockEccLen); + for (let i = 0, k = 0; i < numBlocks; i++) { + let dat: Array = data.slice( + k, + k + shortBlockLen - blockEccLen + (i < numShortBlocks ? 0 : 1), + ); + k += dat.length; + const ecc: Array = QrCode.reedSolomonComputeRemainder(dat, rsDiv); + if (i < numShortBlocks) dat.push(0); + blocks.push(dat.concat(ecc)); + } + + // Interleave (not concatenate) the bytes from every block into a single sequence + let result: Array = []; + for (let i = 0; i < blocks[0]!.length; i++) { + blocks.forEach((block, j) => { + // Skip the padding byte in short blocks + if (i != shortBlockLen - blockEccLen || j >= numShortBlocks) result.push(block[i]!); + }); + } + assert(result.length == rawCodewords); + return result; + } - // Draws the given sequence of 8-bit codewords (data and error correction) onto the entire - // data area of this QR Code. Function modules need to be marked off before this is called. - private drawCodewords(data: Readonly>): void { - if (data.length != Math.floor(QrCode.getNumRawDataModules(this.version) / 8)) - throw new RangeError("Invalid argument"); - let i: int = 0; // Bit index into the data - // Do the funny zigzag scan - for (let right = this.size - 1; right >= 1; right -= 2) { - // Index of right column in each column pair - if (right == 6) right = 5; - for (let vert = 0; vert < this.size; vert++) { - // Vertical counter - for (let j = 0; j < 2; j++) { - const x: int = right - j; // Actual x coordinate - const upward: boolean = ((right + 1) & 2) == 0; - const y: int = upward ? this.size - 1 - vert : vert; // Actual y coordinate - if (!this.isFunction[y][x] && i < data.length * 8) { - this.modules[y][x] = getBit(data[i >>> 3], 7 - (i & 7)); - i++; - } - // If this QR Code has any remainder bits (0 to 7), they were assigned as - // 0/false/light by the constructor and are left unchanged by this method + // Draws the given sequence of 8-bit codewords (data and error correction) onto the entire + // data area of this QR Code. Function modules need to be marked off before this is called. + private drawCodewords(data: Readonly>): void { + if (data.length != Math.floor(QrCode.getNumRawDataModules(this.version) / 8)) + throw new RangeError("Invalid argument"); + let i: int = 0; // Bit index into the data + // Do the funny zigzag scan + for (let right = this.size - 1; right >= 1; right -= 2) { + // Index of right column in each column pair + if (right == 6) right = 5; + for (let vert = 0; vert < this.size; vert++) { + // Vertical counter + for (let j = 0; j < 2; j++) { + const x: int = right - j; // Actual x coordinate + const upward: boolean = ((right + 1) & 2) == 0; + const y: int = upward ? this.size - 1 - vert : vert; // Actual y coordinate + if (!this.isFunction[y]![x]! && i < data.length * 8) { + this.modules[y]![x] = getBit(data[i >>> 3]!, 7 - (i & 7)); + i++; } + // If this QR Code has any remainder bits (0 to 7), they were assigned as + // 0/false/light by the constructor and are left unchanged by this method } } - assert(i == data.length * 8); } + assert(i == data.length * 8); + } - // XORs the codeword modules in this QR Code with the given mask pattern. - // The function modules must be marked and the codeword bits must be drawn - // before masking. Due to the arithmetic of XOR, calling applyMask() with - // the same mask value a second time will undo the mask. A final well-formed - // QR Code needs exactly one (not zero, two, etc.) mask applied. - private applyMask(mask: int): void { - if (mask < 0 || mask > 7) throw new RangeError("Mask value out of range"); - for (let y = 0; y < this.size; y++) { - for (let x = 0; x < this.size; x++) { - let invert: boolean; - switch (mask) { - case 0: - invert = (x + y) % 2 == 0; - break; - case 1: - invert = y % 2 == 0; - break; - case 2: - invert = x % 3 == 0; - break; - case 3: - invert = (x + y) % 3 == 0; - break; - case 4: - invert = (Math.floor(x / 3) + Math.floor(y / 2)) % 2 == 0; - break; - case 5: - invert = ((x * y) % 2) + ((x * y) % 3) == 0; - break; - case 6: - invert = (((x * y) % 2) + ((x * y) % 3)) % 2 == 0; - break; - case 7: - invert = (((x + y) % 2) + ((x * y) % 3)) % 2 == 0; - break; - default: - throw new Error("Unreachable"); - } - if (!this.isFunction[y][x] && invert) this.modules[y][x] = !this.modules[y][x]; + // XORs the codeword modules in this QR Code with the given mask pattern. + // The function modules must be marked and the codeword bits must be drawn + // before masking. Due to the arithmetic of XOR, calling applyMask() with + // the same mask value a second time will undo the mask. A final well-formed + // QR Code needs exactly one (not zero, two, etc.) mask applied. + private applyMask(mask: int): void { + if (mask < 0 || mask > 7) throw new RangeError("Mask value out of range"); + for (let y = 0; y < this.size; y++) { + for (let x = 0; x < this.size; x++) { + let invert: boolean; + switch (mask) { + case 0: + invert = (x + y) % 2 == 0; + break; + case 1: + invert = y % 2 == 0; + break; + case 2: + invert = x % 3 == 0; + break; + case 3: + invert = (x + y) % 3 == 0; + break; + case 4: + invert = (Math.floor(x / 3) + Math.floor(y / 2)) % 2 == 0; + break; + case 5: + invert = ((x * y) % 2) + ((x * y) % 3) == 0; + break; + case 6: + invert = (((x * y) % 2) + ((x * y) % 3)) % 2 == 0; + break; + case 7: + invert = (((x + y) % 2) + ((x * y) % 3)) % 2 == 0; + break; + default: + throw new Error("Unreachable"); } + if (!this.isFunction[y]![x]! && invert) this.modules[y]![x] = !this.modules[y]![x]!; } } + } - // Calculates and returns the penalty score based on state of this QR Code's current modules. - // This is used by the automatic mask choice algorithm to find the mask pattern that yields the lowest score. - private getPenaltyScore(): int { - let result: int = 0; + // Calculates and returns the penalty score based on state of this QR Code's current modules. + // This is used by the automatic mask choice algorithm to find the mask pattern that yields the lowest score. + private getPenaltyScore(): int { + let result: int = 0; - // Adjacent modules in row having same color, and finder-like patterns - for (let y = 0; y < this.size; y++) { - let runColor = false; - let runX = 0; - let runHistory = [0, 0, 0, 0, 0, 0, 0]; - for (let x = 0; x < this.size; x++) { - if (this.modules[y][x] == runColor) { - runX++; - if (runX == 5) result += QrCode.PENALTY_N1; - else if (runX > 5) result++; - } else { - this.finderPenaltyAddHistory(runX, runHistory); - if (!runColor) - result += this.finderPenaltyCountPatterns(runHistory) * QrCode.PENALTY_N3; - runColor = this.modules[y][x]; - runX = 1; - } - } - result += - this.finderPenaltyTerminateAndCount(runColor, runX, runHistory) * QrCode.PENALTY_N3; - } - // Adjacent modules in column having same color, and finder-like patterns + // Adjacent modules in row having same color, and finder-like patterns + for (let y = 0; y < this.size; y++) { + let runColor = false; + let runX = 0; + let runHistory = [0, 0, 0, 0, 0, 0, 0]; for (let x = 0; x < this.size; x++) { - let runColor = false; - let runY = 0; - let runHistory = [0, 0, 0, 0, 0, 0, 0]; - for (let y = 0; y < this.size; y++) { - if (this.modules[y][x] == runColor) { - runY++; - if (runY == 5) result += QrCode.PENALTY_N1; - else if (runY > 5) result++; - } else { - this.finderPenaltyAddHistory(runY, runHistory); - if (!runColor) - result += this.finderPenaltyCountPatterns(runHistory) * QrCode.PENALTY_N3; - runColor = this.modules[y][x]; - runY = 1; - } + if (this.modules[y]![x] === runColor) { + runX++; + if (runX == 5) result += QrCode.PENALTY_N1; + else if (runX > 5) result++; + } else { + this.finderPenaltyAddHistory(runX, runHistory); + if (!runColor) result += this.finderPenaltyCountPatterns(runHistory) * QrCode.PENALTY_N3; + runColor = this.modules[y]![x]!; + runX = 1; } - result += - this.finderPenaltyTerminateAndCount(runColor, runY, runHistory) * QrCode.PENALTY_N3; } - - // 2*2 blocks of modules having same color - for (let y = 0; y < this.size - 1; y++) { - for (let x = 0; x < this.size - 1; x++) { - const color: boolean = this.modules[y][x]; - if ( - color == this.modules[y][x + 1] && - color == this.modules[y + 1][x] && - color == this.modules[y + 1][x + 1] - ) - result += QrCode.PENALTY_N2; + result += this.finderPenaltyTerminateAndCount(runColor, runX, runHistory) * QrCode.PENALTY_N3; + } + // Adjacent modules in column having same color, and finder-like patterns + for (let x = 0; x < this.size; x++) { + let runColor = false; + let runY = 0; + let runHistory = [0, 0, 0, 0, 0, 0, 0]; + for (let y = 0; y < this.size; y++) { + if (this.modules[y]![x] === runColor) { + runY++; + if (runY == 5) result += QrCode.PENALTY_N1; + else if (runY > 5) result++; + } else { + this.finderPenaltyAddHistory(runY, runHistory); + if (!runColor) result += this.finderPenaltyCountPatterns(runHistory) * QrCode.PENALTY_N3; + runColor = this.modules[y]![x]!; + runY = 1; } } - - // Balance of dark and light modules - let dark: int = 0; - for (const row of this.modules) - dark = row.reduce((sum, color) => sum + (color ? 1 : 0), dark); - const total: int = this.size * this.size; // Note that size is odd, so dark/total != 1/2 - // Compute the smallest integer k >= 0 such that (45-5k)% <= dark/total <= (55+5k)% - const k: int = Math.ceil(Math.abs(dark * 20 - total * 10) / total) - 1; - assert(0 <= k && k <= 9); - result += k * QrCode.PENALTY_N4; - assert(0 <= result && result <= 2568888); // Non-tight upper bound based on default values of PENALTY_N1, ..., N4 - return result; - } - - /*-- Private helper functions --*/ - - // Returns an ascending list of positions of alignment patterns for this version number. - // Each position is in the range [0,177), and are used on both the x and y axes. - // This could be implemented as lookup table of 40 variable-length lists of integers. - private getAlignmentPatternPositions(): Array { - if (this.version == 1) return []; - else { - const numAlign: int = Math.floor(this.version / 7) + 2; - const step: int = - this.version == 32 ? 26 : Math.ceil((this.version * 4 + 4) / (numAlign * 2 - 2)) * 2; - let result: Array = [6]; - for (let pos = this.size - 7; result.length < numAlign; pos -= step) - result.splice(1, 0, pos); - return result; + result += this.finderPenaltyTerminateAndCount(runColor, runY, runHistory) * QrCode.PENALTY_N3; + } + + // 2*2 blocks of modules having same color + for (let y = 0; y < this.size - 1; y++) { + for (let x = 0; x < this.size - 1; x++) { + const color: boolean = this.modules[y]![x]!; + if ( + color == this.modules[y]![x + 1]! && + color == this.modules[y + 1]![x]! && + color == this.modules[y + 1]![x + 1]! + ) + result += QrCode.PENALTY_N2; } } - // Returns the number of data bits that can be stored in a QR Code of the given version number, after - // all function modules are excluded. This includes remainder bits, so it might not be a multiple of 8. - // The result is in the range [208, 29648]. This could be implemented as a 40-entry lookup table. - private static getNumRawDataModules(ver: int): int { - if (ver < QrCode.MIN_VERSION || ver > QrCode.MAX_VERSION) - throw new RangeError("Version number out of range"); - let result: int = (16 * ver + 128) * ver + 64; - if (ver >= 2) { - const numAlign: int = Math.floor(ver / 7) + 2; - result -= (25 * numAlign - 10) * numAlign - 55; - if (ver >= 7) result -= 36; - } - assert(208 <= result && result <= 29648); + // Balance of dark and light modules + let dark: int = 0; + for (const row of this.modules) dark = row.reduce((sum, color) => sum + (color ? 1 : 0), dark); + const total: int = this.size * this.size; // Note that size is odd, so dark/total != 1/2 + // Compute the smallest integer k >= 0 such that (45-5k)% <= dark/total <= (55+5k)% + const k: int = Math.ceil(Math.abs(dark * 20 - total * 10) / total) - 1; + assert(0 <= k && k <= 9); + result += k * QrCode.PENALTY_N4; + assert(0 <= result && result <= 2568888); // Non-tight upper bound based on default values of PENALTY_N1, ..., N4 + return result; + } + + /*-- Private helper functions --*/ + + // Returns an ascending list of positions of alignment patterns for this version number. + // Each position is in the range [0,177), and are used on both the x and y axes. + // This could be implemented as lookup table of 40 variable-length lists of integers. + private getAlignmentPatternPositions(): Array { + if (this.version == 1) return []; + else { + const numAlign: int = Math.floor(this.version / 7) + 2; + const step: int = + this.version == 32 ? 26 : Math.ceil((this.version * 4 + 4) / (numAlign * 2 - 2)) * 2; + let result: Array = [6]; + for (let pos = this.size - 7; result.length < numAlign; pos -= step) result.splice(1, 0, pos); return result; } + } - // Returns the number of 8-bit data (i.e. not error correction) codewords contained in any - // QR Code of the given version number and error correction level, with remainder bits discarded. - // This stateless pure function could be implemented as a (40*4)-cell lookup table. - private static getNumDataCodewords(ver: int, ecl: QrCode.Ecc): int { - return ( - Math.floor(QrCode.getNumRawDataModules(ver) / 8) - - QrCode.ECC_CODEWORDS_PER_BLOCK[ecl.ordinal][ver] * - QrCode.NUM_ERROR_CORRECTION_BLOCKS[ecl.ordinal][ver] - ); - } + // Returns the number of data bits that can be stored in a QR Code of the given version number, after + // all function modules are excluded. This includes remainder bits, so it might not be a multiple of 8. + // The result is in the range [208, 29648]. This could be implemented as a 40-entry lookup table. + private static getNumRawDataModules(ver: int): int { + if (ver < QrCode.MIN_VERSION || ver > QrCode.MAX_VERSION) + throw new RangeError("Version number out of range"); + let result: int = (16 * ver + 128) * ver + 64; + if (ver >= 2) { + const numAlign: int = Math.floor(ver / 7) + 2; + result -= (25 * numAlign - 10) * numAlign - 55; + if (ver >= 7) result -= 36; + } + assert(208 <= result && result <= 29648); + return result; + } - // Returns a Reed-Solomon ECC generator polynomial for the given degree. This could be - // implemented as a lookup table over all possible parameter values, instead of as an algorithm. - private static reedSolomonComputeDivisor(degree: int): Array { - if (degree < 1 || degree > 255) throw new RangeError("Degree out of range"); - // Polynomial coefficients are stored from highest to lowest power, excluding the leading term which is always 1. - // For example the polynomial x^3 + 255x^2 + 8x + 93 is stored as the uint8 array [255, 8, 93]. - let result: Array = []; - for (let i = 0; i < degree - 1; i++) result.push(0); - result.push(1); // Start off with the monomial x^0 - - // Compute the product polynomial (x - r^0) * (x - r^1) * (x - r^2) * ... * (x - r^{degree-1}), - // and drop the highest monomial term which is always 1x^degree. - // Note that r = 0x02, which is a generator element of this field GF(2^8/0x11D). - let root = 1; - for (let i = 0; i < degree; i++) { - // Multiply the current product by (x - r^i) - for (let j = 0; j < result.length; j++) { - result[j] = QrCode.reedSolomonMultiply(result[j], root); - if (j + 1 < result.length) result[j] ^= result[j + 1]; - } - root = QrCode.reedSolomonMultiply(root, 0x02); - } - return result; - } + // Returns the number of 8-bit data (i.e. not error correction) codewords contained in any + // QR Code of the given version number and error correction level, with remainder bits discarded. + // This stateless pure function could be implemented as a (40*4)-cell lookup table. + private static getNumDataCodewords(ver: int, ecl: QrCodeEcc): int { + return ( + Math.floor(QrCode.getNumRawDataModules(ver) / 8) - + QrCode.ECC_CODEWORDS_PER_BLOCK[ecl.ordinal]![ver]! * + QrCode.NUM_ERROR_CORRECTION_BLOCKS[ecl.ordinal]![ver]! + ); + } - // Returns the Reed-Solomon error correction codeword for the given data and divisor polynomials. - private static reedSolomonComputeRemainder( - data: Readonly>, - divisor: Readonly>, - ): Array { - let result: Array = divisor.map((_) => 0); - for (const b of data) { - // Polynomial division - const factor: byte = b ^ (result.shift() as byte); - result.push(0); - divisor.forEach((coef, i) => (result[i] ^= QrCode.reedSolomonMultiply(coef, factor))); + // Returns a Reed-Solomon ECC generator polynomial for the given degree. This could be + // implemented as a lookup table over all possible parameter values, instead of as an algorithm. + private static reedSolomonComputeDivisor(degree: int): Array { + if (degree < 1 || degree > 255) throw new RangeError("Degree out of range"); + // Polynomial coefficients are stored from highest to lowest power, excluding the leading term which is always 1. + // For example the polynomial x^3 + 255x^2 + 8x + 93 is stored as the uint8 array [255, 8, 93]. + let result: Array = []; + for (let i = 0; i < degree - 1; i++) result.push(0); + result.push(1); // Start off with the monomial x^0 + + // Compute the product polynomial (x - r^0) * (x - r^1) * (x - r^2) * ... * (x - r^{degree-1}), + // and drop the highest monomial term which is always 1x^degree. + // Note that r = 0x02, which is a generator element of this field GF(2^8/0x11D). + let root = 1; + for (let i = 0; i < degree; i++) { + // Multiply the current product by (x - r^i) + for (let j = 0; j < result.length; j++) { + result[j] = QrCode.reedSolomonMultiply(result[j]!, root); + if (j + 1 < result.length) result[j]! ^= result[j + 1]!; } - return result; + root = QrCode.reedSolomonMultiply(root, 0x02); } + return result; + } - // Returns the product of the two given field elements modulo GF(2^8/0x11D). The arguments and result - // are unsigned 8-bit integers. This could be implemented as a lookup table of 256*256 entries of uint8. - private static reedSolomonMultiply(x: byte, y: byte): byte { - if (x >>> 8 != 0 || y >>> 8 != 0) throw new RangeError("Byte out of range"); - // Russian peasant multiplication - let z: int = 0; - for (let i = 7; i >= 0; i--) { - z = (z << 1) ^ ((z >>> 7) * 0x11d); - z ^= ((y >>> i) & 1) * x; - } - assert(z >>> 8 == 0); - return z as byte; - } + // Returns the Reed-Solomon error correction codeword for the given data and divisor polynomials. + private static reedSolomonComputeRemainder( + data: Readonly>, + divisor: Readonly>, + ): Array { + let result: Array = divisor.map((_) => 0); + for (const b of data) { + // Polynomial division + const factor: byte = b ^ (result.shift() as byte); + result.push(0); + divisor.forEach((coef, i) => (result[i]! ^= QrCode.reedSolomonMultiply(coef, factor))); + } + return result; + } - // Can only be called immediately after a light run is added, and - // returns either 0, 1, or 2. A helper function for getPenaltyScore(). - private finderPenaltyCountPatterns(runHistory: Readonly>): int { - const n: int = runHistory[1]; - assert(n <= this.size * 3); - const core: boolean = - n > 0 && - runHistory[2] == n && - runHistory[3] == n * 3 && - runHistory[4] == n && - runHistory[5] == n; - return ( - (core && runHistory[0] >= n * 4 && runHistory[6] >= n ? 1 : 0) + - (core && runHistory[6] >= n * 4 && runHistory[0] >= n ? 1 : 0) - ); - } + // Returns the product of the two given field elements modulo GF(2^8/0x11D). The arguments and result + // are unsigned 8-bit integers. This could be implemented as a lookup table of 256*256 entries of uint8. + private static reedSolomonMultiply(x: byte, y: byte): byte { + if (x >>> 8 != 0 || y >>> 8 != 0) throw new RangeError("Byte out of range"); + // Russian peasant multiplication + let z: int = 0; + for (let i = 7; i >= 0; i--) { + z = (z << 1) ^ ((z >>> 7) * 0x11d); + z ^= ((y >>> i) & 1) * x; + } + assert(z >>> 8 == 0); + return z as byte; + } - // Must be called at the end of a line (row or column) of modules. A helper function for getPenaltyScore(). - private finderPenaltyTerminateAndCount( - currentRunColor: boolean, - currentRunLength: int, - runHistory: Array, - ): int { - if (currentRunColor) { - // Terminate dark run - this.finderPenaltyAddHistory(currentRunLength, runHistory); - currentRunLength = 0; - } - currentRunLength += this.size; // Add light border to final run + // Can only be called immediately after a light run is added, and + // returns either 0, 1, or 2. A helper function for getPenaltyScore(). + private finderPenaltyCountPatterns(runHistory: Readonly>): int { + const n: int = runHistory[1]!; + assert(n <= this.size * 3); + const core: boolean = + n > 0 && + runHistory[2] === n && + runHistory[3] === n * 3 && + runHistory[4] === n && + runHistory[5] === n; + return ( + (core && runHistory[0]! >= n * 4 && runHistory[6]! >= n ? 1 : 0) + + (core && runHistory[6]! >= n * 4 && runHistory[0]! >= n ? 1 : 0) + ); + } + + // Must be called at the end of a line (row or column) of modules. A helper function for getPenaltyScore(). + private finderPenaltyTerminateAndCount( + currentRunColor: boolean, + currentRunLength: int, + runHistory: Array, + ): int { + if (currentRunColor) { + // Terminate dark run this.finderPenaltyAddHistory(currentRunLength, runHistory); - return this.finderPenaltyCountPatterns(runHistory); + currentRunLength = 0; } + currentRunLength += this.size; // Add light border to final run + this.finderPenaltyAddHistory(currentRunLength, runHistory); + return this.finderPenaltyCountPatterns(runHistory); + } - // Pushes the given value to the front and drops the last value. A helper function for getPenaltyScore(). - private finderPenaltyAddHistory(currentRunLength: int, runHistory: Array): void { - if (runHistory[0] == 0) currentRunLength += this.size; // Add light border to initial run - runHistory.pop(); - runHistory.unshift(currentRunLength); - } + // Pushes the given value to the front and drops the last value. A helper function for getPenaltyScore(). + private finderPenaltyAddHistory(currentRunLength: int, runHistory: Array): void { + if (runHistory[0] === 0) currentRunLength += this.size; // Add light border to initial run + runHistory.pop(); + runHistory.unshift(currentRunLength); + } - /*-- Constants and tables --*/ - - // The minimum version number supported in the QR Code Model 2 standard. - public static readonly MIN_VERSION: int = 1; - // The maximum version number supported in the QR Code Model 2 standard. - public static readonly MAX_VERSION: int = 40; - - // For use in getPenaltyScore(), when evaluating which mask is best. - private static readonly PENALTY_N1: int = 3; - private static readonly PENALTY_N2: int = 3; - private static readonly PENALTY_N3: int = 40; - private static readonly PENALTY_N4: int = 10; - - private static readonly ECC_CODEWORDS_PER_BLOCK: Array> = [ - // Version: (note that index 0 is for padding, and is set to an illegal value) - //0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 Error correction level - [ - -1, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24, 26, 30, 22, 24, 28, 30, 28, 28, 28, 28, - 30, 30, 26, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, - ], // Low - [ - -1, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26, 26, 26, 26, 28, - 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, - ], // Medium - [ - -1, 13, 22, 18, 26, 18, 24, 18, 22, 20, 24, 28, 26, 24, 20, 30, 24, 28, 28, 26, 30, 28, 30, - 30, 30, 30, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, - ], // Quartile - [ - -1, 17, 28, 22, 16, 22, 28, 26, 26, 24, 28, 24, 28, 22, 24, 24, 30, 28, 28, 26, 28, 30, 24, - 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, - ], // High - ]; - - private static readonly NUM_ERROR_CORRECTION_BLOCKS: Array> = [ - // Version: (note that index 0 is for padding, and is set to an illegal value) - //0, 1, 2, 3, 4, 5, 6, 7, 8, 9,10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 Error correction level - [ - -1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 4, 6, 6, 6, 6, 7, 8, 8, 9, 9, 10, 12, 12, 12, 13, - 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 24, 25, - ], // Low - [ - -1, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16, 17, 17, 18, 20, 21, - 23, 25, 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45, 47, 49, - ], // Medium - [ - -1, 1, 1, 2, 2, 4, 4, 6, 6, 8, 8, 8, 10, 12, 16, 12, 17, 16, 18, 21, 20, 23, 23, 25, 27, 29, - 34, 34, 35, 38, 40, 43, 45, 48, 51, 53, 56, 59, 62, 65, 68, - ], // Quartile - [ - -1, 1, 1, 2, 4, 4, 4, 5, 6, 8, 8, 11, 11, 16, 16, 18, 16, 19, 21, 25, 25, 25, 34, 30, 32, - 35, 37, 40, 42, 45, 48, 51, 54, 57, 60, 63, 66, 70, 74, 77, 81, - ], // High - ]; - } - - // Appends the given number of low-order bits of the given value - // to the given buffer. Requires 0 <= len <= 31 and 0 <= val < 2^len. - function appendBits(val: int, len: int, bb: Array): void { - if (len < 0 || len > 31 || val >>> len != 0) throw new RangeError("Value out of range"); - for ( - let i = len - 1; - i >= 0; - i-- // Append bit by bit - ) - bb.push((val >>> i) & 1); - } - - // Returns true iff the i'th bit of x is set to 1. - function getBit(x: int, i: int): boolean { - return ((x >>> i) & 1) != 0; - } - - // Throws an exception if the given condition is false. - function assert(cond: boolean): void { - if (!cond) throw new Error("Assertion error"); - } - - /*---- Data segment class ----*/ - - /* - * A segment of character/binary/control data in a QR Code symbol. - * Instances of this class are immutable. - * The mid-level way to create a segment is to take the payload data - * and call a static factory function such as QrSegment.makeNumeric(). - * The low-level way to create a segment is to custom-make the bit buffer - * and call the QrSegment() constructor with appropriate values. - * This segment class imposes no length restrictions, but QR Codes have restrictions. - * Even in the most favorable conditions, a QR Code can only hold 7089 characters of data. - * Any segment longer than this is meaningless for the purpose of generating QR Codes. - */ - export class QrSegment { - /*-- Static factory functions (mid level) --*/ - - // Returns a segment representing the given binary data encoded in - // byte mode. All input byte arrays are acceptable. Any text string - // can be converted to UTF-8 bytes and encoded as a byte mode segment. - public static makeBytes(data: Readonly>): QrSegment { - let bb: Array = []; - for (const b of data) appendBits(b, 8, bb); - return new QrSegment(QrSegment.Mode.BYTE, data.length, bb); - } + /*-- Constants and tables --*/ + + // The minimum version number supported in the QR Code Model 2 standard. + public static readonly MIN_VERSION: int = 1; + // The maximum version number supported in the QR Code Model 2 standard. + public static readonly MAX_VERSION: int = 40; + + // For use in getPenaltyScore(), when evaluating which mask is best. + private static readonly PENALTY_N1: int = 3; + private static readonly PENALTY_N2: int = 3; + private static readonly PENALTY_N3: int = 40; + private static readonly PENALTY_N4: int = 10; + + private static readonly ECC_CODEWORDS_PER_BLOCK: Array> = [ + // Version: (note that index 0 is for padding, and is set to an illegal value) + //0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 Error correction level + [ + -1, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24, 26, 30, 22, 24, 28, 30, 28, 28, 28, 28, 30, + 30, 26, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, + ], // Low + [ + -1, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26, 26, 26, 26, 28, + 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, + ], // Medium + [ + -1, 13, 22, 18, 26, 18, 24, 18, 22, 20, 24, 28, 26, 24, 20, 30, 24, 28, 28, 26, 30, 28, 30, + 30, 30, 30, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, + ], // Quartile + [ + -1, 17, 28, 22, 16, 22, 28, 26, 26, 24, 28, 24, 28, 22, 24, 24, 30, 28, 28, 26, 28, 30, 24, + 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, + ], // High + ]; + + private static readonly NUM_ERROR_CORRECTION_BLOCKS: Array> = [ + // Version: (note that index 0 is for padding, and is set to an illegal value) + //0, 1, 2, 3, 4, 5, 6, 7, 8, 9,10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 Error correction level + [ + -1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 4, 6, 6, 6, 6, 7, 8, 8, 9, 9, 10, 12, 12, 12, 13, + 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 24, 25, + ], // Low + [ + -1, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16, 17, 17, 18, 20, 21, 23, + 25, 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45, 47, 49, + ], // Medium + [ + -1, 1, 1, 2, 2, 4, 4, 6, 6, 8, 8, 8, 10, 12, 16, 12, 17, 16, 18, 21, 20, 23, 23, 25, 27, 29, + 34, 34, 35, 38, 40, 43, 45, 48, 51, 53, 56, 59, 62, 65, 68, + ], // Quartile + [ + -1, 1, 1, 2, 4, 4, 4, 5, 6, 8, 8, 11, 11, 16, 16, 18, 16, 19, 21, 25, 25, 25, 34, 30, 32, 35, + 37, 40, 42, 45, 48, 51, 54, 57, 60, 63, 66, 70, 74, 77, 81, + ], // High + ]; +} - // Returns a segment representing the given string of decimal digits encoded in numeric mode. - public static makeNumeric(digits: string): QrSegment { - if (!QrSegment.isNumeric(digits)) - throw new RangeError("String contains non-numeric characters"); - let bb: Array = []; - for (let i = 0; i < digits.length; ) { - // Consume up to 3 digits per iteration - const n: int = Math.min(digits.length - i, 3); - appendBits(parseInt(digits.substring(i, i + n), 10), n * 3 + 1, bb); - i += n; - } - return new QrSegment(QrSegment.Mode.NUMERIC, digits.length, bb); - } +// Appends the given number of low-order bits of the given value +// to the given buffer. Requires 0 <= len <= 31 and 0 <= val < 2^len. +function appendBits(val: int, len: int, bb: Array): void { + if (len < 0 || len > 31 || val >>> len != 0) throw new RangeError("Value out of range"); + for ( + let i = len - 1; + i >= 0; + i-- // Append bit by bit + ) + bb.push((val >>> i) & 1); +} - // Returns a segment representing the given text string encoded in alphanumeric mode. - // The characters allowed are: 0 to 9, A to Z (uppercase only), space, - // dollar, percent, asterisk, plus, hyphen, period, slash, colon. - public static makeAlphanumeric(text: string): QrSegment { - if (!QrSegment.isAlphanumeric(text)) - throw new RangeError("String contains unencodable characters in alphanumeric mode"); - let bb: Array = []; - let i: int; - for (i = 0; i + 2 <= text.length; i += 2) { - // Process groups of 2 - let temp: int = QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i)) * 45; - temp += QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i + 1)); - appendBits(temp, 11, bb); - } - if (i < text.length) - // 1 character remaining - appendBits(QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i)), 6, bb); - return new QrSegment(QrSegment.Mode.ALPHANUMERIC, text.length, bb); - } +// Returns true iff the i'th bit of x is set to 1. +function getBit(x: int, i: int): boolean { + return ((x >>> i) & 1) != 0; +} - // Returns a new mutable list of zero or more segments to represent the given Unicode text string. - // The result may use various segment modes and switch modes to optimize the length of the bit stream. - public static makeSegments(text: string): Array { - // Select the most efficient segment encoding automatically - if (text == "") return []; - else if (QrSegment.isNumeric(text)) return [QrSegment.makeNumeric(text)]; - else if (QrSegment.isAlphanumeric(text)) return [QrSegment.makeAlphanumeric(text)]; - else return [QrSegment.makeBytes(QrSegment.toUtf8ByteArray(text))]; - } +// Throws an exception if the given condition is false. +function assert(cond: boolean): void { + if (!cond) throw new Error("Assertion error"); +} - // Returns a segment representing an Extended Channel Interpretation - // (ECI) designator with the given assignment value. - public static makeEci(assignVal: int): QrSegment { - let bb: Array = []; - if (assignVal < 0) throw new RangeError("ECI assignment value out of range"); - else if (assignVal < 1 << 7) appendBits(assignVal, 8, bb); - else if (assignVal < 1 << 14) { - appendBits(0b10, 2, bb); - appendBits(assignVal, 14, bb); - } else if (assignVal < 1000000) { - appendBits(0b110, 3, bb); - appendBits(assignVal, 21, bb); - } else throw new RangeError("ECI assignment value out of range"); - return new QrSegment(QrSegment.Mode.ECI, 0, bb); - } +/*---- Data segment class ----*/ - // Tests whether the given string can be encoded as a segment in numeric mode. - // A string is encodable iff each character is in the range 0 to 9. - public static isNumeric(text: string): boolean { - return QrSegment.NUMERIC_REGEX.test(text); - } +/* + * A segment of character/binary/control data in a QR Code symbol. + * Instances of this class are immutable. + * The mid-level way to create a segment is to take the payload data + * and call a static factory function such as QrSegment.makeNumeric(). + * The low-level way to create a segment is to custom-make the bit buffer + * and call the QrSegment() constructor with appropriate values. + * This segment class imposes no length restrictions, but QR Codes have restrictions. + * Even in the most favorable conditions, a QR Code can only hold 7089 characters of data. + * Any segment longer than this is meaningless for the purpose of generating QR Codes. + */ +export class QrSegment { + public static Mode: typeof QrSegmentMode; + + /*-- Static factory functions (mid level) --*/ + + // Returns a segment representing the given binary data encoded in + // byte mode. All input byte arrays are acceptable. Any text string + // can be converted to UTF-8 bytes and encoded as a byte mode segment. + public static makeBytes(data: Readonly>): QrSegment { + let bb: Array = []; + for (const b of data) appendBits(b, 8, bb); + return new QrSegment(QrSegment.Mode.BYTE, data.length, bb); + } - // Tests whether the given string can be encoded as a segment in alphanumeric mode. - // A string is encodable iff each character is in the following set: 0 to 9, A to Z - // (uppercase only), space, dollar, percent, asterisk, plus, hyphen, period, slash, colon. - public static isAlphanumeric(text: string): boolean { - return QrSegment.ALPHANUMERIC_REGEX.test(text); - } + // Returns a segment representing the given string of decimal digits encoded in numeric mode. + public static makeNumeric(digits: string): QrSegment { + if (!QrSegment.isNumeric(digits)) + throw new RangeError("String contains non-numeric characters"); + let bb: Array = []; + for (let i = 0; i < digits.length; ) { + // Consume up to 3 digits per iteration + const n: int = Math.min(digits.length - i, 3); + appendBits(parseInt(digits.substring(i, i + n), 10), n * 3 + 1, bb); + i += n; + } + return new QrSegment(QrSegment.Mode.NUMERIC, digits.length, bb); + } - /*-- Constructor (low level) and fields --*/ - - // Creates a new QR Code segment with the given attributes and data. - // The character count (numChars) must agree with the mode and the bit buffer length, - // but the constraint isn't checked. The given bit buffer is cloned and stored. - public constructor( - // The mode indicator of this segment. - public readonly mode: QrSegment.Mode, - - // The length of this segment's unencoded data. Measured in characters for - // numeric/alphanumeric/kanji mode, bytes for byte mode, and 0 for ECI mode. - // Always zero or positive. Not the same as the data's bit length. - public readonly numChars: int, - - // The data bits of this segment. Accessed through getData(). - private readonly bitData: Array, - ) { - if (numChars < 0) throw new RangeError("Invalid argument"); - this.bitData = bitData.slice(); // Make defensive copy - } + // Returns a segment representing the given text string encoded in alphanumeric mode. + // The characters allowed are: 0 to 9, A to Z (uppercase only), space, + // dollar, percent, asterisk, plus, hyphen, period, slash, colon. + public static makeAlphanumeric(text: string): QrSegment { + if (!QrSegment.isAlphanumeric(text)) + throw new RangeError("String contains unencodable characters in alphanumeric mode"); + let bb: Array = []; + let i: int; + for (i = 0; i + 2 <= text.length; i += 2) { + // Process groups of 2 + let temp: int = QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i)) * 45; + temp += QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i + 1)); + appendBits(temp, 11, bb); + } + if (i < text.length) + // 1 character remaining + appendBits(QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i)), 6, bb); + return new QrSegment(QrSegment.Mode.ALPHANUMERIC, text.length, bb); + } - /*-- Methods --*/ + // Returns a new mutable list of zero or more segments to represent the given Unicode text string. + // The result may use various segment modes and switch modes to optimize the length of the bit stream. + public static makeSegments(text: string): Array { + // Select the most efficient segment encoding automatically + if (text == "") return []; + else if (QrSegment.isNumeric(text)) return [QrSegment.makeNumeric(text)]; + else if (QrSegment.isAlphanumeric(text)) return [QrSegment.makeAlphanumeric(text)]; + else return [QrSegment.makeBytes(QrSegment.toUtf8ByteArray(text))]; + } - // Returns a new copy of the data bits of this segment. - public getData(): Array { - return this.bitData.slice(); // Make defensive copy - } + // Returns a segment representing an Extended Channel Interpretation + // (ECI) designator with the given assignment value. + public static makeEci(assignVal: int): QrSegment { + let bb: Array = []; + if (assignVal < 0) throw new RangeError("ECI assignment value out of range"); + else if (assignVal < 1 << 7) appendBits(assignVal, 8, bb); + else if (assignVal < 1 << 14) { + appendBits(0b10, 2, bb); + appendBits(assignVal, 14, bb); + } else if (assignVal < 1000000) { + appendBits(0b110, 3, bb); + appendBits(assignVal, 21, bb); + } else throw new RangeError("ECI assignment value out of range"); + return new QrSegment(QrSegment.Mode.ECI, 0, bb); + } - // (Package-private) Calculates and returns the number of bits needed to encode the given segments at - // the given version. The result is infinity if a segment has too many characters to fit its length field. - public static getTotalBits(segs: Readonly>, version: int): number { - let result: number = 0; - for (const seg of segs) { - const ccbits: int = seg.mode.numCharCountBits(version); - if (seg.numChars >= 1 << ccbits) return Infinity; // The segment's length doesn't fit the field's bit width - result += 4 + ccbits + seg.bitData.length; - } - return result; + // Tests whether the given string can be encoded as a segment in numeric mode. + // A string is encodable iff each character is in the range 0 to 9. + public static isNumeric(text: string): boolean { + return QrSegment.NUMERIC_REGEX.test(text); + } + + // Tests whether the given string can be encoded as a segment in alphanumeric mode. + // A string is encodable iff each character is in the following set: 0 to 9, A to Z + // (uppercase only), space, dollar, percent, asterisk, plus, hyphen, period, slash, colon. + public static isAlphanumeric(text: string): boolean { + return QrSegment.ALPHANUMERIC_REGEX.test(text); + } + + /*-- Constructor (low level) and fields --*/ + + public readonly mode: QrSegmentMode; + public readonly numChars: int; + private readonly bitData: Array; + + // Creates a new QR Code segment with the given attributes and data. + // The character count (numChars) must agree with the mode and the bit buffer length, + // but the constraint isn't checked. The given bit buffer is cloned and stored. + public constructor( + // The mode indicator of this segment. + mode: QrSegmentMode, + + // The length of this segment's unencoded data. Measured in characters for + // numeric/alphanumeric/kanji mode, bytes for byte mode, and 0 for ECI mode. + // Always zero or positive. Not the same as the data's bit length. + numChars: int, + + // The data bits of this segment. Accessed through getData(). + bitData: Array, + ) { + this.mode = mode; + this.numChars = numChars; + if (numChars < 0) throw new RangeError("Invalid argument"); + this.bitData = bitData.slice(); // Make defensive copy + } + + /*-- Methods --*/ + + // Returns a new copy of the data bits of this segment. + public getData(): Array { + return this.bitData.slice(); // Make defensive copy + } + + // (Package-private) Calculates and returns the number of bits needed to encode the given segments at + // the given version. The result is infinity if a segment has too many characters to fit its length field. + public static getTotalBits(segs: Readonly>, version: int): number { + let result: number = 0; + for (const seg of segs) { + const ccbits: int = seg.mode.numCharCountBits(version); + if (seg.numChars >= 1 << ccbits) return Infinity; // The segment's length doesn't fit the field's bit width + result += 4 + ccbits + seg.bitData.length; } + return result; + } - // Returns a new array of bytes representing the given string encoded in UTF-8. - private static toUtf8ByteArray(str: string): Array { - str = encodeURI(str); - let result: Array = []; - for (let i = 0; i < str.length; i++) { - if (str.charAt(i) != "%") result.push(str.charCodeAt(i)); - else { - result.push(parseInt(str.substring(i + 1, i + 3), 16)); - i += 2; - } + // Returns a new array of bytes representing the given string encoded in UTF-8. + private static toUtf8ByteArray(str: string): Array { + str = encodeURI(str); + let result: Array = []; + for (let i = 0; i < str.length; i++) { + if (str.charAt(i) != "%") result.push(str.charCodeAt(i)); + else { + result.push(parseInt(str.substring(i + 1, i + 3), 16)); + i += 2; } - return result; } + return result; + } - /*-- Constants --*/ + /*-- Constants --*/ - // Describes precisely all strings that are encodable in numeric mode. - private static readonly NUMERIC_REGEX: RegExp = /^[0-9]*$/; + // Describes precisely all strings that are encodable in numeric mode. + private static readonly NUMERIC_REGEX: RegExp = /^[0-9]*$/; - // Describes precisely all strings that are encodable in alphanumeric mode. - private static readonly ALPHANUMERIC_REGEX: RegExp = /^[A-Z0-9 $%*+.\/:-]*$/; + // Describes precisely all strings that are encodable in alphanumeric mode. + private static readonly ALPHANUMERIC_REGEX: RegExp = /^[A-Z0-9 $%*+.\/:-]*$/; - // The set of all legal characters in alphanumeric mode, - // where each character value maps to the index in the string. - private static readonly ALPHANUMERIC_CHARSET: string = - "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:"; - } + // The set of all legal characters in alphanumeric mode, + // where each character value maps to the index in the string. + private static readonly ALPHANUMERIC_CHARSET: string = + "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:"; } /*---- Public helper enumeration ----*/ -namespace qrcodegen.QrCode { - type int = number; +class QrCodeEcc { + /*-- Constants --*/ - /* - * The error correction level in a QR Code symbol. Immutable. - */ - export class Ecc { - /*-- Constants --*/ + public static readonly LOW = new QrCodeEcc(0, 1); // The QR Code can tolerate about 7% erroneous codewords + public static readonly MEDIUM = new QrCodeEcc(1, 0); // The QR Code can tolerate about 15% erroneous codewords + public static readonly QUARTILE = new QrCodeEcc(2, 3); // The QR Code can tolerate about 25% erroneous codewords + public static readonly HIGH = new QrCodeEcc(3, 2); // The QR Code can tolerate about 30% erroneous codewords - public static readonly LOW = new Ecc(0, 1); // The QR Code can tolerate about 7% erroneous codewords - public static readonly MEDIUM = new Ecc(1, 0); // The QR Code can tolerate about 15% erroneous codewords - public static readonly QUARTILE = new Ecc(2, 3); // The QR Code can tolerate about 25% erroneous codewords - public static readonly HIGH = new Ecc(3, 2); // The QR Code can tolerate about 30% erroneous codewords + public readonly ordinal: int; + public readonly formatBits: int; - /*-- Constructor and fields --*/ + /*-- Constructor and fields --*/ - private constructor( - // In the range 0 to 3 (unsigned 2-bit integer). - public readonly ordinal: int, - // (Package-private) In the range 0 to 3 (unsigned 2-bit integer). - public readonly formatBits: int, - ) {} + private constructor( + // In the range 0 to 3 (unsigned 2-bit integer). + ordinal: int, + // (Package-private) In the range 0 to 3 (unsigned 2-bit integer). + formatBits: int, + ) { + this.ordinal = ordinal; + this.formatBits = formatBits; } } /*---- Public helper enumeration ----*/ -namespace qrcodegen.QrSegment { - type int = number; +class QrSegmentMode { + /*-- Constants --*/ - /* - * Describes how a segment's data bits are interpreted. Immutable. - */ - export class Mode { - /*-- Constants --*/ + public static readonly NUMERIC = new QrSegmentMode(0x1, [10, 12, 14]); + public static readonly ALPHANUMERIC = new QrSegmentMode(0x2, [9, 11, 13]); + public static readonly BYTE = new QrSegmentMode(0x4, [8, 16, 16]); + public static readonly KANJI = new QrSegmentMode(0x8, [8, 10, 12]); + public static readonly ECI = new QrSegmentMode(0x7, [0, 0, 0]); - public static readonly NUMERIC = new Mode(0x1, [10, 12, 14]); - public static readonly ALPHANUMERIC = new Mode(0x2, [9, 11, 13]); - public static readonly BYTE = new Mode(0x4, [8, 16, 16]); - public static readonly KANJI = new Mode(0x8, [8, 10, 12]); - public static readonly ECI = new Mode(0x7, [0, 0, 0]); + public readonly modeBits: int; + private readonly numBitsCharCount: [int, int, int]; - /*-- Constructor and fields --*/ + /*-- Constructor and fields --*/ - private constructor( - // The mode indicator bits, which is a uint4 value (range 0 to 15). - public readonly modeBits: int, - // Number of character count bits for three different version ranges. - private readonly numBitsCharCount: [int, int, int], - ) {} + private constructor( + // The mode indicator bits, which is a uint4 value (range 0 to 15). + modeBits: int, + // Number of character count bits for three different version ranges. + numBitsCharCount: [int, int, int], + ) { + this.modeBits = modeBits; + this.numBitsCharCount = numBitsCharCount; + } - /*-- Method --*/ + /*-- Method --*/ - // (Package-private) Returns the bit width of the character count field for a segment in - // this mode in a QR Code at the given version number. The result is in the range [0, 16]. - public numCharCountBits(ver: int): int { - return this.numBitsCharCount[Math.floor((ver + 7) / 17)]; - } + // (Package-private) Returns the bit width of the character count field for a segment in + // this mode in a QR Code at the given version number. The result is in the range [0, 16]. + public numCharCountBits(ver: int): int { + return this.numBitsCharCount[Math.floor((ver + 7) / 17)]!; } } -export const QrCode = qrcodegen.QrCode; -export const QrSegment = qrcodegen.QrSegment; +QrCode.Ecc = QrCodeEcc; +QrSegment.Mode = QrSegmentMode; diff --git a/packages/shared/src/searchRanking.test.ts b/packages/shared/src/searchRanking.test.ts index d8c4b3d6ca4..dc43770bddd 100644 --- a/packages/shared/src/searchRanking.test.ts +++ b/packages/shared/src/searchRanking.test.ts @@ -6,7 +6,7 @@ import { normalizeSearchQuery, scoreQueryMatch, scoreSubsequenceMatch, -} from "./searchRanking"; +} from "./searchRanking.ts"; describe("normalizeSearchQuery", () => { it("trims and lowercases queries", () => { diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index 0ac5e415dff..6d749252dcc 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -1,9 +1,11 @@ +import { DEFAULT_SERVER_SETTINGS } from "@marcode/contracts"; import { describe, expect, it } from "vitest"; import { + applyServerSettingsPatch, extractPersistedServerObservabilitySettings, normalizePersistedServerSettingString, parsePersistedServerObservabilitySettings, -} from "./serverSettings"; +} from "./serverSettings.ts"; describe("serverSettings helpers", () => { it("normalizes optional persisted strings", () => { @@ -50,4 +52,61 @@ describe("serverSettings helpers", () => { otlpMetricsUrl: undefined, }); }); + + it("replaces text generation selection when provider/model are provided", () => { + const current = { + ...DEFAULT_SERVER_SETTINGS, + textGenerationModelSelection: { + provider: "codex" as const, + model: "gpt-5.4-mini", + options: { + reasoningEffort: "high" as const, + fastMode: true, + }, + }, + }; + + expect( + applyServerSettingsPatch(current, { + textGenerationModelSelection: { + provider: "codex", + model: "gpt-5.4-mini", + }, + }).textGenerationModelSelection, + ).toEqual({ + provider: "codex", + model: "gpt-5.4-mini", + }); + }); + + it("still deep merges text generation selection when only options are provided", () => { + const current = { + ...DEFAULT_SERVER_SETTINGS, + textGenerationModelSelection: { + provider: "codex" as const, + model: "gpt-5.4-mini", + options: { + reasoningEffort: "high" as const, + fastMode: true, + }, + }, + }; + + expect( + applyServerSettingsPatch(current, { + textGenerationModelSelection: { + options: { + fastMode: false, + }, + }, + }).textGenerationModelSelection, + ).toEqual({ + provider: "codex", + model: "gpt-5.4-mini", + options: { + reasoningEffort: "high", + fastMode: false, + }, + }); + }); }); diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index 2ec2c75a91e..3a4d581e668 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -1,6 +1,7 @@ -import { ServerSettings } from "@marcode/contracts"; +import { ServerSettings, type ServerSettingsPatch } from "@marcode/contracts"; import { Schema } from "effect"; -import { fromLenientJson } from "./schemaJson"; +import { deepMerge } from "./Struct.ts"; +import { fromLenientJson } from "./schemaJson.ts"; const ServerSettingsJson = fromLenientJson(ServerSettings); @@ -38,3 +39,34 @@ export function parsePersistedServerObservabilitySettings( return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined }; } } + +function shouldReplaceTextGenerationModelSelection( + patch: ServerSettingsPatch["textGenerationModelSelection"] | undefined, +): boolean { + return Boolean(patch && (patch.provider !== undefined || patch.model !== undefined)); +} + +/** + * Applies a server settings patch while treating textGenerationModelSelection as + * replace-on-provider/model updates. This prevents stale nested options from + * surviving a reset patch that intentionally omits options. + */ +export function applyServerSettingsPatch( + current: ServerSettings, + patch: ServerSettingsPatch, +): ServerSettings { + const selectionPatch = patch.textGenerationModelSelection; + const next = deepMerge(current, patch); + if (!selectionPatch || !shouldReplaceTextGenerationModelSelection(selectionPatch)) { + return next; + } + + return { + ...next, + textGenerationModelSelection: { + provider: selectionPatch.provider ?? current.textGenerationModelSelection.provider, + model: selectionPatch.model ?? current.textGenerationModelSelection.model, + ...(selectionPatch.options ? { options: selectionPatch.options } : {}), + }, + }; +} diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index ca54a19d3e8..45fb5a0a5be 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -2,9 +2,17 @@ import { describe, expect, it, vi } from "vitest"; import { extractPathFromShellOutput, + isCommandAvailable, + listLoginShellCandidates, + mergePathEntries, + mergePathValues, readEnvironmentFromLoginShell, + readEnvironmentFromWindowsShell, + readPathFromLaunchctl, readPathFromLoginShell, -} from "./shell"; + resolveKnownWindowsCliDirs, + resolveWindowsEnvironment, +} from "./shell.ts"; describe("extractPathFromShellOutput", () => { it("extracts the path between capture markers", () => { @@ -60,6 +68,38 @@ describe("readPathFromLoginShell", () => { }); }); +describe("readPathFromLaunchctl", () => { + it("returns a trimmed PATH value from launchctl", () => { + const execFile = vi.fn< + ( + file: string, + args: ReadonlyArray, + options: { encoding: "utf8"; timeout: number }, + ) => string + >(() => " /opt/homebrew/bin:/usr/bin \n"); + + expect(readPathFromLaunchctl(execFile)).toBe("/opt/homebrew/bin:/usr/bin"); + expect(execFile).toHaveBeenCalledWith("/bin/launchctl", ["getenv", "PATH"], { + encoding: "utf8", + timeout: 2000, + }); + }); + + it("returns undefined when launchctl is unavailable", () => { + const execFile = vi.fn< + ( + file: string, + args: ReadonlyArray, + options: { encoding: "utf8"; timeout: number }, + ) => string + >(() => { + throw new Error("spawn /bin/launchctl ENOENT"); + }); + + expect(readPathFromLaunchctl(execFile)).toBeUndefined(); + }); +}); + describe("readEnvironmentFromLoginShell", () => { it("extracts multiple environment variables from a login shell command", () => { const execFile = vi.fn< @@ -128,3 +168,293 @@ describe("readEnvironmentFromLoginShell", () => { }); }); }); + +describe("listLoginShellCandidates", () => { + it("returns env shell, user shell, then the platform fallback without duplicates", () => { + expect(listLoginShellCandidates("darwin", " /opt/homebrew/bin/nu ", "/bin/zsh")).toEqual([ + "/opt/homebrew/bin/nu", + "/bin/zsh", + ]); + }); + + it("falls back to the platform default when no shells are available", () => { + expect(listLoginShellCandidates("linux", undefined, "")).toEqual(["/bin/bash"]); + }); +}); + +describe("mergePathEntries", () => { + it("prefers login-shell PATH entries and keeps inherited extras", () => { + expect( + mergePathEntries("/opt/homebrew/bin:/usr/bin", "/Users/test/.local/bin:/usr/bin", "darwin"), + ).toBe("/opt/homebrew/bin:/usr/bin:/Users/test/.local/bin"); + }); + + it("uses the platform-specific delimiter", () => { + expect(mergePathEntries("C:\\Tools;C:\\Windows", "C:\\Windows;C:\\Git", "win32")).toBe( + "C:\\Tools;C:\\Windows;C:\\Git", + ); + }); +}); + +describe("readEnvironmentFromWindowsShell", () => { + it("extracts environment variables from a PowerShell command", () => { + const execFile = vi.fn< + ( + file: string, + args: ReadonlyArray, + options: { encoding: "utf8"; timeout: number }, + ) => string + >( + () => + "__MARCODE_ENV_PATH_START__\nC:\\Users\\testuser\\AppData\\Roaming\\npm\n__MARCODE_ENV_PATH_END__\n", + ); + + expect(readEnvironmentFromWindowsShell(["PATH"], execFile)).toEqual({ + PATH: "C:\\Users\\testuser\\AppData\\Roaming\\npm", + }); + expect(execFile).toHaveBeenCalledWith( + "pwsh.exe", + expect.arrayContaining(["-NoLogo", "-NoProfile", "-NonInteractive", "-Command"]), + { encoding: "utf8", timeout: 5000 }, + ); + }); + + it("strips CRLF delimiters from captured PowerShell values", () => { + const execFile = vi.fn< + ( + file: string, + args: ReadonlyArray, + options: { encoding: "utf8"; timeout: number }, + ) => string + >( + () => + "__MARCODE_ENV_FNM_DIR_START__\r\nC:\\Users\\testuser\\AppData\\Roaming\\fnm\r\n__MARCODE_ENV_FNM_DIR_END__\r\n", + ); + + expect(readEnvironmentFromWindowsShell(["FNM_DIR"], execFile)).toEqual({ + FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm", + }); + }); + + it("omits -NoProfile when loadProfile is enabled", () => { + const execFile = vi.fn< + ( + file: string, + args: ReadonlyArray, + options: { encoding: "utf8"; timeout: number }, + ) => string + >(() => "__MARCODE_ENV_PATH_START__\nC:\\Tools\n__MARCODE_ENV_PATH_END__\n"); + + expect(readEnvironmentFromWindowsShell(["PATH"], { loadProfile: true }, execFile)).toEqual({ + PATH: "C:\\Tools", + }); + expect(execFile).toHaveBeenCalledWith( + "pwsh.exe", + expect.arrayContaining(["-NoLogo", "-NonInteractive", "-Command"]), + { encoding: "utf8", timeout: 5000 }, + ); + expect(execFile.mock.calls[0]?.[1]).not.toContain("-NoProfile"); + }); + + it("falls back to Windows PowerShell when pwsh.exe is unavailable", () => { + const execFile = vi.fn< + ( + file: string, + args: ReadonlyArray, + options: { encoding: "utf8"; timeout: number }, + ) => string + >((file) => { + if (file === "pwsh.exe") { + throw new Error("spawn pwsh.exe ENOENT"); + } + return "__MARCODE_ENV_PATH_START__\nC:\\Tools\n__MARCODE_ENV_PATH_END__\n"; + }); + + expect(readEnvironmentFromWindowsShell(["PATH"], execFile)).toEqual({ + PATH: "C:\\Tools", + }); + expect(execFile).toHaveBeenNthCalledWith(1, "pwsh.exe", expect.any(Array), { + encoding: "utf8", + timeout: 5000, + }); + expect(execFile).toHaveBeenNthCalledWith(2, "powershell.exe", expect.any(Array), { + encoding: "utf8", + timeout: 5000, + }); + }); +}); + +describe("mergePathValues", () => { + it("dedupes case-insensitively on Windows while preserving preferred order", () => { + expect( + mergePathValues( + 'C:\\Users\\testuser\\AppData\\Roaming\\npm;"C:\\Program Files\\nodejs"', + "c:\\users\\testuser\\appdata\\roaming\\npm;C:\\Windows\\System32", + "win32", + ), + ).toBe( + 'C:\\Users\\testuser\\AppData\\Roaming\\npm;"C:\\Program Files\\nodejs";C:\\Windows\\System32', + ); + }); + + it("dedupes case-sensitively on POSIX", () => { + expect(mergePathValues("/usr/local/bin:/usr/bin", "/usr/bin:/USR/BIN", "linux")).toBe( + "/usr/local/bin:/usr/bin:/USR/BIN", + ); + }); +}); + +describe("resolveKnownWindowsCliDirs", () => { + it("returns known Windows CLI install directories in priority order", () => { + expect( + resolveKnownWindowsCliDirs({ + APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", + LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local", + USERPROFILE: "C:\\Users\\testuser", + }), + ).toEqual([ + "C:\\Users\\testuser\\AppData\\Roaming\\npm", + "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", + "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", + "C:\\Users\\testuser\\AppData\\Local\\pnpm", + "C:\\Users\\testuser\\.bun\\bin", + "C:\\Users\\testuser\\scoop\\shims", + ]); + }); +}); + +describe("isCommandAvailable", () => { + it("returns false when PATH is empty", () => { + expect( + isCommandAvailable("definitely-not-installed", { + platform: "win32", + env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" }, + }), + ).toBe(false); + }); +}); + +describe("resolveWindowsEnvironment", () => { + it("returns the baseline no-profile PATH patch when node is already available", () => { + const readEnvironment = vi.fn( + (_names: ReadonlyArray, options?: { loadProfile?: boolean }) => + options?.loadProfile + ? { PATH: "C:\\Profile\\Bin" } + : { PATH: "C:\\Shell\\Bin;C:\\Windows\\System32" }, + ); + const commandAvailable = vi.fn(() => true); + + expect( + resolveWindowsEnvironment( + { + PATH: "C:\\Windows\\System32", + APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", + LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local", + USERPROFILE: "C:\\Users\\testuser", + }, + { + readEnvironment, + commandAvailable, + }, + ), + ).toEqual({ + PATH: [ + "C:\\Users\\testuser\\AppData\\Roaming\\npm", + "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", + "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", + "C:\\Users\\testuser\\AppData\\Local\\pnpm", + "C:\\Users\\testuser\\.bun\\bin", + "C:\\Users\\testuser\\scoop\\shims", + "C:\\Shell\\Bin", + "C:\\Windows\\System32", + ].join(";"), + }); + expect(readEnvironment).toHaveBeenCalledTimes(1); + expect(readEnvironment).toHaveBeenCalledWith(["PATH"], { loadProfile: false }); + expect(commandAvailable).toHaveBeenCalledWith( + "node", + expect.objectContaining({ + platform: "win32", + }), + ); + }); + + it("loads the PowerShell profile when baseline env cannot resolve node", () => { + const readEnvironment = vi.fn( + (_names: ReadonlyArray, options?: { loadProfile?: boolean }) => + options?.loadProfile + ? { + PATH: "C:\\Profile\\Node;C:\\Windows\\System32", + FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm", + FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123", + } + : { PATH: "C:\\Shell\\Bin;C:\\Windows\\System32" }, + ); + const commandAvailable = vi.fn(() => false); + + expect( + resolveWindowsEnvironment( + { + PATH: "C:\\Windows\\System32", + APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", + LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local", + USERPROFILE: "C:\\Users\\testuser", + }, + { + readEnvironment, + commandAvailable, + }, + ), + ).toEqual({ + PATH: [ + "C:\\Profile\\Node", + "C:\\Windows\\System32", + "C:\\Users\\testuser\\AppData\\Roaming\\npm", + "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", + "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", + "C:\\Users\\testuser\\AppData\\Local\\pnpm", + "C:\\Users\\testuser\\.bun\\bin", + "C:\\Users\\testuser\\scoop\\shims", + "C:\\Shell\\Bin", + ].join(";"), + FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm", + FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123", + }); + expect(readEnvironment).toHaveBeenNthCalledWith(1, ["PATH"], { loadProfile: false }); + expect(readEnvironment).toHaveBeenNthCalledWith(2, ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"], { + loadProfile: true, + }); + expect(commandAvailable).toHaveBeenCalledTimes(1); + }); + + it("keeps the baseline env when profiled probe still does not resolve node", () => { + const readEnvironment = vi.fn( + (_names: ReadonlyArray, options?: { loadProfile?: boolean }) => + options?.loadProfile ? { FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm" } : {}, + ); + const commandAvailable = vi.fn(() => false); + + expect( + resolveWindowsEnvironment( + { + PATH: "C:\\Windows\\System32", + APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", + USERPROFILE: "C:\\Users\\testuser", + }, + { + readEnvironment, + commandAvailable, + }, + ), + ).toEqual({ + PATH: [ + "C:\\Users\\testuser\\AppData\\Roaming\\npm", + "C:\\Users\\testuser\\.bun\\bin", + "C:\\Users\\testuser\\scoop\\shims", + "C:\\Windows\\System32", + ].join(";"), + FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm", + }); + expect(commandAvailable).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index 202674e5774..46a468c5168 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -1,8 +1,14 @@ +import * as OS from "node:os"; import { execFileSync } from "node:child_process"; +import { accessSync, constants, statSync } from "node:fs"; +import { extname, join } from "node:path"; const PATH_CAPTURE_START = "__MARCODE_PATH_START__"; const PATH_CAPTURE_END = "__MARCODE_PATH_END__"; const SHELL_ENV_NAME_PATTERN = /^[A-Z0-9_]+$/; +const WINDOWS_PATH_DELIMITER = ";"; +const POSIX_PATH_DELIMITER = ":"; +const WINDOWS_SHELL_CANDIDATES = ["pwsh.exe", "powershell.exe"] as const; type ExecFileSyncLike = ( file: string, @@ -10,24 +16,47 @@ type ExecFileSyncLike = ( options: { encoding: "utf8"; timeout: number }, ) => string; -export function resolveLoginShell( - platform: NodeJS.Platform, - shell: string | undefined, -): string | undefined { - const trimmedShell = shell?.trim(); - if (trimmedShell) { - return trimmedShell; - } +export interface CommandAvailabilityOptions { + readonly platform?: NodeJS.Platform; + readonly env?: NodeJS.ProcessEnv; +} + +export interface WindowsEnvironmentProbeOptions { + readonly loadProfile?: boolean; +} + +function trimNonEmpty(value: string | null | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : undefined; +} - if (platform === "darwin") { - return "/bin/zsh"; +function readUserLoginShell(): string | undefined { + try { + return trimNonEmpty(OS.userInfo().shell); + } catch { + return undefined; } +} + +export function listLoginShellCandidates( + platform: NodeJS.Platform, + shell: string | undefined, + userShell = readUserLoginShell(), +): ReadonlyArray { + const fallbackShell = + platform === "darwin" ? "/bin/zsh" : platform === "linux" ? "/bin/bash" : undefined; + const seen = new Set(); + const candidates: string[] = []; - if (platform === "linux") { - return "/bin/bash"; + for (const candidate of [trimNonEmpty(shell), trimNonEmpty(userShell), fallbackShell]) { + if (!candidate || seen.has(candidate)) { + continue; + } + seen.add(candidate); + candidates.push(candidate); } - return undefined; + return candidates; } export function extractPathFromShellOutput(output: string): string | null { @@ -49,6 +78,45 @@ export function readPathFromLoginShell( return readEnvironmentFromLoginShell(shell, ["PATH"], execFile).PATH; } +export function readPathFromLaunchctl( + execFile: ExecFileSyncLike = execFileSync, +): string | undefined { + try { + return trimNonEmpty( + execFile("/bin/launchctl", ["getenv", "PATH"], { + encoding: "utf8", + timeout: 2000, + }), + ); + } catch { + return undefined; + } +} + +export function mergePathEntries( + preferredPath: string | undefined, + inheritedPath: string | undefined, + platform: NodeJS.Platform, +): string | undefined { + const delimiter = platform === "win32" ? ";" : ":"; + const merged: string[] = []; + const seen = new Set(); + + for (const pathValue of [preferredPath, inheritedPath]) { + if (!pathValue) continue; + for (const entry of pathValue.split(delimiter)) { + const trimmedEntry = entry.trim(); + if (!trimmedEntry || seen.has(trimmedEntry)) { + continue; + } + seen.add(trimmedEntry); + merged.push(trimmedEntry); + } + } + + return merged.length > 0 ? merged.join(delimiter) : undefined; +} + function envCaptureStart(name: string): string { return `__MARCODE_ENV_${name}_START__`; } @@ -73,6 +141,24 @@ function buildEnvironmentCaptureCommand(names: ReadonlyArray): string { .join("; "); } +function buildWindowsEnvironmentCaptureCommand(names: ReadonlyArray): string { + return [ + "$ErrorActionPreference = 'Stop'", + ...names.flatMap((name) => { + if (!SHELL_ENV_NAME_PATTERN.test(name)) { + throw new Error(`Unsupported environment variable name: ${name}`); + } + + return [ + `Write-Output '${envCaptureStart(name)}'`, + `$value = [Environment]::GetEnvironmentVariable('${name}')`, + "if ($null -ne $value -and $value.Length -gt 0) { Write-Output $value }", + `Write-Output '${envCaptureEnd(name)}'`, + ]; + }), + ].join("; "); +} + function extractEnvironmentValue(output: string, name: string): string | undefined { const startMarker = envCaptureStart(name); const endMarker = envCaptureEnd(name); @@ -83,13 +169,10 @@ function extractEnvironmentValue(output: string, name: string): string | undefin const endIndex = output.indexOf(endMarker, valueStartIndex); if (endIndex === -1) return undefined; - let value = output.slice(valueStartIndex, endIndex); - if (value.startsWith("\n")) { - value = value.slice(1); - } - if (value.endsWith("\n")) { - value = value.slice(0, -1); - } + const value = output + .slice(valueStartIndex, endIndex) + .replace(/^\r?\n/, "") + .replace(/\r?\n$/, ""); return value.length > 0 ? value : undefined; } @@ -124,3 +207,284 @@ export const readEnvironmentFromLoginShell: ShellEnvironmentReader = ( return environment; }; + +export type WindowsShellEnvironmentReader = ( + names: ReadonlyArray, + options?: WindowsEnvironmentProbeOptions, +) => Partial>; + +export function readEnvironmentFromWindowsShell( + names: ReadonlyArray, + execFile?: ExecFileSyncLike, +): Partial>; +export function readEnvironmentFromWindowsShell( + names: ReadonlyArray, + options?: WindowsEnvironmentProbeOptions, + execFile?: ExecFileSyncLike, +): Partial>; +export function readEnvironmentFromWindowsShell( + names: ReadonlyArray, + optionsOrExecFile?: WindowsEnvironmentProbeOptions | ExecFileSyncLike, + maybeExecFile?: ExecFileSyncLike, +): Partial> { + if (names.length === 0) { + return {}; + } + + const options = + typeof optionsOrExecFile === "function" + ? ({} satisfies WindowsEnvironmentProbeOptions) + : (optionsOrExecFile ?? {}); + const execFile: ExecFileSyncLike = + typeof optionsOrExecFile === "function" + ? optionsOrExecFile + : (maybeExecFile ?? (execFileSync as ExecFileSyncLike)); + const command = buildWindowsEnvironmentCaptureCommand(names); + const args = [ + "-NoLogo", + ...(options.loadProfile ? ([] as const) : (["-NoProfile"] as const)), + "-NonInteractive", + "-Command", + command, + ]; + for (const shell of WINDOWS_SHELL_CANDIDATES) { + try { + const output = execFile(shell, args, { encoding: "utf8", timeout: 5000 }); + + const environment: Partial> = {}; + for (const name of names) { + const value = extractEnvironmentValue(output, name); + if (value !== undefined) { + environment[name] = value; + } + } + return environment; + } catch { + continue; + } + } + + return {}; +} + +function stripWrappingQuotes(value: string): string { + return value.replace(/^"+|"+$/g, ""); +} + +function pathDelimiterForPlatform(platform: NodeJS.Platform): string { + return platform === "win32" ? WINDOWS_PATH_DELIMITER : POSIX_PATH_DELIMITER; +} + +function normalizePathEntryForComparison(entry: string, platform: NodeJS.Platform): string { + const normalized = stripWrappingQuotes(entry.trim()); + return platform === "win32" ? normalized.toLowerCase() : normalized; +} + +export function mergePathValues( + preferredPath: string | undefined, + inheritedPath: string | undefined, + platform: NodeJS.Platform, +): string | undefined { + const delimiter = pathDelimiterForPlatform(platform); + const merged: string[] = []; + const seen = new Set(); + + for (const rawValue of [preferredPath, inheritedPath]) { + if (!rawValue) continue; + + for (const entry of rawValue.split(delimiter)) { + const trimmed = entry.trim(); + if (trimmed.length === 0) continue; + + const normalized = normalizePathEntryForComparison(trimmed, platform); + if (normalized.length === 0 || seen.has(normalized)) continue; + + seen.add(normalized); + merged.push(trimmed); + } + } + + return merged.length > 0 ? merged.join(delimiter) : undefined; +} + +function readEnvPath(env: NodeJS.ProcessEnv): string | undefined { + return env.PATH ?? env.Path ?? env.path; +} + +function resolvePathEnvironmentVariable(env: NodeJS.ProcessEnv): string { + return readEnvPath(env) ?? ""; +} + +function resolveWindowsPathExtensions(env: NodeJS.ProcessEnv): ReadonlyArray { + const rawValue = env.PATHEXT; + const fallback = [".COM", ".EXE", ".BAT", ".CMD"]; + if (!rawValue) return fallback; + + const parsed = rawValue + .split(";") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) + .map((entry) => (entry.startsWith(".") ? entry.toUpperCase() : `.${entry.toUpperCase()}`)); + return parsed.length > 0 ? Array.from(new Set(parsed)) : fallback; +} + +function resolveCommandCandidates( + command: string, + platform: NodeJS.Platform, + windowsPathExtensions: ReadonlyArray, +): ReadonlyArray { + if (platform !== "win32") return [command]; + const extension = extname(command); + const normalizedExtension = extension.toUpperCase(); + + if (extension.length > 0 && windowsPathExtensions.includes(normalizedExtension)) { + const commandWithoutExtension = command.slice(0, -extension.length); + return Array.from( + new Set([ + command, + `${commandWithoutExtension}${normalizedExtension}`, + `${commandWithoutExtension}${normalizedExtension.toLowerCase()}`, + ]), + ); + } + + const candidates: string[] = []; + for (const candidateExtension of windowsPathExtensions) { + candidates.push(`${command}${candidateExtension}`); + candidates.push(`${command}${candidateExtension.toLowerCase()}`); + } + return Array.from(new Set(candidates)); +} + +function isExecutableFile( + filePath: string, + platform: NodeJS.Platform, + windowsPathExtensions: ReadonlyArray, +): boolean { + try { + const stat = statSync(filePath); + if (!stat.isFile()) return false; + if (platform === "win32") { + const extension = extname(filePath); + if (extension.length === 0) return false; + return windowsPathExtensions.includes(extension.toUpperCase()); + } + accessSync(filePath, constants.X_OK); + return true; + } catch { + return false; + } +} + +export function isCommandAvailable( + command: string, + options: CommandAvailabilityOptions = {}, +): boolean { + const platform = options.platform ?? process.platform; + const env = options.env ?? process.env; + const windowsPathExtensions = platform === "win32" ? resolveWindowsPathExtensions(env) : []; + const commandCandidates = resolveCommandCandidates(command, platform, windowsPathExtensions); + + if (command.includes("/") || command.includes("\\")) { + return commandCandidates.some((candidate) => + isExecutableFile(candidate, platform, windowsPathExtensions), + ); + } + + const pathValue = resolvePathEnvironmentVariable(env); + if (pathValue.length === 0) return false; + const pathEntries = pathValue + .split(pathDelimiterForPlatform(platform)) + .map((entry) => stripWrappingQuotes(entry.trim())) + .filter((entry) => entry.length > 0); + + for (const pathEntry of pathEntries) { + for (const candidate of commandCandidates) { + if (isExecutableFile(join(pathEntry, candidate), platform, windowsPathExtensions)) { + return true; + } + } + } + return false; +} + +export function resolveKnownWindowsCliDirs(env: NodeJS.ProcessEnv): ReadonlyArray { + const appData = env.APPDATA?.trim(); + const localAppData = env.LOCALAPPDATA?.trim(); + const userProfile = env.USERPROFILE?.trim(); + + return [ + ...(appData ? [`${appData}\\npm`] : []), + ...(localAppData ? [`${localAppData}\\Programs\\nodejs`, `${localAppData}\\Volta\\bin`] : []), + ...(localAppData ? [`${localAppData}\\pnpm`] : []), + ...(userProfile ? [`${userProfile}\\.bun\\bin`, `${userProfile}\\scoop\\shims`] : []), + ]; +} + +export interface WindowsEnvironmentResolverOptions { + readonly readEnvironment?: WindowsShellEnvironmentReader; + readonly commandAvailable?: typeof isCommandAvailable; +} + +function readWindowsEnvironmentSafely( + readEnvironment: WindowsShellEnvironmentReader, + names: ReadonlyArray, + options?: WindowsEnvironmentProbeOptions, +): Partial> { + try { + return readEnvironment(names, options); + } catch { + return {}; + } +} + +function mergeWindowsEnv( + currentEnv: NodeJS.ProcessEnv, + patch: Partial>, +): NodeJS.ProcessEnv { + const nextEnv: NodeJS.ProcessEnv = { ...currentEnv }; + for (const [key, value] of Object.entries(patch)) { + if (value !== undefined) { + nextEnv[key] = value; + } + } + return nextEnv; +} + +export function resolveWindowsEnvironment( + env: NodeJS.ProcessEnv, + options: WindowsEnvironmentResolverOptions = {}, +): Partial { + const readEnvironment = options.readEnvironment ?? readEnvironmentFromWindowsShell; + const commandAvailable = options.commandAvailable ?? isCommandAvailable; + const inheritedPath = readEnvPath(env); + const shellPath = readWindowsEnvironmentSafely(readEnvironment, ["PATH"], { + loadProfile: false, + }).PATH; + const mergedPath = mergePathValues(shellPath, inheritedPath, "win32"); + const knownCliPath = resolveKnownWindowsCliDirs(env).join(WINDOWS_PATH_DELIMITER); + const baselinePath = mergePathValues(knownCliPath, mergedPath, "win32"); + const baselinePatch: Partial = baselinePath ? { PATH: baselinePath } : {}; + const baselineEnv = mergeWindowsEnv(env, baselinePatch); + + if (commandAvailable("node", { platform: "win32", env: baselineEnv })) { + return baselinePatch; + } + + const profiledEnvironment = readWindowsEnvironmentSafely( + readEnvironment, + ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"], + { loadProfile: true }, + ); + const profiledPath = mergePathValues(profiledEnvironment.PATH, baselinePath, "win32"); + const profiledPatch: Partial = { + ...(profiledPath ? { PATH: profiledPath } : {}), + ...(profiledEnvironment.FNM_DIR ? { FNM_DIR: profiledEnvironment.FNM_DIR } : {}), + ...(profiledEnvironment.FNM_MULTISHELL_PATH + ? { FNM_MULTISHELL_PATH: profiledEnvironment.FNM_MULTISHELL_PATH } + : {}), + }; + return Object.keys(profiledPatch).length > 0 + ? { ...baselinePatch, ...profiledPatch } + : baselinePatch; +} diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index d71cbcbe4e6..ab2d6084bb2 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -2,9 +2,65 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { ConfigProvider, Effect, Option } from "effect"; -import { resolveBuildOptions } from "./build-desktop-artifact.ts"; +import { + resolveBuildOptions, + resolveDesktopBuildIconAssets, + resolveDesktopProductName, + resolveDesktopUpdateChannel, + resolveMockUpdateServerPort, + resolveMockUpdateServerUrl, +} from "./build-desktop-artifact.ts"; +import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts"; it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { + it("resolves the dedicated nightly updater channel from nightly versions", () => { + assert.equal(resolveDesktopUpdateChannel("0.0.17-nightly.20260413.42"), "nightly"); + assert.equal(resolveDesktopUpdateChannel("0.0.17"), "latest"); + }); + + it("switches desktop packaging product names to nightly for nightly builds", () => { + assert.equal(resolveDesktopProductName("0.0.17"), "MarCode (Alpha)"); + assert.equal(resolveDesktopProductName("0.0.17-nightly.20260413.42"), "MarCode (Nightly)"); + }); + + it("switches desktop packaging icons to the nightly artwork for nightly versions", () => { + assert.deepStrictEqual(resolveDesktopBuildIconAssets("0.0.17"), { + macIconPng: BRAND_ASSET_PATHS.productionMacIconPng, + linuxIconPng: BRAND_ASSET_PATHS.productionLinuxIconPng, + windowsIconIco: BRAND_ASSET_PATHS.productionWindowsIconIco, + }); + + assert.deepStrictEqual(resolveDesktopBuildIconAssets("0.0.17-nightly.20260413.42"), { + macIconPng: BRAND_ASSET_PATHS.nightlyMacIconPng, + linuxIconPng: BRAND_ASSET_PATHS.nightlyLinuxIconPng, + windowsIconIco: BRAND_ASSET_PATHS.nightlyWindowsIconIco, + }); + }); + + it("falls back to the default mock update port when the configured port is blank", () => { + assert.equal(resolveMockUpdateServerUrl(undefined), "http://localhost:3000"); + assert.equal(resolveMockUpdateServerUrl(4123), "http://localhost:4123"); + }); + + it.effect("normalizes mock update server ports from env-style strings", () => + Effect.gen(function* () { + assert.equal(yield* resolveMockUpdateServerPort(undefined), undefined); + assert.equal(yield* resolveMockUpdateServerPort(""), undefined); + assert.equal(yield* resolveMockUpdateServerPort(" "), undefined); + assert.equal(yield* resolveMockUpdateServerPort("4123"), 4123); + }), + ); + + it.effect("rejects non-numeric or out-of-range mock update ports", () => + Effect.gen(function* () { + const invalidPorts = ["abc", "12.5", "0", "65536"]; + for (const port of invalidPorts) { + const exit = yield* Effect.exit(resolveMockUpdateServerPort(port)); + assert.equal(exit._tag, "Failure"); + } + }), + ); + it.effect("preserves explicit false boolean flags over true env defaults", () => Effect.gen(function* () { const resolved = yield* resolveBuildOptions({ diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index d07d003f542..b32fd988c95 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -1,19 +1,27 @@ #!/usr/bin/env node -import { spawnSync } from "node:child_process"; -import { existsSync } from "node:fs"; -import { join } from "node:path"; - import rootPackageJson from "../package.json" with { type: "json" }; import desktopPackageJson from "../apps/desktop/package.json" with { type: "json" }; import serverPackageJson from "../apps/server/package.json" with { type: "json" }; import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts"; +import { getDefaultBuildArch } from "./lib/build-target-arch.ts"; import { resolveCatalogDependencies } from "./lib/resolve-catalog.ts"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { Config, Data, Effect, FileSystem, Layer, Logger, Option, Path, Schema } from "effect"; +import { + Config, + Data, + Effect, + FileSystem, + Layer, + Logger, + Option, + Path, + Schema, + Stream, +} from "effect"; import { Command, Flag } from "effect/unstable/cli"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; @@ -23,23 +31,14 @@ const BuildArch = Schema.Literals(["arm64", "x64", "universal"]); const RepoRoot = Effect.service(Path.Path).pipe( Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))), ); -const ProductionMacIconSource = Effect.zipWith( - RepoRoot, - Effect.service(Path.Path), - (repoRoot, path) => path.join(repoRoot, BRAND_ASSET_PATHS.productionMacIconPng), -); -const ProductionLinuxIconSource = Effect.zipWith( - RepoRoot, - Effect.service(Path.Path), - (repoRoot, path) => path.join(repoRoot, BRAND_ASSET_PATHS.productionLinuxIconPng), -); -const ProductionWindowsIconSource = Effect.zipWith( - RepoRoot, - Effect.service(Path.Path), - (repoRoot, path) => path.join(repoRoot, BRAND_ASSET_PATHS.productionWindowsIconIco), -); const encodeJsonString = Schema.encodeEffect(Schema.UnknownFromJsonString); +interface DesktopBuildIconAssets { + readonly macIconPng: string; + readonly linuxIconPng: string; + readonly windowsIconIco: string; +} + interface PlatformConfig { readonly cliFlag: "--mac" | "--linux" | "--win"; readonly defaultTarget: string; @@ -75,7 +74,7 @@ interface BuildCliInput { readonly signed: Option.Option; readonly verbose: Option.Option; readonly mockUpdates: Option.Option; - readonly mockUpdateServerPort: Option.Option; + readonly mockUpdateServerPort: Option.Option; } function detectHostBuildPlatform(hostPlatform: string): typeof BuildPlatform.Type | undefined { @@ -91,14 +90,7 @@ function getDefaultArch(platform: typeof BuildPlatform.Type): typeof BuildArch.T return "x64"; } - if (process.arch === "arm64" && config.archChoices.includes("arm64")) { - return "arm64"; - } - if (process.arch === "x64" && config.archChoices.includes("x64")) { - return "x64"; - } - - return config.archChoices[0] ?? "x64"; + return getDefaultBuildArch(platform, process.arch, process.env, config); } class BuildScriptError extends Data.TaggedError("BuildScriptError")<{ @@ -106,12 +98,49 @@ class BuildScriptError extends Data.TaggedError("BuildScriptError")<{ readonly cause?: unknown; }> {} -function resolveGitCommitHash(repoRoot: string): string { - const result = spawnSync("git", ["rev-parse", "--short=12", "HEAD"], { - cwd: repoRoot, - encoding: "utf8", - }); - if (result.status !== 0) { +const collectStreamAsString = (stream: Stream.Stream): Effect.Effect => + stream.pipe( + Stream.decodeText(), + Stream.runFold( + () => "", + (acc, chunk) => acc + chunk, + ), + ); + +const spawnAndCollectOutput = Effect.fn("spawnAndCollectOutput")(function* ( + command: ChildProcess.Command, +) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const child = yield* spawner.spawn(command); + + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collectStreamAsString(child.stdout), + collectStreamAsString(child.stderr), + child.exitCode.pipe(Effect.map(Number)), + ], + { concurrency: "unbounded" }, + ); + + return { stdout, stderr, exitCode } as const; +}); + +const resolveGitCommitHash = Effect.fn("resolveGitCommitHash")(function* (repoRoot: string) { + const result = yield* spawnAndCollectOutput( + ChildProcess.make("git", ["rev-parse", "--short=12", "HEAD"], { + cwd: repoRoot, + }), + ).pipe( + Effect.catch(() => + Effect.succeed({ + stdout: "", + stderr: "", + exitCode: 1, + }), + ), + ); + + if (result.exitCode !== 0) { return "unknown"; } const hash = result.stdout.trim(); @@ -119,11 +148,13 @@ function resolveGitCommitHash(repoRoot: string): string { return "unknown"; } return hash.toLowerCase(); -} +}); -function resolvePythonForNodeGyp(): string | undefined { +const resolvePythonForNodeGyp = Effect.fn("resolvePythonForNodeGyp")(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const configured = process.env.npm_config_python ?? process.env.PYTHON; - if (configured && existsSync(configured)) { + if (configured && (yield* fs.exists(configured))) { return configured; } @@ -131,28 +162,37 @@ function resolvePythonForNodeGyp(): string | undefined { const localAppData = process.env.LOCALAPPDATA; if (localAppData) { for (const version of ["Python313", "Python312", "Python311", "Python310"]) { - const candidate = join(localAppData, "Programs", "Python", version, "python.exe"); - if (existsSync(candidate)) { + const candidate = path.join(localAppData, "Programs", "Python", version, "python.exe"); + if (yield* fs.exists(candidate)) { return candidate; } } } } - const probe = spawnSync("python", ["-c", "import sys;print(sys.executable)"], { - encoding: "utf8", - }); - if (probe.status !== 0) { + const probe = yield* spawnAndCollectOutput( + ChildProcess.make("python", ["-c", "import sys;print(sys.executable)"]), + ).pipe( + Effect.catch(() => + Effect.succeed({ + stdout: "", + stderr: "", + exitCode: 1, + }), + ), + ); + + if (probe.exitCode !== 0) { return undefined; } const executable = probe.stdout.trim(); - if (!executable || !existsSync(executable)) { + if (!executable || !(yield* fs.exists(executable))) { return undefined; } return executable; -} +}); interface ResolvedBuildOptions { readonly platform: typeof BuildPlatform.Type; @@ -165,7 +205,7 @@ interface ResolvedBuildOptions { readonly signed: boolean; readonly verbose: boolean; readonly mockUpdates: boolean; - readonly mockUpdateServerPort: string | undefined; + readonly mockUpdateServerPort: number | undefined; } interface StagePackageJson { @@ -215,11 +255,28 @@ const BuildEnvConfig = Config.all({ ), }); +const MockUpdateServerPortSchema = Schema.NumberFromString.check( + Schema.isInt(), + Schema.isBetween({ minimum: 1, maximum: 65535 }), +); +const decodeMockUpdateServerPort = Schema.decodeUnknownEffect(MockUpdateServerPortSchema); + const resolveBooleanFlag = (flag: Option.Option, envValue: boolean) => Option.getOrElse(flag, () => envValue); const mergeOptions = (a: Option.Option, b: Option.Option, defaultValue: A) => Option.getOrElse(a, () => Option.getOrElse(b, () => defaultValue)); +export const resolveMockUpdateServerPort = Effect.fn("resolveMockUpdateServerPort")(function* ( + mockUpdateServerPort: string | undefined, +) { + const port = mockUpdateServerPort?.trim(); + if (!port) { + return undefined; + } + + return yield* decodeMockUpdateServerPort(port); +}); + export const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* ( input: BuildCliInput, ) { @@ -256,11 +313,17 @@ export const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* ( const verbose = resolveBooleanFlag(input.verbose, env.verbose); const mockUpdates = resolveBooleanFlag(input.mockUpdates, env.mockUpdates); - const mockUpdateServerPort = mergeOptions( - input.mockUpdateServerPort, - env.mockUpdateServerPort, - undefined, - ); + const mockUpdateServerPort = + Option.getOrUndefined(input.mockUpdateServerPort) ?? + (yield* resolveMockUpdateServerPort(Option.getOrUndefined(env.mockUpdateServerPort)).pipe( + Effect.mapError( + (cause) => + new BuildScriptError({ + message: "Invalid mock update server port.", + cause, + }), + ), + )); return { platform, @@ -331,14 +394,13 @@ function generateMacIconSet( }); } -function stageMacIcons(stageResourcesDir: string, verbose: boolean) { +function stageMacIcons(stageResourcesDir: string, sourcePng: string, verbose: boolean) { return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const iconSource = yield* ProductionMacIconSource; - if (!(yield* fs.exists(iconSource))) { + if (!(yield* fs.exists(sourcePng))) { return yield* new BuildScriptError({ - message: `Production icon source is missing at ${iconSource}`, + message: `Desktop macOS icon source is missing at ${sourcePng}`, }); } @@ -352,42 +414,40 @@ function stageMacIcons(stageResourcesDir: string, verbose: boolean) { yield* runCommand( ChildProcess.make({ ...commandOutputOptions(verbose), - })`sips -z 512 512 ${iconSource} --out ${iconPngPath}`, + })`sips -z 512 512 ${sourcePng} --out ${iconPngPath}`, ); - yield* generateMacIconSet(iconSource, iconIcnsPath, tmpRoot, path, verbose); + yield* generateMacIconSet(sourcePng, iconIcnsPath, tmpRoot, path, verbose); }); } -function stageLinuxIcons(stageResourcesDir: string) { +function stageLinuxIcons(stageResourcesDir: string, sourcePng: string) { return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const iconSource = yield* ProductionLinuxIconSource; - if (!(yield* fs.exists(iconSource))) { + if (!(yield* fs.exists(sourcePng))) { return yield* new BuildScriptError({ - message: `Production icon source is missing at ${iconSource}`, + message: `Desktop Linux icon source is missing at ${sourcePng}`, }); } const iconPath = path.join(stageResourcesDir, "icon.png"); - yield* fs.copyFile(iconSource, iconPath); + yield* fs.copyFile(sourcePng, iconPath); }); } -function stageWindowsIcons(stageResourcesDir: string) { +function stageWindowsIcons(stageResourcesDir: string, sourceIco: string) { return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const iconSource = yield* ProductionWindowsIconSource; - if (!(yield* fs.exists(iconSource))) { + if (!(yield* fs.exists(sourceIco))) { return yield* new BuildScriptError({ - message: `Production Windows icon source is missing at ${iconSource}`, + message: `Desktop Windows icon source is missing at ${sourceIco}`, }); } const iconPath = path.join(stageResourcesDir, "icon.ico"); - yield* fs.copyFile(iconSource, iconPath); + yield* fs.copyFile(sourceIco, iconPath); }); } @@ -443,12 +503,13 @@ function resolveDesktopRuntimeDependencies( return resolveCatalogDependencies(runtimeDependencies, catalog, "apps/desktop"); } -function resolveGitHubPublishConfig(): +function resolveGitHubPublishConfig(updateChannel: "latest" | "nightly"): | { readonly provider: "github"; readonly owner: string; readonly repo: string; - readonly releaseType: "release"; + readonly releaseType: "release" | "prerelease"; + readonly channel?: "nightly"; } | undefined { const rawRepo = @@ -464,34 +525,66 @@ function resolveGitHubPublishConfig(): provider: "github", owner, repo, - releaseType: "release", + releaseType: updateChannel === "nightly" ? "prerelease" : "release", + ...(updateChannel === "nightly" ? { channel: "nightly" as const } : {}), + }; +} + +export function resolveDesktopUpdateChannel(version: string): "latest" | "nightly" { + return /-nightly\.\d{8}\.\d+$/.test(version) ? "nightly" : "latest"; +} + +export function resolveDesktopBuildIconAssets(version: string): DesktopBuildIconAssets { + if (resolveDesktopUpdateChannel(version) === "nightly") { + return { + macIconPng: BRAND_ASSET_PATHS.nightlyMacIconPng, + linuxIconPng: BRAND_ASSET_PATHS.nightlyLinuxIconPng, + windowsIconIco: BRAND_ASSET_PATHS.nightlyWindowsIconIco, + }; + } + + return { + macIconPng: BRAND_ASSET_PATHS.productionMacIconPng, + linuxIconPng: BRAND_ASSET_PATHS.productionLinuxIconPng, + windowsIconIco: BRAND_ASSET_PATHS.productionWindowsIconIco, }; } +export function resolveMockUpdateServerUrl(mockUpdateServerPort: number | undefined): string { + return `http://localhost:${mockUpdateServerPort ?? 3000}`; +} + +export function resolveDesktopProductName(version: string): string { + return resolveDesktopUpdateChannel(version) === "nightly" + ? "MarCode (Nightly)" + : (desktopPackageJson.productName ?? "MarCode"); +} + const createBuildConfig = Effect.fn("createBuildConfig")(function* ( platform: typeof BuildPlatform.Type, target: string, - productName: string, + version: string, signed: boolean, mockUpdates: boolean, - mockUpdateServerPort: string | undefined, + mockUpdateServerPort: number | undefined, ) { const buildConfig: Record = { appId: "com.tyulyukov.marcode", - productName, + productName: resolveDesktopProductName(version), artifactName: "MarCode-${version}-${arch}.${ext}", directories: { buildResources: "apps/desktop/resources", }, }; - const publishConfig = resolveGitHubPublishConfig(); + const updateChannel = resolveDesktopUpdateChannel(version); + const publishConfig = resolveGitHubPublishConfig(updateChannel); if (publishConfig) { buildConfig.publish = [publishConfig]; } else if (mockUpdates) { buildConfig.publish = [ { provider: "generic", - url: `http://localhost:${mockUpdateServerPort ?? 3000}`, + url: resolveMockUpdateServerUrl(mockUpdateServerPort), }, ]; } @@ -519,12 +612,15 @@ const createBuildConfig = Effect.fn("createBuildConfig")(function* ( } if (platform === "win") { + buildConfig.npmRebuild = false; const winConfig: Record = { target: [target], icon: "icon.ico", }; if (signed) { winConfig.azureSignOptions = yield* AzureTrustedSigningOptionsConfig; + } else { + winConfig.signAndEditExecutable = false; } buildConfig.win = winConfig; } @@ -535,20 +631,21 @@ const createBuildConfig = Effect.fn("createBuildConfig")(function* ( const assertPlatformBuildResources = Effect.fn("assertPlatformBuildResources")(function* ( platform: typeof BuildPlatform.Type, stageResourcesDir: string, + iconAssets: DesktopBuildIconAssets, verbose: boolean, ) { if (platform === "mac") { - yield* stageMacIcons(stageResourcesDir, verbose); + yield* stageMacIcons(stageResourcesDir, iconAssets.macIconPng, verbose); return; } if (platform === "linux") { - yield* stageLinuxIcons(stageResourcesDir); + yield* stageLinuxIcons(stageResourcesDir, iconAssets.linuxIconPng); return; } if (platform === "win") { - yield* stageWindowsIcons(stageResourcesDir); + yield* stageWindowsIcons(stageResourcesDir, iconAssets.windowsIconIco); } }); @@ -616,7 +713,8 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( }); const appVersion = options.version ?? serverPackageJson.version; - const commitHash = resolveGitCommitHash(repoRoot); + const iconAssets = resolveDesktopBuildIconAssets(appVersion); + const commitHash = yield* resolveGitCommitHash(repoRoot); const mkdir = options.keepStage ? fs.makeTempDirectory : fs.makeTempDirectoryScoped; const stageRoot = yield* mkdir({ prefix: `marcode-desktop-${options.platform}-stage-`, @@ -667,7 +765,16 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( yield* fs.copy(distDirs.desktopResources, stageResourcesDir); yield* fs.copy(distDirs.serverDist, path.join(stageAppDir, "apps/server/dist")); - yield* assertPlatformBuildResources(options.platform, stageResourcesDir, options.verbose); + yield* assertPlatformBuildResources( + options.platform, + stageResourcesDir, + { + macIconPng: path.join(repoRoot, iconAssets.macIconPng), + linuxIconPng: path.join(repoRoot, iconAssets.linuxIconPng), + windowsIconIco: path.join(repoRoot, iconAssets.windowsIconIco), + }, + options.verbose, + ); // electron-builder is filtering out stageResourcesDir directory in the AppImage for production yield* fs.copy(stageResourcesDir, path.join(stageAppDir, "apps/desktop/prod-resources")); @@ -680,7 +787,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( private: true, description: "MarCode desktop build", author: "tyulyukov", - main: "apps/desktop/dist-electron/main.js", + main: "apps/desktop/dist-electron/main.cjs", build: yield* createBuildConfig( options.platform, options.target, @@ -717,7 +824,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( }, ...commandOutputOptions(options.verbose), shell: process.platform === "win32", - })`bun install --production`, + })`bun install --production --omit optional`, ); const buildEnv: NodeJS.ProcessEnv = { @@ -738,7 +845,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( } if (process.platform === "win32") { - const python = resolvePythonForNodeGyp(); + const python = yield* resolvePythonForNodeGyp(); if (python) { buildEnv.PYTHON = python; buildEnv.npm_config_python = python; @@ -757,7 +864,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( ...commandOutputOptions(options.verbose), // Windows needs shell mode to resolve .cmd shims. shell: process.platform === "win32", - })`bunx electron-builder ${platformConfig.cliFlag} --${options.arch} --publish never`, + })`bun x --install=fallback electron-builder ${platformConfig.cliFlag} --${options.arch} --publish never`, ); const stageDistDir = path.join(stageAppDir, "dist"); @@ -841,7 +948,8 @@ const buildDesktopArtifactCli = Command.make("build-desktop-artifact", { Flag.withDescription("Enable mock updates (env: MARCODE_DESKTOP_MOCK_UPDATES)."), Flag.optional, ), - mockUpdateServerPort: Flag.string("mock-update-server-port").pipe( + mockUpdateServerPort: Flag.integer("mock-update-server-port").pipe( + Flag.withSchema(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }))), Flag.withDescription("Mock update server port (env: MARCODE_DESKTOP_MOCK_UPDATE_SERVER_PORT)."), Flag.optional, ), diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts index b869a46d193..722a8295bf6 100644 --- a/scripts/dev-runner.test.ts +++ b/scripts/dev-runner.test.ts @@ -1,8 +1,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { homedir } from "node:os"; -import { resolve } from "node:path"; +import * as NodeOS from "node:os"; import { assert, describe, it } from "@effect/vitest"; -import { Effect } from "effect"; +import { Effect, Path } from "effect"; import { checkPortAvailabilityOnHosts, @@ -49,6 +48,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { describe("createDevRunnerEnv", () => { it.effect("defaults MARCODE_HOME to ~/.marcode when not provided", () => Effect.gen(function* () { + const path = yield* Path.Path; const env = yield* createDevRunnerEnv({ mode: "dev", baseEnv: {}, @@ -64,12 +64,13 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { devUrl: undefined, }); - assert.equal(env.MARCODE_HOME, resolve(homedir(), ".marcode")); + assert.equal(env.MARCODE_HOME, path.resolve(NodeOS.homedir(), ".marcode")); }), ); it.effect("supports explicit typed overrides", () => Effect.gen(function* () { + const path = yield* Path.Path; const env = yield* createDevRunnerEnv({ mode: "dev:server", baseEnv: {}, @@ -85,7 +86,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { devUrl: new URL("http://localhost:7331"), }); - assert.equal(env.MARCODE_HOME, resolve("/tmp/custom-marcode")); + assert.equal(env.MARCODE_HOME, path.resolve("/tmp/custom-marcode")); assert.equal(env.MARCODE_PORT, "4222"); assert.equal(env.VITE_HTTP_URL, "http://localhost:4222"); assert.equal(env.VITE_WS_URL, "ws://localhost:4222"); @@ -146,6 +147,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { it.effect("uses custom marcodeHome when provided", () => Effect.gen(function* () { + const path = yield* Path.Path; const env = yield* createDevRunnerEnv({ mode: "dev", baseEnv: {}, @@ -161,12 +163,13 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { devUrl: undefined, }); - assert.equal(env.MARCODE_HOME, resolve("/tmp/my-marcode")); + assert.equal(env.MARCODE_HOME, path.resolve("/tmp/my-marcode")); }), ); it.effect("pins desktop dev to a stable backend port and websocket url", () => Effect.gen(function* () { + const path = yield* Path.Path; const env = yield* createDevRunnerEnv({ mode: "dev:desktop", baseEnv: { @@ -189,7 +192,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { devUrl: undefined, }); - assert.equal(env.MARCODE_HOME, resolve("/tmp/my-marcode")); + assert.equal(env.MARCODE_HOME, path.resolve("/tmp/my-marcode")); assert.equal(env.PORT, "5733"); assert.equal(env.VITE_DEV_SERVER_URL, "http://127.0.0.1:5733"); assert.equal(env.HOST, "127.0.0.1"); diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index 5d9066e1ff1..43d2f303ed6 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { homedir } from "node:os"; +import * as NodeOS from "node:os"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -17,7 +17,7 @@ const DESKTOP_DEV_LOOPBACK_HOST = "127.0.0.1"; const DEV_PORT_PROBE_HOSTS = ["127.0.0.1", "0.0.0.0", "::1", "::"] as const; export const DEFAULT_MARCODE_HOME = Effect.map(Effect.service(Path.Path), (path) => - path.join(homedir(), ".marcode"), + path.join(NodeOS.homedir(), ".marcode"), ); const MODE_ARGS = { @@ -539,11 +539,10 @@ const cliRuntimeLayer = Layer.mergeAll( NetService.layer, ); -const runtimeProgram = Command.run(devRunnerCli, { version: "0.0.0" }).pipe( - Effect.scoped, - Effect.provide(cliRuntimeLayer), -); - if (import.meta.main) { - NodeRuntime.runMain(runtimeProgram); + Command.run(devRunnerCli, { version: "0.0.0" }).pipe( + Effect.scoped, + Effect.provide(cliRuntimeLayer), + NodeRuntime.runMain, + ); } diff --git a/scripts/lib/brand-assets.ts b/scripts/lib/brand-assets.ts index 747515759b0..8d6218c688e 100644 --- a/scripts/lib/brand-assets.ts +++ b/scripts/lib/brand-assets.ts @@ -6,6 +6,12 @@ export const BRAND_ASSET_PATHS = { productionWebFavicon16Png: "assets/prod/marcode-web-favicon-16x16.png", productionWebFavicon32Png: "assets/prod/marcode-web-favicon-32x32.png", productionWebAppleTouchIconPng: "assets/prod/marcode-web-apple-touch-180.png", + + nightlyMacIconPng: "assets/nightly/blueprint-macos-1024.png", + nightlyLinuxIconPng: "assets/nightly/blueprint-universal-1024.png", + nightlyWindowsIconIco: "assets/nightly/blueprint-windows.ico", + + developmentDesktopIconPng: "assets/dev/blueprint-macos-1024.png", developmentWindowsIconIco: "assets/dev/blueprint-windows.ico", developmentWebFaviconIco: "assets/dev/blueprint-web-favicon.ico", developmentWebFavicon16Png: "assets/dev/blueprint-web-favicon-16x16.png", diff --git a/scripts/lib/build-target-arch.test.ts b/scripts/lib/build-target-arch.test.ts new file mode 100644 index 00000000000..56251d3ffd1 --- /dev/null +++ b/scripts/lib/build-target-arch.test.ts @@ -0,0 +1,61 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { getDefaultBuildArch, resolveHostProcessArch } from "./build-target-arch.ts"; + +describe("build-target-arch", () => { + it("prefers arm64 for Windows-on-Arm hosts running x64 emulation", () => { + // Windows-on-Arm can run an x64 Node process under emulation while still + // exposing the real host CPU via PROCESSOR_ARCHITEW6432. + const hostArch = resolveHostProcessArch("win32", "x64", { + PROCESSOR_ARCHITECTURE: "AMD64", // The currently running Node process is x64. + PROCESSOR_ARCHITEW6432: "ARM64", // Windows exposes the real host CPU here when x64 runs under ARM emulation. + }); + + assert.equal(hostArch, "arm64"); + }); + + it("falls back to x64 for native x64 Windows hosts", () => { + const hostArch = resolveHostProcessArch("win32", "x64", { + PROCESSOR_ARCHITECTURE: "AMD64", // Both the process and the Windows host are native x64. + }); + + assert.equal(hostArch, "x64"); + }); + + it("keeps arm64 when the current process is already native arm64", () => { + const hostArch = resolveHostProcessArch("win32", "arm64", {}); + + assert.equal(hostArch, "arm64"); + }); + + it("uses the resolved host arch when selecting the default Windows build arch", () => { + // This mirrors the packaging script's default-path behavior: the current + // process is x64, but the machine itself is ARM64, so the default build + // target should be win-arm64 rather than win-x64. + const arch = getDefaultBuildArch( + "win", + "x64", + { + PROCESSOR_ARCHITECTURE: "AMD64", // The currently running Node process is x64. + PROCESSOR_ARCHITEW6432: "ARM64", // The process is x64, but the actual Windows host is ARM64. + }, + { archChoices: ["x64", "arm64"] }, + ); + + assert.equal(arch, "arm64"); + }); + + it("does not apply Windows host env heuristics for non-Windows targets", () => { + const arch = getDefaultBuildArch( + "linux", + "x64", + { + PROCESSOR_ARCHITECTURE: "AMD64", + PROCESSOR_ARCHITEW6432: "ARM64", + }, + { archChoices: ["x64", "arm64"] }, + ); + + assert.equal(arch, "x64"); + }); +}); diff --git a/scripts/lib/build-target-arch.ts b/scripts/lib/build-target-arch.ts new file mode 100644 index 00000000000..8c39648414a --- /dev/null +++ b/scripts/lib/build-target-arch.ts @@ -0,0 +1,50 @@ +export type BuildArch = "arm64" | "x64" | "universal"; +export type BuildPlatform = "mac" | "linux" | "win"; + +interface PlatformConfig { + readonly archChoices: ReadonlyArray; +} + +function normalizeWindowsArch(value: string | undefined): BuildArch | undefined { + const normalized = value?.trim().toLowerCase(); + if (!normalized) return undefined; + if (normalized.includes("arm64") || normalized === "aarch64") return "arm64"; + if (normalized.includes("amd64") || normalized.includes("x64")) return "x64"; + return undefined; +} + +export function resolveHostProcessArch( + platform: NodeJS.Platform, + processArch: NodeJS.Architecture, + env: NodeJS.ProcessEnv, +): BuildArch | undefined { + if (processArch === "arm64") return "arm64"; + if (processArch === "x64") { + if (platform !== "win32") return "x64"; + + // On Windows-on-Arm, x64 Node/Bun can run under emulation while the host + // still reports ARM64 via the processor environment variables. + return ( + normalizeWindowsArch(env.PROCESSOR_ARCHITEW6432) ?? + normalizeWindowsArch(env.PROCESSOR_ARCHITECTURE) ?? + "x64" + ); + } + return undefined; +} + +export function getDefaultBuildArch( + platform: BuildPlatform, + processArch: NodeJS.Architecture, + env: NodeJS.ProcessEnv, + platformConfig: PlatformConfig, +): BuildArch { + const hostPlatform: NodeJS.Platform = + platform === "win" ? "win32" : platform === "mac" ? "darwin" : "linux"; + const hostArch = resolveHostProcessArch(hostPlatform, processArch, env); + if (hostArch && platformConfig.archChoices.includes(hostArch)) { + return hostArch; + } + + return platformConfig.archChoices[0] ?? "x64"; +} diff --git a/scripts/merge-mac-update-manifests.ts b/scripts/lib/update-manifest.ts similarity index 58% rename from scripts/merge-mac-update-manifests.ts rename to scripts/lib/update-manifest.ts index c59bc76b9b0..191a3c0e535 100644 --- a/scripts/merge-mac-update-manifests.ts +++ b/scripts/lib/update-manifest.ts @@ -1,23 +1,19 @@ -import { readFileSync, writeFileSync } from "node:fs"; -import { resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -interface MacUpdateFile { +export interface UpdateManifestFile { readonly url: string; readonly sha512: string; readonly size: number; } -type MacUpdateScalar = string | number | boolean; +export type UpdateManifestScalar = string | number | boolean; -interface MacUpdateManifest { +export interface UpdateManifest { readonly version: string; readonly releaseDate: string; - readonly files: ReadonlyArray; - readonly extras: Readonly>; + readonly files: ReadonlyArray; + readonly extras: Readonly>; } -interface MutableMacUpdateFile { +interface MutableUpdateManifestFile { url?: string; sha512?: string; size?: number; @@ -31,10 +27,11 @@ function stripSingleQuotes(value: string): string { } function parseFileRecord( - currentFile: MutableMacUpdateFile | null, + currentFile: MutableUpdateManifestFile | null, sourcePath: string, lineNumber: number, -): MacUpdateFile | null { + platformLabel: string, +): UpdateManifestFile | null { if (currentFile === null) { return null; } @@ -44,7 +41,7 @@ function parseFileRecord( typeof currentFile.size !== "number" ) { throw new Error( - `Invalid macOS update manifest at ${sourcePath}:${lineNumber}: incomplete file entry.`, + `Invalid ${platformLabel} update manifest at ${sourcePath}:${lineNumber}: incomplete file entry.`, ); } return { @@ -54,7 +51,7 @@ function parseFileRecord( }; } -function parseScalarValue(rawValue: string): MacUpdateScalar { +function parseScalarValue(rawValue: string): UpdateManifestScalar { const trimmed = rawValue.trim(); const isQuoted = trimmed.startsWith("'") && trimmed.endsWith("'") && trimmed.length >= 2; const value = isQuoted ? trimmed.slice(1, -1).replace(/''/g, "'") : trimmed; @@ -67,14 +64,18 @@ function parseScalarValue(rawValue: string): MacUpdateScalar { return value; } -export function parseMacUpdateManifest(raw: string, sourcePath: string): MacUpdateManifest { +export function parseUpdateManifest( + raw: string, + sourcePath: string, + platformLabel: string, +): UpdateManifest { const lines = raw.split(/\r?\n/); - const files: MacUpdateFile[] = []; - const extras: Record = {}; + const files: UpdateManifestFile[] = []; + const extras: Record = {}; let version: string | null = null; let releaseDate: string | null = null; let inFiles = false; - let currentFile: MutableMacUpdateFile | null = null; + let currentFile: MutableUpdateManifestFile | null = null; for (const [index, rawLine] of lines.entries()) { const lineNumber = index + 1; @@ -83,7 +84,7 @@ export function parseMacUpdateManifest(raw: string, sourcePath: string): MacUpda const fileUrlMatch = line.match(/^ - url:\s*(.+)$/); if (fileUrlMatch?.[1]) { - const finalized = parseFileRecord(currentFile, sourcePath, lineNumber); + const finalized = parseFileRecord(currentFile, sourcePath, lineNumber, platformLabel); if (finalized) files.push(finalized); currentFile = { url: stripSingleQuotes(fileUrlMatch[1].trim()) }; inFiles = true; @@ -94,7 +95,7 @@ export function parseMacUpdateManifest(raw: string, sourcePath: string): MacUpda if (fileShaMatch?.[1]) { if (currentFile === null) { throw new Error( - `Invalid macOS update manifest at ${sourcePath}:${lineNumber}: sha512 without a file entry.`, + `Invalid ${platformLabel} update manifest at ${sourcePath}:${lineNumber}: sha512 without a file entry.`, ); } currentFile.sha512 = stripSingleQuotes(fileShaMatch[1].trim()); @@ -105,7 +106,7 @@ export function parseMacUpdateManifest(raw: string, sourcePath: string): MacUpda if (fileSizeMatch?.[1]) { if (currentFile === null) { throw new Error( - `Invalid macOS update manifest at ${sourcePath}:${lineNumber}: size without a file entry.`, + `Invalid ${platformLabel} update manifest at ${sourcePath}:${lineNumber}: size without a file entry.`, ); } currentFile.size = Number(fileSizeMatch[1]); @@ -118,7 +119,7 @@ export function parseMacUpdateManifest(raw: string, sourcePath: string): MacUpda } if (inFiles && currentFile !== null) { - const finalized = parseFileRecord(currentFile, sourcePath, lineNumber); + const finalized = parseFileRecord(currentFile, sourcePath, lineNumber, platformLabel); if (finalized) files.push(finalized); currentFile = null; } @@ -127,7 +128,7 @@ export function parseMacUpdateManifest(raw: string, sourcePath: string): MacUpda const topLevelMatch = line.match(/^([A-Za-z][A-Za-z0-9]*):\s*(.+)$/); if (!topLevelMatch?.[1] || topLevelMatch[2] === undefined) { throw new Error( - `Invalid macOS update manifest at ${sourcePath}:${lineNumber}: unsupported line '${line}'.`, + `Invalid ${platformLabel} update manifest at ${sourcePath}:${lineNumber}: unsupported line '${line}'.`, ); } @@ -137,7 +138,7 @@ export function parseMacUpdateManifest(raw: string, sourcePath: string): MacUpda if (key === "version") { if (typeof value !== "string") { throw new Error( - `Invalid macOS update manifest at ${sourcePath}:${lineNumber}: version must be a string.`, + `Invalid ${platformLabel} update manifest at ${sourcePath}:${lineNumber}: version must be a string.`, ); } version = value; @@ -147,7 +148,7 @@ export function parseMacUpdateManifest(raw: string, sourcePath: string): MacUpda if (key === "releaseDate") { if (typeof value !== "string") { throw new Error( - `Invalid macOS update manifest at ${sourcePath}:${lineNumber}: releaseDate must be a string.`, + `Invalid ${platformLabel} update manifest at ${sourcePath}:${lineNumber}: releaseDate must be a string.`, ); } releaseDate = value; @@ -161,17 +162,19 @@ export function parseMacUpdateManifest(raw: string, sourcePath: string): MacUpda extras[key] = value; } - const finalized = parseFileRecord(currentFile, sourcePath, lines.length); + const finalized = parseFileRecord(currentFile, sourcePath, lines.length, platformLabel); if (finalized) files.push(finalized); if (!version) { - throw new Error(`Invalid macOS update manifest at ${sourcePath}: missing version.`); + throw new Error(`Invalid ${platformLabel} update manifest at ${sourcePath}: missing version.`); } if (!releaseDate) { - throw new Error(`Invalid macOS update manifest at ${sourcePath}: missing releaseDate.`); + throw new Error( + `Invalid ${platformLabel} update manifest at ${sourcePath}: missing releaseDate.`, + ); } if (files.length === 0) { - throw new Error(`Invalid macOS update manifest at ${sourcePath}: missing files.`); + throw new Error(`Invalid ${platformLabel} update manifest at ${sourcePath}: missing files.`); } return { @@ -183,16 +186,17 @@ export function parseMacUpdateManifest(raw: string, sourcePath: string): MacUpda } function mergeExtras( - primary: Readonly>, - secondary: Readonly>, -): Record { - const merged: Record = { ...primary }; + primary: Readonly>, + secondary: Readonly>, + platformLabel: string, +): Record { + const merged: Record = { ...primary }; for (const [key, value] of Object.entries(secondary)) { const existing = merged[key]; if (existing !== undefined && existing !== value) { throw new Error( - `Cannot merge macOS update manifests: conflicting '${key}' values ('${existing}' vs '${value}').`, + `Cannot merge ${platformLabel} update manifests: conflicting '${key}' values ('${existing}' vs '${value}').`, ); } merged[key] = value; @@ -201,22 +205,23 @@ function mergeExtras( return merged; } -export function mergeMacUpdateManifests( - primary: MacUpdateManifest, - secondary: MacUpdateManifest, -): MacUpdateManifest { +export function mergeUpdateManifests( + primary: UpdateManifest, + secondary: UpdateManifest, + platformLabel: string, +): UpdateManifest { if (primary.version !== secondary.version) { throw new Error( - `Cannot merge macOS update manifests with different versions (${primary.version} vs ${secondary.version}).`, + `Cannot merge ${platformLabel} update manifests with different versions (${primary.version} vs ${secondary.version}).`, ); } - const filesByUrl = new Map(); + const filesByUrl = new Map(); for (const file of [...primary.files, ...secondary.files]) { const existing = filesByUrl.get(file.url); if (existing && (existing.sha512 !== file.sha512 || existing.size !== file.size)) { throw new Error( - `Cannot merge macOS update manifests: conflicting file entry for ${file.url}.`, + `Cannot merge ${platformLabel} update manifests: conflicting file entry for ${file.url}.`, ); } filesByUrl.set(file.url, file); @@ -227,7 +232,7 @@ export function mergeMacUpdateManifests( releaseDate: primary.releaseDate >= secondary.releaseDate ? primary.releaseDate : secondary.releaseDate, files: [...filesByUrl.values()], - extras: mergeExtras(primary.extras, secondary.extras), + extras: mergeExtras(primary.extras, secondary.extras, platformLabel), }; } @@ -235,15 +240,20 @@ function quoteYamlString(value: string): string { return `'${value.replace(/'/g, "''")}'`; } -function serializeScalarValue(value: MacUpdateScalar): string { +function serializeScalarValue(value: UpdateManifestScalar): string { if (typeof value === "string") { return quoteYamlString(value); } return String(value); } -export function serializeMacUpdateManifest(manifest: MacUpdateManifest): string { - const lines = [`version: ${manifest.version}`, "files:"]; +export function serializeUpdateManifest( + manifest: UpdateManifest, + options: { + readonly platformLabel: string; + }, +): string { + const lines = [`version: ${quoteYamlString(manifest.version)}`, "files:"]; for (const file of manifest.files) { lines.push(` - url: ${file.url}`); @@ -254,7 +264,9 @@ export function serializeMacUpdateManifest(manifest: MacUpdateManifest): string for (const key of Object.keys(manifest.extras).toSorted()) { const value = manifest.extras[key]; if (value === undefined) { - throw new Error(`Cannot serialize macOS update manifest: missing value for '${key}'.`); + throw new Error( + `Cannot serialize ${options.platformLabel} update manifest: missing value for '${key}'.`, + ); } lines.push(`${key}: ${serializeScalarValue(value)}`); } @@ -263,25 +275,3 @@ export function serializeMacUpdateManifest(manifest: MacUpdateManifest): string lines.push(""); return lines.join("\n"); } - -function main(args: ReadonlyArray): void { - const [arm64PathArg, x64PathArg, outputPathArg] = args; - if (!arm64PathArg || !x64PathArg) { - throw new Error( - "Usage: node scripts/merge-mac-update-manifests.ts [output-path]", - ); - } - - const arm64Path = resolve(arm64PathArg); - const x64Path = resolve(x64PathArg); - const outputPath = resolve(outputPathArg ?? arm64PathArg); - - const arm64Manifest = parseMacUpdateManifest(readFileSync(arm64Path, "utf8"), arm64Path); - const x64Manifest = parseMacUpdateManifest(readFileSync(x64Path, "utf8"), x64Path); - const merged = mergeMacUpdateManifests(arm64Manifest, x64Manifest); - writeFileSync(outputPath, serializeMacUpdateManifest(merged)); -} - -if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { - main(process.argv.slice(2)); -} diff --git a/scripts/merge-mac-update-manifests.test.ts b/scripts/merge-mac-update-manifests.test.ts deleted file mode 100644 index dca8cf06e26..00000000000 --- a/scripts/merge-mac-update-manifests.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; - -import { - mergeMacUpdateManifests, - parseMacUpdateManifest, - serializeMacUpdateManifest, -} from "./merge-mac-update-manifests.ts"; - -describe("merge-mac-update-manifests", () => { - it("merges arm64 and x64 macOS update manifests into one multi-arch manifest", () => { - const arm64 = parseMacUpdateManifest( - `version: 0.0.4 -files: - - url: MarCode-0.0.4-arm64.zip - sha512: arm64zip - size: 125621344 - - url: MarCode-0.0.4-arm64.dmg - sha512: arm64dmg - size: 131754935 -path: MarCode-0.0.4-arm64.zip -sha512: arm64zip -releaseDate: '2026-03-07T10:32:14.587Z' -`, - "latest-mac.yml", - ); - - const x64 = parseMacUpdateManifest( - `version: 0.0.4 -files: - - url: MarCode-0.0.4-x64.zip - sha512: x64zip - size: 132000112 - - url: MarCode-0.0.4-x64.dmg - sha512: x64dmg - size: 138148807 -path: MarCode-0.0.4-x64.zip -sha512: x64zip -releaseDate: '2026-03-07T10:36:07.540Z' -`, - "latest-mac-x64.yml", - ); - - const merged = mergeMacUpdateManifests(arm64, x64); - - assert.equal(merged.version, "0.0.4"); - assert.equal(merged.releaseDate, "2026-03-07T10:36:07.540Z"); - assert.deepStrictEqual( - merged.files.map((file) => file.url), - [ - "MarCode-0.0.4-arm64.zip", - "MarCode-0.0.4-arm64.dmg", - "MarCode-0.0.4-x64.zip", - "MarCode-0.0.4-x64.dmg", - ], - ); - - const serialized = serializeMacUpdateManifest(merged); - assert.ok(!serialized.includes("path:")); - assert.equal((serialized.match(/- url:/g) ?? []).length, 4); - }); - - it("rejects mismatched manifest versions", () => { - const arm64 = parseMacUpdateManifest( - `version: 0.0.4 -files: - - url: MarCode-0.0.4-arm64.zip - sha512: arm64zip - size: 1 -releaseDate: '2026-03-07T10:32:14.587Z' -`, - "latest-mac.yml", - ); - - const x64 = parseMacUpdateManifest( - `version: 0.0.5 -files: - - url: MarCode-0.0.5-x64.zip - sha512: x64zip - size: 1 -releaseDate: '2026-03-07T10:36:07.540Z' -`, - "latest-mac-x64.yml", - ); - - assert.throws(() => mergeMacUpdateManifests(arm64, x64), /different versions/); - }); - - it("preserves quoted scalars as strings", () => { - const manifest = parseMacUpdateManifest( - `version: '1.0' -files: - - url: MarCode-1.0-x64.zip - sha512: zipsha - size: 1 -releaseName: 'true' -minimumSystemVersion: '13.0' -stagingPercentage: 50 -releaseDate: '2026-03-07T10:36:07.540Z' -`, - "latest-mac.yml", - ); - - assert.equal(manifest.version, "1.0"); - assert.equal(manifest.extras.releaseName, "true"); - assert.equal(manifest.extras.minimumSystemVersion, "13.0"); - assert.equal(manifest.extras.stagingPercentage, 50); - }); -}); diff --git a/scripts/merge-update-manifests.test.ts b/scripts/merge-update-manifests.test.ts new file mode 100644 index 00000000000..3f2e3b08713 --- /dev/null +++ b/scripts/merge-update-manifests.test.ts @@ -0,0 +1,306 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { Effect, FileSystem, Path } from "effect"; +import { Command, CliError } from "effect/unstable/cli"; + +import { + mergePlatformUpdateManifests, + mergeUpdateManifestsCommand, + parsePlatformUpdateManifest, + serializePlatformUpdateManifest, +} from "./merge-update-manifests.ts"; + +const runCli = Command.runWith(mergeUpdateManifestsCommand, { version: "0.0.0" }); + +describe("merge-update-manifests", () => { + it("merges arm64 and x64 macOS update manifests into one multi-arch manifest", () => { + const arm64 = parsePlatformUpdateManifest( + "mac", + `version: 0.0.4 +files: + - url: T3-Code-0.0.4-arm64.zip + sha512: arm64zip + size: 125621344 + - url: T3-Code-0.0.4-arm64.dmg + sha512: arm64dmg + size: 131754935 +path: T3-Code-0.0.4-arm64.zip +sha512: arm64zip +releaseDate: '2026-03-07T10:32:14.587Z' +`, + "latest-mac.yml", + ); + + const x64 = parsePlatformUpdateManifest( + "mac", + `version: 0.0.4 +files: + - url: T3-Code-0.0.4-x64.zip + sha512: x64zip + size: 132000112 + - url: T3-Code-0.0.4-x64.dmg + sha512: x64dmg + size: 138148807 +path: T3-Code-0.0.4-x64.zip +sha512: x64zip +releaseDate: '2026-03-07T10:36:07.540Z' +`, + "latest-mac-x64.yml", + ); + + const merged = mergePlatformUpdateManifests("mac", arm64, x64); + + assert.equal(merged.version, "0.0.4"); + assert.equal(merged.releaseDate, "2026-03-07T10:36:07.540Z"); + assert.deepStrictEqual( + merged.files.map((file) => file.url), + [ + "T3-Code-0.0.4-arm64.zip", + "T3-Code-0.0.4-arm64.dmg", + "T3-Code-0.0.4-x64.zip", + "T3-Code-0.0.4-x64.dmg", + ], + ); + + const serialized = serializePlatformUpdateManifest("mac", merged); + assert.ok(!serialized.includes("path:")); + assert.equal((serialized.match(/- url:/g) ?? []).length, 4); + }); + + it("merges arm64 and x64 Windows update manifests into one multi-arch manifest", () => { + const arm64 = parsePlatformUpdateManifest( + "win", + `version: 0.0.4 +files: + - url: T3-Code-0.0.4-arm64.exe + sha512: arm64exe + size: 125621344 + - url: T3-Code-0.0.4-arm64.exe.blockmap + sha512: arm64blockmap + size: 131754 +path: T3-Code-0.0.4-arm64.exe +sha512: arm64exe +releaseDate: '2026-03-07T10:32:14.587Z' +`, + "latest-win-arm64.yml", + ); + + const x64 = parsePlatformUpdateManifest( + "win", + `version: 0.0.4 +files: + - url: T3-Code-0.0.4-x64.exe + sha512: x64exe + size: 132000112 + - url: T3-Code-0.0.4-x64.exe.blockmap + sha512: x64blockmap + size: 138148 +path: T3-Code-0.0.4-x64.exe +sha512: x64exe +releaseDate: '2026-03-07T10:36:07.540Z' +`, + "latest-win-x64.yml", + ); + + const merged = mergePlatformUpdateManifests("win", arm64, x64); + + assert.equal(merged.version, "0.0.4"); + assert.equal(merged.releaseDate, "2026-03-07T10:36:07.540Z"); + assert.deepStrictEqual( + merged.files.map((file) => file.url), + [ + "T3-Code-0.0.4-arm64.exe", + "T3-Code-0.0.4-arm64.exe.blockmap", + "T3-Code-0.0.4-x64.exe", + "T3-Code-0.0.4-x64.exe.blockmap", + ], + ); + + const serialized = serializePlatformUpdateManifest("win", merged); + assert.ok(!serialized.includes("path:")); + assert.equal((serialized.match(/- url:/g) ?? []).length, 4); + }); + + it("rejects mismatched manifest versions", () => { + const primary = parsePlatformUpdateManifest( + "win", + `version: 0.0.4 +files: + - url: T3-Code-0.0.4-arm64.exe + sha512: arm64exe + size: 1 +releaseDate: '2026-03-07T10:32:14.587Z' +`, + "latest-win-arm64.yml", + ); + + const secondary = parsePlatformUpdateManifest( + "win", + `version: 0.0.5 +files: + - url: T3-Code-0.0.5-x64.exe + sha512: x64exe + size: 1 +releaseDate: '2026-03-07T10:36:07.540Z' +`, + "latest-win-x64.yml", + ); + + assert.throws( + () => mergePlatformUpdateManifests("win", primary, secondary), + /different versions/, + ); + }); + + it("preserves quoted scalars as strings", () => { + const manifest = parsePlatformUpdateManifest( + "mac", + `version: '1.0' +files: + - url: T3-Code-1.0-x64.zip + sha512: zipsha + size: 1 +releaseName: 'true' +minimumSystemVersion: '13.0' +stagingPercentage: 50 +releaseDate: '2026-03-07T10:36:07.540Z' +`, + "latest-mac.yml", + ); + + assert.equal(manifest.version, "1.0"); + assert.equal(manifest.extras.releaseName, "true"); + assert.equal(manifest.extras.minimumSystemVersion, "13.0"); + assert.equal(manifest.extras.stagingPercentage, 50); + }); + + it("round-trips numeric-looking versions as strings", () => { + const original = parsePlatformUpdateManifest( + "win", + `version: '1.0' +files: + - url: T3-Code-1.0-x64.exe + sha512: exesha + size: 1 +releaseDate: '2026-03-07T10:36:07.540Z' +`, + "latest-win-x64.yml", + ); + + const serialized = serializePlatformUpdateManifest("win", original); + assert.ok(serialized.includes("version: '1.0'")); + + const reparsed = parsePlatformUpdateManifest("win", serialized, "latest-win-x64.yml"); + assert.equal(reparsed.version, "1.0"); + }); +}); + +it.layer(NodeServices.layer)("merge-update-manifests cli", (it) => { + const arm64MacManifest = `version: 0.0.4 +files: + - url: T3-Code-0.0.4-arm64.zip + sha512: arm64zip + size: 125621344 + - url: T3-Code-0.0.4-arm64.dmg + sha512: arm64dmg + size: 131754935 +path: T3-Code-0.0.4-arm64.zip +sha512: arm64zip +releaseDate: '2026-03-07T10:32:14.587Z' +`; + + const x64MacManifest = `version: 0.0.4 +files: + - url: T3-Code-0.0.4-x64.zip + sha512: x64zip + size: 132000112 + - url: T3-Code-0.0.4-x64.dmg + sha512: x64dmg + size: 138148807 +path: T3-Code-0.0.4-x64.zip +sha512: x64zip +releaseDate: '2026-03-07T10:36:07.540Z' +`; + + it.effect("writes the merged manifest back to the primary path by default", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ + prefix: "merge-update-manifests-cli-", + }); + const primaryPath = path.join(baseDir, "latest-mac.yml"); + const secondaryPath = path.join(baseDir, "latest-mac-x64.yml"); + + yield* fs.writeFileString(primaryPath, arm64MacManifest); + yield* fs.writeFileString(secondaryPath, x64MacManifest); + + yield* runCli(["--platform", "mac", primaryPath, secondaryPath]); + + const merged = yield* fs.readFileString(primaryPath); + assert.ok(merged.includes("T3-Code-0.0.4-arm64.zip")); + assert.ok(merged.includes("T3-Code-0.0.4-x64.zip")); + assert.ok(!merged.includes("path:")); + }), + ); + + it.effect("writes the merged manifest to an explicit output path", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ + prefix: "merge-update-manifests-cli-output-", + }); + const primaryPath = path.join(baseDir, "latest-win-arm64.yml"); + const secondaryPath = path.join(baseDir, "latest-win-x64.yml"); + const outputPath = path.join(baseDir, "latest-win.yml"); + + yield* fs.writeFileString( + primaryPath, + `version: 0.0.4 +files: + - url: T3-Code-0.0.4-arm64.exe + sha512: arm64exe + size: 125621344 +releaseDate: '2026-03-07T10:32:14.587Z' +`, + ); + yield* fs.writeFileString( + secondaryPath, + `version: 0.0.4 +files: + - url: T3-Code-0.0.4-x64.exe + sha512: x64exe + size: 132000112 +releaseDate: '2026-03-07T10:36:07.540Z' +`, + ); + + yield* runCli(["--platform", "win", primaryPath, secondaryPath, outputPath]); + + const merged = yield* fs.readFileString(outputPath); + assert.ok(merged.includes("T3-Code-0.0.4-arm64.exe")); + assert.ok(merged.includes("T3-Code-0.0.4-x64.exe")); + }), + ); + + it.effect("rejects invalid platform values during cli parsing", () => + Effect.gen(function* () { + const error = yield* runCli(["--platform", "linux", "a.yml", "b.yml"]).pipe(Effect.flip); + + if (!CliError.isCliError(error)) { + assert.fail(`Expected CliError, got ${String(error)}`); + } + + const platformError = + error._tag === "ShowHelp" ? (error.errors[0] as CliError.CliError | undefined) : error; + + if (!platformError || platformError._tag !== "InvalidValue") { + assert.fail(`Expected InvalidValue, got ${String(platformError?._tag)}`); + } + + assert.equal(platformError.option, "platform"); + assert.equal(platformError.value, "linux"); + }), + ); +}); diff --git a/scripts/merge-update-manifests.ts b/scripts/merge-update-manifests.ts new file mode 100644 index 00000000000..1913cd7113f --- /dev/null +++ b/scripts/merge-update-manifests.ts @@ -0,0 +1,108 @@ +#!/usr/bin/env node + +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { Effect, FileSystem, Option, Path, Schema } from "effect"; +import { Argument, Command, Flag } from "effect/unstable/cli"; + +import { + mergeUpdateManifests, + parseUpdateManifest, + serializeUpdateManifest, + type UpdateManifest, +} from "./lib/update-manifest.ts"; + +const UpdateManifestPlatform = Schema.Literals(["mac", "win"]); +export type UpdateManifestPlatform = typeof UpdateManifestPlatform.Type; + +function getPlatformLabel(platform: UpdateManifestPlatform): string { + return platform === "mac" ? "macOS" : "Windows"; +} + +export function parsePlatformUpdateManifest( + platform: UpdateManifestPlatform, + raw: string, + sourcePath: string, +): UpdateManifest { + return parseUpdateManifest(raw, sourcePath, getPlatformLabel(platform)); +} + +export function mergePlatformUpdateManifests( + platform: UpdateManifestPlatform, + primary: UpdateManifest, + secondary: UpdateManifest, +): UpdateManifest { + return mergeUpdateManifests(primary, secondary, getPlatformLabel(platform)); +} + +export function serializePlatformUpdateManifest( + platform: UpdateManifestPlatform, + manifest: UpdateManifest, +): string { + return serializeUpdateManifest(manifest, { + platformLabel: getPlatformLabel(platform), + }); +} + +export const mergeUpdateManifestFiles = Effect.fn("mergeUpdateManifestFiles")(function* ( + platform: UpdateManifestPlatform, + primaryPathArg: string, + secondaryPathArg: string, + outputPathArg: string | undefined, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const primaryPath = path.resolve(primaryPathArg); + const secondaryPath = path.resolve(secondaryPathArg); + const outputPath = path.resolve(outputPathArg ?? primaryPathArg); + + const primaryManifest = parsePlatformUpdateManifest( + platform, + yield* fs.readFileString(primaryPath), + primaryPath, + ); + const secondaryManifest = parsePlatformUpdateManifest( + platform, + yield* fs.readFileString(secondaryPath), + secondaryPath, + ); + const merged = mergePlatformUpdateManifests(platform, primaryManifest, secondaryManifest); + + yield* fs.writeFileString(outputPath, serializePlatformUpdateManifest(platform, merged)); +}); + +export const mergeUpdateManifestsCommand = Command.make( + "merge-update-manifests", + { + platform: Flag.choice("platform", UpdateManifestPlatform.literals).pipe( + Flag.withDescription("Update manifest platform."), + ), + primaryPath: Argument.string("primary-path").pipe( + Argument.withDescription("Primary update manifest path. Defaults to the output path."), + ), + secondaryPath: Argument.string("secondary-path").pipe( + Argument.withDescription( + "Secondary update manifest path to merge into the primary manifest.", + ), + ), + outputPath: Argument.string("output-path").pipe( + Argument.withDescription("Optional output path for the merged manifest."), + Argument.optional, + ), + }, + ({ platform, primaryPath, secondaryPath, outputPath }) => + mergeUpdateManifestFiles( + platform, + primaryPath, + secondaryPath, + Option.getOrUndefined(outputPath), + ), +).pipe(Command.withDescription("Merge two Electron updater manifests into a multi-arch manifest.")); + +if (import.meta.main) { + Command.run(mergeUpdateManifestsCommand, { version: "0.0.0" }).pipe( + Effect.provide(NodeServices.layer), + NodeRuntime.runMain, + ); +} diff --git a/scripts/mock-update-server.test.ts b/scripts/mock-update-server.test.ts new file mode 100644 index 00000000000..218dcd224f4 --- /dev/null +++ b/scripts/mock-update-server.test.ts @@ -0,0 +1,104 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { NodeHttpServer } from "@effect/platform-node"; +import { assert, it } from "@effect/vitest"; +import { Effect, FileSystem, Layer, Path } from "effect"; +import { HttpClient, HttpRouter } from "effect/unstable/http"; + +import { makeMockUpdateRouteLayer } from "./mock-update-server.ts"; + +const withMockUpdateServer = (rootRealPath: string, effect: Effect.Effect) => + effect.pipe( + Effect.provide( + HttpRouter.serve(makeMockUpdateRouteLayer(rootRealPath), { + disableListenLog: true, + disableLogger: true, + }).pipe(Layer.provideMerge(NodeHttpServer.layerTest)), + ), + ); + +it.layer(NodeServices.layer)("mock-update-server", (it) => { + it.effect("serves files from the configured root", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "mock-update-server-root-", + }); + const rootRealPath = yield* fileSystem.realPath(root); + const filePath = path.join(root, "latest.yml"); + + yield* fileSystem.writeFileString(filePath, "version: 0.0.1\n"); + + yield* withMockUpdateServer( + rootRealPath, + Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const response = yield* client.get("/latest.yml"); + + assert.equal(response.status, 200); + assert.equal(response.headers["content-type"], "text/yaml"); + assert.equal(yield* response.text, "version: 0.0.1\n"); + }), + ); + }), + ); + + it.effect("rejects encoded path traversal outside the configured root", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "mock-update-server-root-", + }); + const outside = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "mock-update-server-outside-", + }); + const rootRealPath = yield* fileSystem.realPath(root); + + yield* fileSystem.writeFileString(path.join(outside, "secret.txt"), "nope\n"); + + yield* withMockUpdateServer( + rootRealPath, + Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const response = yield* client.get("/%2e%2e/secret.txt"); + + assert.equal(response.status, 404); + assert.equal(yield* response.text, "Not Found"); + }), + ); + }), + ); + + it.effect("rejects symlinked files that escape the configured root", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "mock-update-server-root-", + }); + const outside = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "mock-update-server-outside-", + }); + const rootRealPath = yield* fileSystem.realPath(root); + const outsideFile = path.join(outside, "outside.yml"); + const linksDir = path.join(root, "links"); + const symlinkPath = path.join(linksDir, "outside.yml"); + + yield* fileSystem.writeFileString(outsideFile, "version: outside\n"); + yield* fileSystem.makeDirectory(linksDir, { recursive: true }); + yield* fileSystem.symlink(outsideFile, symlinkPath); + + yield* withMockUpdateServer( + rootRealPath, + Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const response = yield* client.get("/links/outside.yml"); + + assert.equal(response.status, 404); + assert.equal(yield* response.text, "Not Found"); + }), + ); + }), + ); +}); diff --git a/scripts/mock-update-server.ts b/scripts/mock-update-server.ts index aef58dec021..19ee88b9588 100644 --- a/scripts/mock-update-server.ts +++ b/scripts/mock-update-server.ts @@ -1,44 +1,154 @@ -import { resolve, relative } from "node:path"; -import { realpathSync } from "node:fs"; +import * as NodeHttp from "node:http"; -const port = Number(process.env.MARCODE_DESKTOP_MOCK_UPDATE_SERVER_PORT ?? 3000); -const root = - process.env.MARCODE_DESKTOP_MOCK_UPDATE_SERVER_ROOT ?? - resolve(import.meta.dirname, "..", "release-mock"); +import { NodeHttpServer, NodeRuntime } from "@effect/platform-node"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { Config, Effect, FileSystem, Layer, Path } from "effect"; +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; -const mockServerLog = (level: "info" | "warn" | "error" = "info", message: string) => { - console[level](`[mock-update-server] ${message}`); -}; - -function isWithinRoot(filePath: string): boolean { - try { - return !relative(realpathSync(root), realpathSync(filePath)).startsWith("."); - } catch (error) { - mockServerLog("error", `Error checking if file is within root: ${error}`); - return false; - } +interface MockUpdateServerConfig { + readonly port: number; + readonly rootRealPath: string; } -Bun.serve({ - port, - hostname: "localhost", - fetch: async (request) => { - const url = new URL(request.url); - const path = url.pathname; - mockServerLog("info", `Request received for path: ${path}`); - const filePath = resolve(root, `.${path}`); - if (!isWithinRoot(filePath)) { - mockServerLog("warn", `Attempted to access file outside of root: ${filePath}`); - return new Response("Not Found", { status: 404 }); +const resolveMockUpdateServerConfig = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const config = yield* Config.all({ + port: Config.port("MARCODE_DESKTOP_MOCK_UPDATE_SERVER_PORT").pipe(Config.withDefault(3000)), + root: Config.string("MARCODE_DESKTOP_MOCK_UPDATE_SERVER_ROOT").pipe( + Config.withDefault("../release-mock"), + ), + }).asEffect(); + + const resolvedRoot = path.resolve(import.meta.dirname, config.root); + + return { + port: config.port, + rootRealPath: yield* fileSystem.realPath(resolvedRoot), + } satisfies MockUpdateServerConfig; +}); + +const isOutsideRoot = (rootRealPath: string, filePath: string) => + Effect.gen(function* () { + const path = yield* Path.Path; + const relativePath = path.relative(rootRealPath, filePath); + return ( + relativePath === ".." || relativePath.startsWith("../") || relativePath.startsWith("..\\") + ); + }); + +const isWithinRoot = (rootRealPath: string, filePath: string) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const resolvedFilePath = yield* fileSystem.realPath(filePath).pipe( + Effect.match({ + onFailure: () => undefined, + onSuccess: (resolvedPath) => resolvedPath, + }), + ); + + return ( + resolvedFilePath !== undefined && !(yield* isOutsideRoot(rootRealPath, resolvedFilePath)) + ); + }); + +const resolveRequestedFilePath = (rootRealPath: string, requestUrl: string | undefined) => + Effect.gen(function* () { + const path = yield* Path.Path; + const rawPath = (requestUrl ?? "/").split("?", 1)[0] ?? "/"; + const decodedPath = yield* Effect.try({ + try: () => decodeURIComponent(rawPath), + catch: () => null, + }).pipe( + Effect.match({ + onFailure: () => undefined, + onSuccess: (value) => value, + }), + ); + + if (!decodedPath) { + return undefined; } - const file = Bun.file(filePath); - if (!(await file.exists())) { - mockServerLog("warn", `Attempted to access non-existent file: ${filePath}`); - return new Response("Not Found", { status: 404 }); + + if (decodedPath.includes("\0")) { + return undefined; } - mockServerLog("info", `Serving file: ${filePath}`); - return new Response(file.stream()); - }, -}); -mockServerLog("info", `running on http://localhost:${port}`); + const filePath = path.resolve( + rootRealPath, + `.${decodedPath.startsWith("/") ? decodedPath : `/${decodedPath}`}`, + ); + + return (yield* isOutsideRoot(rootRealPath, filePath)) ? undefined : filePath; + }); + +const isServableFile = (rootRealPath: string, filePath: string) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const stat = yield* fileSystem.stat(filePath).pipe( + Effect.match({ + onFailure: () => undefined, + onSuccess: (info) => info, + }), + ); + + if (stat?.type !== "File") { + return false; + } + + return yield* isWithinRoot(rootRealPath, filePath); + }); + +export const makeMockUpdateRouteLayer = (rootRealPath: string) => { + return HttpRouter.add( + "*", + "*", + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const requestPath = (request.url ?? "/").split("?", 1)[0] ?? "/"; + yield* Effect.logInfo(`Request received for path: ${requestPath}`); + + const filePath = yield* resolveRequestedFilePath(rootRealPath, request.url); + if (!filePath) { + yield* Effect.logWarning(`Attempted to access file outside of root: ${request.url ?? "/"}`); + return HttpServerResponse.text("Not Found", { status: 404 }); + } + + if (!(yield* isServableFile(rootRealPath, filePath))) { + yield* Effect.logWarning(`Attempted to access invalid file: ${filePath}`); + return HttpServerResponse.text("Not Found", { status: 404 }); + } + + yield* Effect.logInfo(`Serving file: ${filePath}`); + return yield* HttpServerResponse.file(filePath, { status: 200 }); + }).pipe( + Effect.catchCause((cause) => + Effect.gen(function* () { + yield* Effect.logError(`Unhandled mock update request failure: ${cause}`); + return HttpServerResponse.text("Internal Server Error", { status: 500 }); + }), + ), + ), + ); +}; + +const makeMockUpdateServerLayer = (config: MockUpdateServerConfig) => + HttpRouter.serve(makeMockUpdateRouteLayer(config.rootRealPath)).pipe( + Layer.provideMerge( + NodeHttpServer.layer(NodeHttp.createServer, { + host: "localhost", + port: config.port, + }), + ), + Layer.provideMerge(NodeServices.layer), + ); + +if (import.meta.main) { + resolveMockUpdateServerConfig.pipe( + Effect.map(makeMockUpdateServerLayer), + Layer.unwrap, + Layer.launch, + Effect.provide(NodeServices.layer), + NodeRuntime.runMain, + ); +} diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index b1bcda91216..f20f8d6fc90 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -1,5 +1,13 @@ import { execFileSync } from "node:child_process"; -import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -69,12 +77,91 @@ releaseDate: '2026-03-08T10:36:07.540Z' return { arm64Path, x64Path }; } +function writeWindowsManifestFixtures( + targetRoot: string, + channel: string, +): { arm64Path: string; x64Path: string } { + const assetDirectory = resolve(targetRoot, "release-assets"); + mkdirSync(assetDirectory, { recursive: true }); + + const arm64Path = resolve(assetDirectory, `${channel}-win-arm64.yml`); + const x64Path = resolve(assetDirectory, `${channel}-win-x64.yml`); + + writeFileSync( + arm64Path, + `version: 9.9.9-smoke.0 +files: + - url: T3-Code-9.9.9-smoke.0-arm64.exe + sha512: arm64exe + size: 126621344 + - url: T3-Code-9.9.9-smoke.0-arm64.exe.blockmap + sha512: arm64blockmap + size: 152344 +path: T3-Code-9.9.9-smoke.0-arm64.exe +sha512: arm64exe +releaseDate: '2026-03-08T10:32:14.587Z' +`, + ); + + writeFileSync( + x64Path, + `version: 9.9.9-smoke.0 +files: + - url: T3-Code-9.9.9-smoke.0-x64.exe + sha512: x64exe + size: 132000112 + - url: T3-Code-9.9.9-smoke.0-x64.exe.blockmap + sha512: x64blockmap + size: 160112 +path: T3-Code-9.9.9-smoke.0-x64.exe +sha512: x64exe +releaseDate: '2026-03-08T10:36:07.540Z' +`, + ); + + return { arm64Path, x64Path }; +} + +function writeWindowsBuilderDebugFixtures(targetRoot: string): { + arm64Path: string; + x64Path: string; +} { + const assetDirectory = resolve(targetRoot, "release-assets"); + mkdirSync(assetDirectory, { recursive: true }); + + const arm64Path = resolve(assetDirectory, "builder-debug-win-arm64.yml"); + const x64Path = resolve(assetDirectory, "builder-debug-win-x64.yml"); + const debugFixture = `arm64: + firstOrDefaultFilePatterns: + - '**/*' +nsis: + script: |- + !include "example.nsh" +`; + + writeFileSync(arm64Path, debugFixture); + writeFileSync(x64Path, debugFixture); + + return { arm64Path, x64Path }; +} function assertContains(haystack: string, needle: string, message: string): void { if (!haystack.includes(needle)) { throw new Error(message); } } +function assertExists(path: string, message: string): void { + if (!existsSync(path)) { + throw new Error(message); + } +} + +function assertMissing(path: string, message: string): void { + if (existsSync(path)) { + throw new Error(message); + } +} + const tempRoot = mkdtempSync(join(tmpdir(), "marcode-release-smoke-")); try { @@ -94,7 +181,7 @@ try { }, ); - execFileSync("bun", ["install", "--lockfile-only", "--ignore-scripts"], { + execFileSync("bun", ["install", "--ignore-scripts"], { cwd: tempRoot, stdio: "inherit", }); @@ -106,10 +193,50 @@ try { "Expected bun.lock to contain the smoke version.", ); + const nightlyReleaseMetadata = execFileSync( + process.execPath, + [ + resolve(repoRoot, "scripts/resolve-nightly-release.ts"), + "--date", + "20260413", + "--run-number", + "321", + "--sha", + "abcdef1234567890", + "--root", + tempRoot, + ], + { + cwd: repoRoot, + encoding: "utf8", + }, + ); + assertContains( + nightlyReleaseMetadata, + "version=9.9.10-nightly.20260413.321", + "Expected nightly metadata to contain the derived nightly version.", + ); + assertContains( + nightlyReleaseMetadata, + "tag=nightly-v9.9.10-nightly.20260413.321", + "Expected nightly metadata to contain the derived nightly tag.", + ); + assertContains( + nightlyReleaseMetadata, + "name=T3 Code Nightly 9.9.10-nightly.20260413.321 (abcdef123456)", + "Expected nightly metadata to include the short commit SHA in the release name.", + ); + const { arm64Path, x64Path } = writeMacManifestFixtures(tempRoot); execFileSync( process.execPath, - [resolve(repoRoot, "scripts/merge-mac-update-manifests.ts"), arm64Path, x64Path], + [ + resolve(repoRoot, "scripts/merge-update-manifests.ts"), + "--platform", + "mac", + arm64Path, + x64Path, + ], { cwd: repoRoot, stdio: "inherit", @@ -128,6 +255,122 @@ try { "Merged manifest is missing the x64 asset.", ); + const { arm64Path: winArm64Path, x64Path: winX64Path } = writeWindowsManifestFixtures( + tempRoot, + "latest", + ); + const mergedWindowsManifestPath = resolve(tempRoot, "release-assets/latest.yml"); + const { arm64Path: nightlyWinArm64Path, x64Path: nightlyWinX64Path } = + writeWindowsManifestFixtures(tempRoot, "nightly"); + const mergedNightlyWindowsManifestPath = resolve(tempRoot, "release-assets/nightly.yml"); + const { arm64Path: previewWinArm64Path, x64Path: previewWinX64Path } = + writeWindowsManifestFixtures(tempRoot, "preview"); + const mergedPreviewWindowsManifestPath = resolve(tempRoot, "release-assets/preview.yml"); + const { arm64Path: winDebugArm64Path, x64Path: winDebugX64Path } = + writeWindowsBuilderDebugFixtures(tempRoot); + execFileSync( + "bash", + [ + "-lc", + ` + release_assets_dir=${JSON.stringify(resolve(tempRoot, "release-assets"))} + shopt -s nullglob + found_windows_manifest=false + for x64_manifest in "$release_assets_dir"/*-win-x64.yml; do + if [[ "$(basename "$x64_manifest")" == builder-debug-* ]]; then + continue + fi + + arm64_manifest="\${x64_manifest/-x64.yml/-arm64.yml}" + output_manifest="\${x64_manifest/-win-x64.yml/.yml}" + if [[ ! -f "$arm64_manifest" ]]; then + echo "Missing matching arm64 Windows manifest for $x64_manifest" >&2 + exit 1 + fi + + found_windows_manifest=true + node ${JSON.stringify(resolve(repoRoot, "scripts/merge-update-manifests.ts"))} --platform win \ + "$arm64_manifest" \ + "$x64_manifest" \ + "$output_manifest" + rm -f "$arm64_manifest" "$x64_manifest" + done + + if [[ "$found_windows_manifest" != true ]]; then + echo "No Windows updater manifests found to merge." >&2 + exit 1 + fi + `, + ], + { + cwd: repoRoot, + stdio: "inherit", + }, + ); + + const mergedWindowsManifest = readFileSync(mergedWindowsManifestPath, "utf8"); + assertContains( + mergedWindowsManifest, + "T3-Code-9.9.9-smoke.0-arm64.exe", + "Merged Windows manifest is missing the arm64 asset.", + ); + assertContains( + mergedWindowsManifest, + "T3-Code-9.9.9-smoke.0-x64.exe", + "Merged Windows manifest is missing the x64 asset.", + ); + const mergedNightlyWindowsManifest = readFileSync(mergedNightlyWindowsManifestPath, "utf8"); + assertContains( + mergedNightlyWindowsManifest, + "T3-Code-9.9.9-smoke.0-arm64.exe", + "Merged nightly Windows manifest is missing the arm64 asset.", + ); + assertContains( + mergedNightlyWindowsManifest, + "T3-Code-9.9.9-smoke.0-x64.exe", + "Merged nightly Windows manifest is missing the x64 asset.", + ); + const mergedPreviewWindowsManifest = readFileSync(mergedPreviewWindowsManifestPath, "utf8"); + assertContains( + mergedPreviewWindowsManifest, + "T3-Code-9.9.9-smoke.0-arm64.exe", + "Merged preview Windows manifest is missing the arm64 asset.", + ); + assertContains( + mergedPreviewWindowsManifest, + "T3-Code-9.9.9-smoke.0-x64.exe", + "Merged preview Windows manifest is missing the x64 asset.", + ); + assertMissing( + winArm64Path, + "Windows release smoke unexpectedly kept the arm64 updater manifest.", + ); + assertMissing(winX64Path, "Windows release smoke unexpectedly kept the x64 updater manifest."); + assertMissing( + nightlyWinArm64Path, + "Windows release smoke unexpectedly kept the nightly arm64 updater manifest.", + ); + assertMissing( + nightlyWinX64Path, + "Windows release smoke unexpectedly kept the nightly x64 updater manifest.", + ); + assertMissing( + previewWinArm64Path, + "Windows release smoke unexpectedly kept the preview arm64 updater manifest.", + ); + assertMissing( + previewWinX64Path, + "Windows release smoke unexpectedly kept the preview x64 updater manifest.", + ); + assertExists( + winDebugArm64Path, + "Windows release smoke unexpectedly removed the arm64 builder debug fixture.", + ); + assertExists( + winDebugX64Path, + "Windows release smoke unexpectedly removed the x64 builder debug fixture.", + ); + console.log("Release smoke checks passed."); } finally { rmSync(tempRoot, { recursive: true, force: true }); diff --git a/scripts/resolve-nightly-release.test.ts b/scripts/resolve-nightly-release.test.ts new file mode 100644 index 00000000000..56358d6c142 --- /dev/null +++ b/scripts/resolve-nightly-release.test.ts @@ -0,0 +1,32 @@ +import { assert, it } from "@effect/vitest"; + +import { + resolveNightlyBaseVersion, + resolveNightlyReleaseMetadata, + resolveNightlyTargetVersion, +} from "./resolve-nightly-release.ts"; + +it("strips prerelease and build metadata when deriving the nightly base version", () => { + assert.equal(resolveNightlyBaseVersion("0.0.17"), "0.0.17"); + assert.equal(resolveNightlyBaseVersion("9.9.9-smoke.0"), "9.9.9"); + assert.equal(resolveNightlyBaseVersion("1.2.3-beta.4+build.9"), "1.2.3"); +}); + +it("bumps the patch version before deriving nightly prerelease versions", () => { + assert.equal(resolveNightlyTargetVersion("0.0.17"), "0.0.18"); + assert.equal(resolveNightlyTargetVersion("9.9.9-smoke.0"), "9.9.10"); + assert.equal(resolveNightlyTargetVersion("1.2.3-beta.4+build.9"), "1.2.4"); +}); + +it("derives nightly metadata including the short commit sha in the release name", () => { + assert.deepStrictEqual( + resolveNightlyReleaseMetadata("9.9.10", "20260413", 321, "abcdef1234567890"), + { + baseVersion: "9.9.10", + version: "9.9.10-nightly.20260413.321", + tag: "nightly-v9.9.10-nightly.20260413.321", + name: "T3 Code Nightly 9.9.10-nightly.20260413.321 (abcdef123456)", + shortSha: "abcdef123456", + }, + ); +}); diff --git a/scripts/resolve-nightly-release.ts b/scripts/resolve-nightly-release.ts new file mode 100644 index 00000000000..4a92ef63aed --- /dev/null +++ b/scripts/resolve-nightly-release.ts @@ -0,0 +1,137 @@ +#!/usr/bin/env node + +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { Config, Effect, FileSystem, Option, Path, Schema } from "effect"; +import { Command, Flag } from "effect/unstable/cli"; + +interface NightlyReleaseMetadata { + readonly baseVersion: string; + readonly version: string; + readonly tag: string; + readonly name: string; + readonly shortSha: string; +} + +const DateSchema = Schema.String.check(Schema.isPattern(/^\d{8}$/)); +const RunNumberSchema = Schema.FiniteFromString.check( + Schema.isInt(), + Schema.isGreaterThanOrEqualTo(1), +); +const ShaSchema = Schema.String.check(Schema.isPattern(/^[0-9a-f]{7,40}$/i)); +const DesktopPackageJsonSchema = Schema.Struct({ + version: Schema.NonEmptyString, +}); + +const RepoRoot = Effect.service(Path.Path).pipe( + Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))), +); +const decodeDesktopPackageJson = Schema.decodeUnknownEffect( + Schema.fromJsonString(DesktopPackageJsonSchema), +); + +export const resolveNightlyBaseVersion = (version: string) => version.replace(/[-+].*$/, ""); + +export const resolveNightlyTargetVersion = (version: string) => { + const stableCore = resolveNightlyBaseVersion(version); + const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(stableCore); + if (!match) { + throw new Error(`Invalid desktop package version '${version}'.`); + } + + const [, major, minor, patch] = match; + return `${major}.${minor}.${Number(patch) + 1}`; +}; + +export const resolveNightlyReleaseMetadata = ( + baseVersion: string, + date: string, + runNumber: number, + sha: string, +) => { + const shortSha = sha.slice(0, 12); + const version = `${baseVersion}-nightly.${date}.${runNumber}`; + return { + baseVersion, + version, + tag: `nightly-v${version}`, + name: `T3 Code Nightly ${version} (${shortSha})`, + shortSha, + }; +}; + +const readDesktopBaseVersion = Effect.fn("readDesktopBaseVersion")(function* ( + rootDir: string | undefined, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspaceRoot = rootDir ? path.resolve(rootDir) : yield* RepoRoot; + const packageJsonPath = path.join(workspaceRoot, "apps/desktop/package.json"); + const packageJson = yield* fs + .readFileString(packageJsonPath) + .pipe(Effect.flatMap(decodeDesktopPackageJson)); + return resolveNightlyTargetVersion(packageJson.version); +}); + +const writeOutput = Effect.fn("writeOutput")(function* ( + metadata: NightlyReleaseMetadata, + writeGithubOutput: boolean, +) { + const fs = yield* FileSystem.FileSystem; + + const entries = [ + ["base_version", metadata.baseVersion], + ["version", metadata.version], + ["tag", metadata.tag], + ["name", metadata.name], + ["short_sha", metadata.shortSha], + ] as const; + + if (writeGithubOutput) { + const githubOutputPath = yield* Config.nonEmptyString("GITHUB_OUTPUT"); + const serialized = entries.map(([key, value]) => `${key}=${value}\n`).join(""); + yield* fs.writeFileString(githubOutputPath, serialized, { flag: "a" }); + } else { + for (const [key, value] of entries) { + console.log(`${key}=${value}`); + } + } +}); + +const command = Command.make( + "resolve-nightly-release", + { + date: Flag.string("date").pipe( + Flag.withSchema(DateSchema), + Flag.withDescription("Nightly build date in YYYYMMDD."), + ), + runNumber: Flag.string("run-number").pipe( + Flag.withSchema(RunNumberSchema), + Flag.withDescription("GitHub Actions run number."), + ), + sha: Flag.string("sha").pipe( + Flag.withSchema(ShaSchema), + Flag.withDescription("Commit sha for the nightly build."), + ), + githubOutput: Flag.boolean("github-output").pipe( + Flag.withDescription("Write values to GITHUB_OUTPUT instead of stdout."), + Flag.withDefault(false), + ), + root: Flag.string("root").pipe( + Flag.withDescription("Workspace root used to resolve apps/desktop/package.json."), + Flag.optional, + ), + }, + ({ date, runNumber, sha, githubOutput, root }) => + readDesktopBaseVersion(Option.getOrUndefined(root)).pipe( + Effect.map((baseVersion) => resolveNightlyReleaseMetadata(baseVersion, date, runNumber, sha)), + Effect.flatMap((metadata) => writeOutput(metadata, githubOutput)), + ), +).pipe(Command.withDescription("Resolve nightly release version metadata.")); + +if (import.meta.main) { + Command.run(command, { version: "0.0.0" }).pipe( + Effect.provide(NodeServices.layer), + NodeRuntime.runMain, + ); +} diff --git a/scripts/resolve-previous-release-tag.ts b/scripts/resolve-previous-release-tag.ts new file mode 100644 index 00000000000..93f932821ff --- /dev/null +++ b/scripts/resolve-previous-release-tag.ts @@ -0,0 +1,205 @@ +#!/usr/bin/env node + +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { Array, Config, Effect, FileSystem, Schema, Stream, String } from "effect"; +import { Command, Flag } from "effect/unstable/cli"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +const ReleaseChannel = Schema.Literals(["stable", "nightly"]); +type ReleaseChannel = typeof ReleaseChannel.Type; + +interface StableVersion { + readonly major: number; + readonly minor: number; + readonly patch: number; + readonly prerelease: ReadonlyArray; +} + +interface NightlyVersion { + readonly major: number; + readonly minor: number; + readonly patch: number; + readonly date: number; + readonly runNumber: number; +} + +const parseNumericIdentifier = (identifier: string): number | undefined => + /^\d+$/.test(identifier) ? Number(identifier) : undefined; + +const comparePrereleaseIdentifiers = (left: string, right: string): number => { + const leftNumeric = parseNumericIdentifier(left); + const rightNumeric = parseNumericIdentifier(right); + + if (leftNumeric !== undefined && rightNumeric !== undefined) { + return leftNumeric - rightNumeric; + } + if (leftNumeric !== undefined) { + return -1; + } + if (rightNumeric !== undefined) { + return 1; + } + return left.localeCompare(right); +}; + +const compareStableVersions = (left: StableVersion, right: StableVersion): number => { + if (left.major !== right.major) return left.major - right.major; + if (left.minor !== right.minor) return left.minor - right.minor; + if (left.patch !== right.patch) return left.patch - right.patch; + + const leftHasPrerelease = left.prerelease.length > 0; + const rightHasPrerelease = right.prerelease.length > 0; + if (!leftHasPrerelease && !rightHasPrerelease) return 0; + if (!leftHasPrerelease) return 1; + if (!rightHasPrerelease) return -1; + + const maxLength = Math.max(left.prerelease.length, right.prerelease.length); + for (let index = 0; index < maxLength; index += 1) { + const leftIdentifier = left.prerelease[index]; + const rightIdentifier = right.prerelease[index]; + if (leftIdentifier === undefined) return -1; + if (rightIdentifier === undefined) return 1; + + const comparison = comparePrereleaseIdentifiers(leftIdentifier, rightIdentifier); + if (comparison !== 0) return comparison; + } + + return 0; +}; + +const parseStableTag = (tag: string): StableVersion | undefined => { + const match = /^v(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(tag); + if (!match) return undefined; + + const [, major, minor, patch, prerelease] = match; + if (!major || !minor || !patch) return undefined; + + return { + major: Number(major), + minor: Number(minor), + patch: Number(patch), + prerelease: prerelease ? prerelease.split(".") : [], + }; +}; + +const compareNightlyVersions = (left: NightlyVersion, right: NightlyVersion): number => { + if (left.major !== right.major) return left.major - right.major; + if (left.minor !== right.minor) return left.minor - right.minor; + if (left.patch !== right.patch) return left.patch - right.patch; + if (left.date !== right.date) return left.date - right.date; + return left.runNumber - right.runNumber; +}; + +const parseNightlyTag = (tag: string): NightlyVersion | undefined => { + const match = /^nightly-v(\d+)\.(\d+)\.(\d+)-nightly\.(\d{8})\.(\d+)$/.exec(tag); + if (!match) return undefined; + + const [, major, minor, patch, date, runNumber] = match; + if (!major || !minor || !patch || !date || !runNumber) return undefined; + + return { + major: Number(major), + minor: Number(minor), + patch: Number(patch), + date: Number(date), + runNumber: Number(runNumber), + }; +}; + +const resolvePreviousReleaseTag = ( + channel: ReleaseChannel, + currentTag: string, + tags: ReadonlyArray, +): string | undefined => { + if (channel === "stable") { + const current = parseStableTag(currentTag); + if (!current) { + throw new Error(`Invalid stable release tag '${currentTag}'.`); + } + + const candidates = tags + .map((tag) => ({ tag, parsed: parseStableTag(tag) })) + .filter( + (entry): entry is { tag: string; parsed: StableVersion } => entry.parsed !== undefined, + ) + .filter((entry) => compareStableVersions(entry.parsed, current) < 0) + .toSorted((left, right) => compareStableVersions(right.parsed, left.parsed)); + + return candidates[0]?.tag; + } + + const current = parseNightlyTag(currentTag); + if (!current) { + throw new Error(`Invalid nightly release tag '${currentTag}'.`); + } + + const candidates = tags + .map((tag) => ({ tag, parsed: parseNightlyTag(tag) })) + .filter((entry): entry is { tag: string; parsed: NightlyVersion } => entry.parsed !== undefined) + .filter((entry) => compareNightlyVersions(entry.parsed, current) < 0) + .toSorted((left, right) => compareNightlyVersions(right.parsed, left.parsed)); + + return candidates[0]?.tag; +}; + +const listGitTags = Effect.fn("listGitTags")(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const child = yield* spawner.spawn(ChildProcess.make("git", ["tag", "--list"])); + const tags = yield* child.stdout.pipe( + Stream.decodeText(), + Stream.runFold( + () => "", + (acc, chunk) => acc + chunk, + ), + Effect.map(String.split(/\r?\n/)), + Effect.map(Array.map(String.trim)), + Effect.map(Array.filter(String.isNonEmpty)), + ); + return tags; +}); + +const writeOutput = Effect.fn("writeOutput")(function* ( + previousTag: string | undefined, + writeGithubOutput: boolean, +) { + const entry = `previous_tag=${previousTag ?? ""}\n`; + + if (writeGithubOutput) { + const fs = yield* FileSystem.FileSystem; + const githubOutputPath = yield* Config.nonEmptyString("GITHUB_OUTPUT"); + yield* fs.writeFileString(githubOutputPath, entry, { flag: "a" }); + return; + } + + process.stdout.write(entry); +}); + +const command = Command.make( + "resolve-previous-release-tag", + { + channel: Flag.choice("channel", ReleaseChannel.literals).pipe( + Flag.withDescription("Release channel whose previous tag should be resolved."), + ), + currentTag: Flag.string("current-tag").pipe( + Flag.withDescription("Current release tag to compare against."), + ), + githubOutput: Flag.boolean("github-output").pipe( + Flag.withDescription("Write values to GITHUB_OUTPUT instead of stdout."), + Flag.withDefault(false), + ), + }, + ({ channel, currentTag, githubOutput }) => + listGitTags().pipe( + Effect.map((tags) => resolvePreviousReleaseTag(channel, currentTag, tags)), + Effect.flatMap((previousTag) => writeOutput(previousTag, githubOutput)), + ), +).pipe(Command.withDescription("Resolve the previous release tag for a stable or nightly series.")); + +if (import.meta.main) { + Command.run(command, { version: "0.0.0" }).pipe( + Effect.scoped, + Effect.provide(NodeServices.layer), + NodeRuntime.runMain, + ); +} diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json index e9ed7c8ae53..3b189a7671a 100644 --- a/scripts/tsconfig.json +++ b/scripts/tsconfig.json @@ -2,10 +2,8 @@ "extends": "../tsconfig.base.json", "compilerOptions": { "composite": true, - "types": ["node", "bun"], - "lib": ["ES2023", "esnext.disposable"], - "noEmit": true, - "allowImportingTsExtensions": true, + "types": ["node"], + "lib": ["ESNext", "esnext.disposable"], "plugins": [ { "name": "@effect/language-service" diff --git a/scripts/update-release-package-versions.test.ts b/scripts/update-release-package-versions.test.ts new file mode 100644 index 00000000000..df2b194ce34 --- /dev/null +++ b/scripts/update-release-package-versions.test.ts @@ -0,0 +1,213 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { ConfigProvider, Effect, FileSystem, Layer, Path, Schema, SchemaGetter } from "effect"; +import { Command, CliError } from "effect/unstable/cli"; +import * as TestConsole from "effect/testing/TestConsole"; + +import { + releasePackageFiles, + updateReleasePackageVersions, + updateReleasePackageVersionsCommand, +} from "./update-release-package-versions.ts"; + +const ScriptTestLayer = Layer.mergeAll(NodeServices.layer, TestConsole.layer); +const runCli = Command.runWith(updateReleasePackageVersionsCommand, { version: "0.0.0" }); +const PackageJsonSchema = Schema.Record(Schema.String, Schema.Unknown); +const PrettyJsonString = SchemaGetter.parseJson().compose( + SchemaGetter.stringifyJson({ space: 2 }), +); +const PackageJsonPrettyJson = Schema.fromJsonString(PackageJsonSchema).pipe( + Schema.encode({ + decode: PrettyJsonString, + encode: PrettyJsonString, + }), +); +const decodePackageJson = Schema.decodeUnknownEffect(PackageJsonPrettyJson); +const encodePackageJson = Schema.encodeSync(PackageJsonPrettyJson); + +const writePackageJsonFixtures = Effect.fn("writePackageJsonFixtures")(function* ( + rootDir: string, + version: string, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + for (const relativePath of releasePackageFiles) { + const filePath = path.join(rootDir, relativePath); + yield* fs.makeDirectory(path.dirname(filePath), { recursive: true }); + yield* fs.writeFileString( + filePath, + `${encodePackageJson({ + name: relativePath, + version, + private: true, + })}\n`, + ); + } +}); + +const readReleaseVersions = Effect.fn("readReleaseVersions")(function* (rootDir: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const versions = new Map(); + + for (const relativePath of releasePackageFiles) { + const filePath = path.join(rootDir, relativePath); + const packageJson = yield* fs.readFileString(filePath).pipe(Effect.flatMap(decodePackageJson)); + versions.set(relativePath, String(packageJson.version)); + } + + return versions; +}); + +const captureLogs = (effect: Effect.Effect) => + Effect.gen(function* () { + const result = yield* effect; + const logs = (yield* TestConsole.logLines).filter( + (line): line is string => typeof line === "string", + ); + return { result, logs }; + }); + +it.layer(ScriptTestLayer)("update-release-package-versions", (it) => { + it.effect("updates all release package versions under the provided root", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const baseDir = yield* fs.makeTempDirectoryScoped({ + prefix: "update-release-package-versions-", + }); + + yield* writePackageJsonFixtures(baseDir, "0.0.1"); + + const result = yield* updateReleasePackageVersions("1.2.3", { rootDir: baseDir }); + const versions = yield* readReleaseVersions(baseDir); + + assert.deepStrictEqual(result, { changed: true }); + assert.deepStrictEqual( + Array.from(versions.entries()), + releasePackageFiles.map((relativePath) => [relativePath, "1.2.3"]), + ); + }), + ); + + it.effect("returns changed=false when all versions already match", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const baseDir = yield* fs.makeTempDirectoryScoped({ + prefix: "update-release-package-versions-unchanged-", + }); + + yield* writePackageJsonFixtures(baseDir, "1.2.3"); + + const result = yield* updateReleasePackageVersions("1.2.3", { rootDir: baseDir }); + + assert.deepStrictEqual(result, { changed: false }); + }), + ); + + it.effect("accepts flags before the version positional and appends changed output", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ + prefix: "update-release-package-versions-cli-", + }); + const githubOutputPath = path.join(baseDir, "github-output.txt"); + + yield* writePackageJsonFixtures(baseDir, "0.0.1"); + + yield* runCli(["--github-output", "--root", baseDir, "2.0.0"]).pipe( + Effect.provide( + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + GITHUB_OUTPUT: githubOutputPath, + }, + }), + ), + ), + ); + + const githubOutput = yield* fs.readFileString(githubOutputPath); + assert.equal(githubOutput, "changed=true\n"); + }), + ); + + it.effect("logs when nothing changed", () => + captureLogs( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const baseDir = yield* fs.makeTempDirectoryScoped({ + prefix: "update-release-package-versions-cli-log-", + }); + + yield* writePackageJsonFixtures(baseDir, "3.0.0"); + yield* runCli(["3.0.0", "--root", baseDir]); + }), + ).pipe( + Effect.tap(({ logs }) => { + assert.deepStrictEqual(logs, ["All package.json versions already match release version."]); + return Effect.void; + }), + ), + ); + + it.effect("requires GITHUB_OUTPUT when --github-output is set", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const baseDir = yield* fs.makeTempDirectoryScoped({ + prefix: "update-release-package-versions-cli-missing-output-", + }); + + yield* writePackageJsonFixtures(baseDir, "0.0.1"); + + const error = yield* runCli(["4.0.0", "--root", baseDir, "--github-output"]).pipe( + Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} }))), + Effect.flip, + ); + + assert.equal( + error.message, + 'SchemaError(Expected string, got undefined\n at ["GITHUB_OUTPUT"])', + ); + }), + ); + + it.effect("rejects unknown flags during cli parsing", () => + Effect.gen(function* () { + const error = yield* runCli(["1.2.3", "--unknown"]).pipe(Effect.flip); + + if (!CliError.isCliError(error)) { + assert.fail(`Expected CliError, got ${String(error)}`); + } + + const optionError = + error._tag === "ShowHelp" ? (error.errors[0] as CliError.CliError | undefined) : error; + + if (!optionError || optionError._tag !== "UnrecognizedOption") { + assert.fail(`Expected UnrecognizedOption, got ${String(optionError?._tag)}`); + } + + assert.equal(optionError.option, "--unknown"); + }), + ); + + it.effect("rejects a missing version positional during cli parsing", () => + Effect.gen(function* () { + const error = yield* runCli(["--github-output"]).pipe(Effect.flip); + + if (!CliError.isCliError(error)) { + assert.fail(`Expected CliError, got ${String(error)}`); + } + + const versionError = + error._tag === "ShowHelp" ? (error.errors[0] as CliError.CliError | undefined) : error; + + if (!versionError || versionError._tag !== "MissingArgument") { + assert.fail(`Expected MissingArgument, got ${String(versionError?._tag)}`); + } + + assert.equal(versionError.argument, "version"); + }), + ); +}); diff --git a/scripts/update-release-package-versions.ts b/scripts/update-release-package-versions.ts index b860b85e8e8..d2baa85a162 100644 --- a/scripts/update-release-package-versions.ts +++ b/scripts/update-release-package-versions.ts @@ -1,6 +1,9 @@ -import { appendFileSync, readFileSync, writeFileSync } from "node:fs"; -import { resolve } from "node:path"; -import { fileURLToPath } from "node:url"; +#!/usr/bin/env node + +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { Config, Console, Effect, FileSystem, Option, Path, Schema, SchemaGetter } from "effect"; +import { Argument, Command, Flag } from "effect/unstable/cli"; export const releasePackageFiles = [ "apps/server/package.json", @@ -10,103 +13,82 @@ export const releasePackageFiles = [ ] as const; interface UpdateReleasePackageVersionsOptions { - readonly rootDir?: string; -} - -interface MutablePackageJson { - version?: string; - [key: string]: unknown; + readonly rootDir?: string | undefined; } -export function updateReleasePackageVersions( +const PackageJsonSchema = Schema.Record(Schema.String, Schema.Unknown); +const PrettyJsonString = SchemaGetter.parseJson().compose( + SchemaGetter.stringifyJson({ space: 2 }), +); +const PackageJsonPrettyJson = Schema.fromJsonString(PackageJsonSchema).pipe( + Schema.encode({ + decode: PrettyJsonString, + encode: PrettyJsonString, + }), +); +const decodePackageJson = Schema.decodeUnknownEffect(PackageJsonPrettyJson); +const encodePackageJson = Schema.encodeSync(PackageJsonPrettyJson); + +export const updateReleasePackageVersions = Effect.fn("updateReleasePackageVersions")(function* ( version: string, options: UpdateReleasePackageVersionsOptions = {}, -): { changed: boolean } { - const rootDir = resolve(options.rootDir ?? process.cwd()); +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const rootDir = path.resolve(options.rootDir ?? process.cwd()); let changed = false; for (const relativePath of releasePackageFiles) { - const filePath = resolve(rootDir, relativePath); - const packageJson = JSON.parse(readFileSync(filePath, "utf8")) as MutablePackageJson; + const filePath = path.join(rootDir, relativePath); + const packageJson = yield* fs.readFileString(filePath).pipe(Effect.flatMap(decodePackageJson)); if (packageJson.version === version) { continue; } - packageJson.version = version; - writeFileSync(filePath, `${JSON.stringify(packageJson, null, 2)}\n`); + yield* fs.writeFileString(filePath, `${encodePackageJson({ ...packageJson, version })}\n`); changed = true; } return { changed }; -} - -function parseArgs(argv: ReadonlyArray): { - version: string; - rootDir: string | undefined; - writeGithubOutput: boolean; -} { - let version: string | undefined; - let rootDir: string | undefined; - let writeGithubOutput = false; - - for (let index = 0; index < argv.length; index += 1) { - const argument = argv[index]; - if (argument === undefined) { - continue; - } - - if (argument === "--github-output") { - writeGithubOutput = true; - continue; - } - - if (argument === "--root") { - rootDir = argv[index + 1]; - if (!rootDir) { - throw new Error("Missing value for --root."); - } - index += 1; - continue; - } - - if (argument.startsWith("--")) { - throw new Error(`Unknown argument: ${argument}`); - } - - if (version !== undefined) { - throw new Error("Only one release version can be provided."); - } - version = argument; - } - - if (!version) { - throw new Error( - "Usage: node scripts/update-release-package-versions.ts [--root ] [--github-output]", - ); - } - - return { version, rootDir, writeGithubOutput }; -} - -const isMain = - process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url); - -if (isMain) { - const { version, rootDir, writeGithubOutput } = parseArgs(process.argv.slice(2)); - const { changed } = updateReleasePackageVersions( - version, - rootDir === undefined ? {} : { rootDir }, +}); + +const writeGithubOutput = Effect.fn("writeGithubOutput")(function* (changed: boolean) { + const fs = yield* FileSystem.FileSystem; + const githubOutputPath = yield* Config.nonEmptyString("GITHUB_OUTPUT"); + yield* fs.writeFileString(githubOutputPath, `changed=${changed}\n`, { flag: "a" }); +}); + +export const updateReleasePackageVersionsCommand = Command.make( + "update-release-package-versions", + { + version: Argument.string("version").pipe( + Argument.withDescription("Release version to write into each releasable package.json."), + ), + root: Flag.string("root").pipe( + Flag.withDescription("Workspace root used to resolve the release package manifests."), + Flag.optional, + ), + githubOutput: Flag.boolean("github-output").pipe( + Flag.withDescription("Append changed= to GITHUB_OUTPUT."), + Flag.withDefault(false), + ), + }, + ({ version, root, githubOutput }) => + updateReleasePackageVersions(version, { + rootDir: Option.getOrUndefined(root), + }).pipe( + Effect.tap(({ changed }) => + changed + ? Effect.void + : Console.log("All package.json versions already match release version."), + ), + Effect.tap(({ changed }) => (githubOutput ? writeGithubOutput(changed) : Effect.void)), + ), +).pipe(Command.withDescription("Update release package versions across the workspace.")); + +if (import.meta.main) { + Command.run(updateReleasePackageVersionsCommand, { version: "0.0.0" }).pipe( + Effect.provide(NodeServices.layer), + NodeRuntime.runMain, ); - - if (!changed) { - console.log("All package.json versions already match release version."); - } - - if (writeGithubOutput) { - const githubOutputPath = process.env.GITHUB_OUTPUT; - if (!githubOutputPath) { - throw new Error("GITHUB_OUTPUT is required when --github-output is set."); - } - appendFileSync(githubOutputPath, `changed=${changed}\n`); - } } diff --git a/tsconfig.base.json b/tsconfig.base.json index 538fa0f0eb3..8d481cc7f81 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -1,8 +1,13 @@ { "compilerOptions": { - "target": "ES2023", - "module": "ESNext", - "moduleResolution": "Bundler", + "target": "ESNext", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmit": true, + "allowImportingTsExtensions": true, + "rewriteRelativeImportExtensions": true, + "erasableSyntaxOnly": true, + "verbatimModuleSyntax": true, "strict": true, "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true,