diff --git a/.github/workflows/build-and-upload.yml b/.github/workflows/build-and-upload.yml index 2dd97162..b14af9da 100644 --- a/.github/workflows/build-and-upload.yml +++ b/.github/workflows/build-and-upload.yml @@ -317,15 +317,98 @@ jobs: - name: Verify bundled Node resource (Electron Linux) run: node scripts/verify-bundled-node.cjs packages/electron-app/electron/resources@linux-x64 + - name: Verify Electron portable archive + run: | + set -euo pipefail + shopt -s nullglob + archives=(packages/electron-app/release/*.tar.gz) + if [ "${#archives[@]}" -ne 1 ]; then + echo "Expected one Electron Linux tar.gz, found ${#archives[@]}" >&2 + exit 1 + fi + + extract_dir=$(mktemp -d) + trap 'rm -rf "$extract_dir"' EXIT + tar -xzf "${archives[0]}" -C "$extract_dir" + + binary=$(find "$extract_dir" -type f -name CodeNomad -print -quit) + if [ -z "$binary" ] || [ ! -x "$binary" ]; then + echo "Electron portable archive does not contain an executable CodeNomad binary" >&2 + exit 1 + fi + + resources_dir="$(dirname "$binary")/resources" + node scripts/smoke-packaged-resources.cjs \ + --resources "$resources_dir" \ + --target linux-x64 + + app_asar="$resources_dir/app.asar" + if [ ! -f "$app_asar" ]; then + echo "Electron portable archive does not contain resources/app.asar" >&2 + exit 1 + fi + node - "$app_asar" <<'NODE' + const asar = require("@electron/asar") + const path = require("node:path") + const archive = process.argv[2] + const files = asar.listPackage(archive).map((file) => + file.replace(/^[/\\]/, "").replaceAll("\\", "/"), + ) + const fileSet = new Set(files) + const loadingPath = "dist/renderer/loading.html" + if (!fileSet.has(loadingPath)) { + throw new Error("Electron app.asar is missing dist/renderer/loading.html") + } + if (!files.some((file) => file.startsWith("dist/renderer/assets/") && !file.endsWith("/"))) { + throw new Error("Electron app.asar does not contain renderer assets") + } + const html = asar.extractFile(archive, loadingPath).toString("utf8") + for (const match of html.matchAll(/\b(?:src|href)=["']([^"']+)["']/g)) { + const reference = match[1] + if (/^(?:[a-z]+:|#)/i.test(reference)) continue + const cleanReference = reference.split(/[?#]/, 1)[0] + const target = cleanReference.startsWith("/") + ? cleanReference.slice(1) + : path.posix.normalize(path.posix.join(path.posix.dirname(loadingPath), cleanReference)) + if (!fileSet.has(target)) { + throw new Error(`Electron app.asar is missing renderer reference ${target}`) + } + } + NODE + + ldd "$binary" | tee /tmp/codenomad-electron-ldd.txt + if grep -q "not found" /tmp/codenomad-electron-ldd.txt; then + echo "Electron portable binary has unresolved shared libraries" >&2 + exit 1 + fi + - name: Upload release assets if: ${{ inputs.upload && inputs.tag != '' }} run: | set -euo pipefail shopt -s nullglob - for file in packages/electron-app/release/*.zip packages/electron-app/release/*.AppImage; do - [ -f "$file" ] || continue - echo "Uploading $file" - gh release upload "$TAG" "$file" --clobber + files=(packages/electron-app/release/*.tar.gz) + if [ "${#files[@]}" -ne 1 ]; then + echo "Expected one Electron Linux release asset, found ${#files[@]}" >&2 + exit 1 + fi + + file="${files[0]}" + asset_name=$(basename "$file") + echo "Uploading $file" + gh release upload "$TAG" "$file" --clobber + if ! gh release view "$TAG" --json assets --jq '.assets[].name' | grep -Fxq -- "$asset_name"; then + echo "Uploaded Electron Linux asset is missing from release: $asset_name" >&2 + exit 1 + fi + + gh release view "$TAG" --json assets --jq '.assets[].name' | while IFS= read -r asset; do + case "$asset" in + CodeNomad-Electron-linux-*.zip|CodeNomad-Electron-linux-*.AppImage|CodeNomad-Electron-linux-*.flatpak) + echo "Deleting obsolete Linux asset $asset" + gh release delete-asset "$TAG" "$asset" --yes + ;; + esac done - name: Upload Actions artifacts (Electron Linux) @@ -333,9 +416,7 @@ jobs: uses: actions/upload-artifact@v4 with: name: ${{ inputs.actions_artifacts_name_prefix }}electron-linux - path: | - packages/electron-app/release/*.zip - packages/electron-app/release/*.AppImage + path: packages/electron-app/release/*.tar.gz retention-days: ${{ inputs.actions_artifacts_retention_days }} if-no-files-found: error @@ -364,6 +445,9 @@ jobs: if: ${{ inputs.set_versions && inputs.version != '' }} run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version + - name: Sync Tauri package version + run: npm run sync:version --workspace @codenomad/tauri-app + - name: Install dependencies run: npm ci --workspaces --include=optional @@ -451,6 +535,9 @@ jobs: if: ${{ inputs.set_versions && inputs.version != '' }} run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version + - name: Sync Tauri package version + run: npm run sync:version --workspace @codenomad/tauri-app + - name: Install dependencies run: npm ci --workspaces --include=optional @@ -539,6 +626,9 @@ jobs: run: npm version ${{ env.VERSION }} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version shell: bash + - name: Sync Tauri package version + run: npm run sync:version --workspace @codenomad/tauri-app + - name: Install dependencies run: npm ci --workspaces --include=optional @@ -643,6 +733,9 @@ jobs: if: ${{ inputs.set_versions && inputs.version != '' }} run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version + - name: Sync Tauri package version + run: npm run sync:version --workspace @codenomad/tauri-app + - name: Install dependencies run: npm ci --workspaces --include=optional @@ -669,53 +762,135 @@ jobs: echo "Tauri CLI failed to load after retries" >&2 exit 1 - - name: Build Linux bundle (Tauri) + - name: Build Debian package (Tauri) working-directory: packages/tauri-app - run: npm exec -- tauri build + run: npm exec -- tauri build --bundles deb - - name: Package Tauri artifacts (Linux) + - name: Package and verify Tauri deb if: ${{ inputs.upload || inputs.upload_actions_artifacts }} run: | set -euo pipefail - SEARCH_ROOT="packages/tauri-app/target" + BUNDLE_ROOT="packages/tauri-app/target/release/bundle/deb" ARTIFACT_DIR="packages/tauri-app/release-tauri" + VERSION_TO_USE="${VERSION:-}" + if [ -z "$VERSION_TO_USE" ]; then + VERSION_TO_USE=$(node -p "require('./packages/tauri-app/package.json').version") + fi rm -rf "$ARTIFACT_DIR" mkdir -p "$ARTIFACT_DIR" - shopt -s nullglob globstar + shopt -s nullglob - find_one() { - find "$SEARCH_ROOT" -type f -iname "$1" | head -n1 - } - - appimage=$(find_one "*.AppImage") - fallback_bin="$SEARCH_ROOT/release/codenomad-tauri" - - if [ -z "$appimage" ] || [ ! -f "$fallback_bin" ]; then - echo "Missing bundle(s): appimage=${appimage:-none} binary=$fallback_bin" >&2 + debs=("$BUNDLE_ROOT"/*.deb) + if [ "${#debs[@]}" -ne 1 ]; then + echo "Expected one Tauri deb under $BUNDLE_ROOT, found ${#debs[@]}" >&2 exit 1 fi - cp "$appimage" "$ARTIFACT_DIR/CodeNomad-Tauri-linux-x64-${VERSION}.AppImage" - zip -j "$ARTIFACT_DIR/CodeNomad-Tauri-linux-x64-${VERSION}.zip" "$fallback_bin" + artifact="$ARTIFACT_DIR/CodeNomad-Tauri-linux-x64-${VERSION_TO_USE}.deb" + cp "${debs[0]}" "$artifact" + dpkg-deb --info "$artifact" + dpkg-deb --contents "$artifact" + package_version=$(dpkg-deb --field "$artifact" Version) + if [ "$package_version" != "$VERSION_TO_USE" ]; then + echo "Tauri deb version mismatch: expected $VERSION_TO_USE, found $package_version" >&2 + exit 1 + fi + + extract_dir=$(mktemp -d) + trap 'rm -rf "$extract_dir"' EXIT + dpkg-deb --extract "$artifact" "$extract_dir" + + binary="$extract_dir/usr/bin/codenomad-tauri" + resources_dir="$extract_dir/usr/lib/CodeNomad/resources" + desktop_file="$extract_dir/usr/share/applications/CodeNomad.desktop" + for required in "$binary" "$resources_dir" "$desktop_file"; do + if [ ! -e "$required" ]; then + echo "Missing path in Tauri deb: $required" >&2 + exit 1 + fi + done + + grep -Fx "Exec=codenomad-tauri" "$desktop_file" + if grep -Fxq "NoDisplay=true" "$desktop_file"; then + echo "Generated Tauri desktop entry must be visible" >&2 + exit 1 + fi + node scripts/smoke-packaged-resources.cjs \ + --resources "$resources_dir" \ + --loading "$resources_dir/ui-loading" \ + --target linux-x64 + + ldd "$binary" | tee /tmp/codenomad-tauri-ldd.txt + if grep -q "not found" /tmp/codenomad-tauri-ldd.txt; then + echo "Tauri deb binary has unresolved shared libraries" >&2 + exit 1 + fi + + - name: Verify Tauri deb installation on Ubuntu 24.04 + if: ${{ inputs.upload || inputs.upload_actions_artifacts }} + run: | + set -euo pipefail + shopt -s nullglob + debs=(packages/tauri-app/release-tauri/*.deb) + if [ "${#debs[@]}" -ne 1 ]; then + echo "Expected one packaged Tauri deb, found ${#debs[@]}" >&2 + exit 1 + fi + + artifact=$(realpath "${debs[0]}") + docker run --rm \ + --volume "$artifact:/tmp/codenomad.deb:ro" \ + ubuntu:24.04 \ + bash -euc ' + export DEBIAN_FRONTEND=noninteractive + apt-get update >/dev/null + apt-get install -y /tmp/codenomad.deb >/dev/null + test -x /usr/bin/codenomad-tauri + test -x /usr/lib/CodeNomad/resources/node/linux-x64/bin/node + test -f /usr/lib/CodeNomad/resources/server/dist/bin.js + test -f /usr/lib/CodeNomad/resources/ui-loading/loading.html + /usr/lib/CodeNomad/resources/node/linux-x64/bin/node \ + /usr/lib/CodeNomad/resources/server/dist/bin.js --version + ldd /usr/bin/codenomad-tauri | tee /tmp/codenomad-tauri-ldd.txt + ! grep -q "not found" /tmp/codenomad-tauri-ldd.txt + ' - name: Upload Actions artifacts (Tauri Linux) if: ${{ inputs.upload_actions_artifacts }} uses: actions/upload-artifact@v4 with: name: ${{ inputs.actions_artifacts_name_prefix }}tauri-linux - path: packages/tauri-app/release-tauri/* + path: packages/tauri-app/release-tauri/*.deb retention-days: ${{ inputs.actions_artifacts_retention_days }} - if-no-files-found: warn + if-no-files-found: error - name: Upload Tauri release assets (Linux) if: ${{ inputs.upload && inputs.tag != '' }} run: | set -euo pipefail shopt -s nullglob - for file in packages/tauri-app/release-tauri/*; do - [ -f "$file" ] || continue - echo "Uploading $file" - gh release upload "$TAG" "$file" --clobber + files=(packages/tauri-app/release-tauri/*.deb) + if [ "${#files[@]}" -ne 1 ]; then + echo "Expected one Tauri Linux release asset, found ${#files[@]}" >&2 + exit 1 + fi + + file="${files[0]}" + asset_name=$(basename "$file") + echo "Uploading $file" + gh release upload "$TAG" "$file" --clobber + if ! gh release view "$TAG" --json assets --jq '.assets[].name' | grep -Fxq -- "$asset_name"; then + echo "Uploaded Tauri Linux asset is missing from release: $asset_name" >&2 + exit 1 + fi + + gh release view "$TAG" --json assets --jq '.assets[].name' | while IFS= read -r asset; do + case "$asset" in + CodeNomad-Tauri-linux-*.zip|CodeNomad-Tauri-linux-*.AppImage|CodeNomad-Tauri-linux-*.rpm|CodeNomad-Tauri-linux-*.flatpak) + echo "Deleting obsolete Linux asset $asset" + gh release delete-asset "$TAG" "$asset" --yes + ;; + esac done build-tauri-linux-arm64: @@ -772,6 +947,9 @@ jobs: - name: Set workspace versions run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version + - name: Sync Tauri package version + run: npm run sync:version --workspace @codenomad/tauri-app + - name: Install dependencies run: npm ci --workspaces --include=optional diff --git a/.opencode/skills/codenomad-architecture-guide/references/feature-traces.md b/.opencode/skills/codenomad-architecture-guide/references/feature-traces.md index 286d32d8..2aa5a42d 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/feature-traces.md +++ b/.opencode/skills/codenomad-architecture-guide/references/feature-traces.md @@ -7,11 +7,12 @@ End-to-end feature flows with decision branches and mechanism references. 1. **Server:** Backend emits SSE event `permission.asked` or `permission.updated` - Events are pushed through the instance event stream -2. **UI Store:** `packages/ui/src/stores/instances.ts` receives via `serverEvents` handler - - **Branch:** IF `isPermissionAutoAcceptEnabled(instanceId, sessionId)` is true - - **Mechanism:** `drainAutoAcceptPermissions()` in `packages/ui/src/stores/permission-auto-accept.ts` - - **Action:** Automatically calls `Permission.reply()`, skips modal display - - **File:** `packages/ui/src/stores/permission-auto-accept.ts:drainAutoAcceptPermission()` +2. **Server AutoAcceptManager** intercepts permission events (if Yolo is enabled) + - **File:** `packages/server/src/permissions/auto-accept-manager.ts` + - **Action:** Auto-replies via SDK client, emits `yolo.autoAccepted` to UI for immediate queue cleanup + - **Pending drain:** Re-drains pending permissions on toggle(enable) and session ancestry changes +3. **UI Store:** `packages/ui/src/stores/instances.ts` receives via `serverEvents` + - **Branch:** IF `yolo.autoAccepted` event arrives → marks replied + removes from queue immediately - **Branch:** ELSE (normal flow) - **Mechanism:** Permission queued in `permissionQueues` signal - **Action:** Display approval modal diff --git a/.opencode/skills/codenomad-architecture-guide/references/sdk-integration-patterns.md b/.opencode/skills/codenomad-architecture-guide/references/sdk-integration-patterns.md index eda86515..3f5d4321 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/sdk-integration-patterns.md +++ b/.opencode/skills/codenomad-architecture-guide/references/sdk-integration-patterns.md @@ -129,20 +129,20 @@ Rapid successive operations can cause temporary desync: 1. **Server emits** `permission.asked` or `permission.updated` SSE event - Pushed through instance event stream -2. **UI Store receives** via `serverEvents` +2. **Server AutoAcceptManager** intercepts the event (if Yolo is enabled) + - File: `packages/server/src/permissions/auto-accept-manager.ts` + - Action: Auto-replies via SDK client (`createInstanceClient`), tracks pending permissions, drains on enable/ancestry change + - Emits `yolo.autoAccepted` + `yolo.stateChanged` events to UI +3. **UI Store receives** via `serverEvents` - File: `packages/ui/src/stores/instances.ts` - - **Branch:** IF `isPermissionAutoAcceptEnabled()` - - Mechanism: `drainAutoAcceptPermissions()` in `packages/ui/src/stores/permission-auto-accept.ts` - - Action: Calls reply immediately, skips modal - - **Branch:** ELSE - - Mechanism: Queued in `permissionQueues` - - Action: Display modal -3. **UI Store:** `packages/ui/src/stores/message-v2/bridge.ts` calls `upsertPermissionV2()` -4. **UI Component:** `packages/ui/src/components/permission-approval-modal.tsx` displays -5. **User Action:** Calls `packages/ui/src/stores/instances.ts:sendPermissionResponse()` -6. **SDK Call:** `client.permission.reply()` via `packages/ui/src/lib/opencode-api.ts` -7. **Optimistic Update:** `removePermissionV2()` in bridge -8. **SSE Confirmation:** `permission.replied` event + - **Branch:** IF `yolo.autoAccepted` event arrives → immediately marks replied + removes from queue + - **Branch:** ELSE (user must reply) → Queued in `permissionQueues` → Display modal +4. **UI Store:** `packages/ui/src/stores/message-v2/bridge.ts` calls `upsertPermissionV2()` +5. **UI Component:** `packages/ui/src/components/permission-approval-modal.tsx` displays +6. **User Action:** Calls `packages/ui/src/stores/instances.ts:sendPermissionResponse()` +7. **SDK Call:** `client.permission.reply()` via `packages/ui/src/lib/opencode-api.ts` +8. **Optimistic Update:** `removePermissionV2()` in bridge +9. **SSE Confirmation:** `permission.replied` event - **Branch:** IF SSE disconnected → `syncPendingPermissions()` reconciles on reconnect ## Session Event Handling diff --git a/BUILD.md b/BUILD.md index 0ae1909f..60c2415a 100644 --- a/BUILD.md +++ b/BUILD.md @@ -56,14 +56,14 @@ bun run build:win-arm64 ### Linux ```bash -# x64 (64-bit) -bun run build:linux +# Portable Electron archive (x64) +npm run build:linux --workspace @neuralnomads/codenomad-electron-app -# ARM64 -bun run build:linux-arm64 +# Tauri Debian package (x64) +npm exec --workspace @codenomad/tauri-app -- tauri build --bundles deb ``` -**Output formats:** `.AppImage`, `.deb`, `.tar.gz` +**Release formats:** Electron `.tar.gz` portable archive and Tauri `.deb` installer. ### Build All Platforms @@ -83,27 +83,29 @@ The build script performs these steps: ## Output -Binaries are generated in the `release/` directory: +Build artifacts are generated in package-specific output directories: ``` -release/ -├── CodeNomad-0.1.0-mac-universal.dmg -├── CodeNomad-0.1.0-mac-universal.zip -├── CodeNomad-0.1.0-win-x64.exe -├── CodeNomad-0.1.0-linux-x64.AppImage -└── ... +packages/electron-app/release/ +└── CodeNomad-Electron-linux-x64-0.18.0.tar.gz + +packages/tauri-app/target/release/bundle/deb/ +└── CodeNomad_0.18.0_amd64.deb ``` ## File Naming Convention ``` -CodeNomad-{version}-{os}-{arch}.{ext} +CodeNomad-Electron-{os}-{arch}-{version}.{ext} +CodeNomad-Tauri-{os}-{arch}-{version}.{ext} ``` -- **version**: From package.json (e.g., `0.1.0`) -- **os**: `mac`, `win`, `linux` +- **version**: From package.json (e.g., `0.18.0`) +- **os**: `macos`, `windows`, `linux` - **arch**: `x64`, `arm64`, `universal` -- **ext**: `dmg`, `zip`, `exe`, `AppImage`, `deb`, `tar.gz` +- **ext**: `dmg`, `zip`, `exe`, `deb`, `tar.gz` + +The Tauri build directory uses Tauri's native Debian filename. CI renames the package to the convention above when preparing release assets. ## Platform Requirements @@ -121,9 +123,9 @@ CodeNomad-{version}-{os}-{arch}.{ext} ### Linux -- **Build on:** Any platform -- **Run on:** Ubuntu 18.04+, Debian 10+, Fedora 32+, Arch -- **Dependencies:** Varies by distro +- **Build on:** Linux x64 +- **Electron portable:** extract the tar.gz and run the `CodeNomad` executable +- **Tauri deb:** built and installation-tested on Ubuntu 24.04; older distributions are not yet guaranteed ## Troubleshooting @@ -136,13 +138,7 @@ xcode-select --install ### Build fails on Linux -```bash -# Install dependencies (Debian/Ubuntu) -sudo apt-get install -y rpm - -# Install dependencies (Fedora) -sudo dnf install -y rpm-build -``` +Install the Electron and Tauri build dependencies documented by their upstream projects. Release builds currently target Linux x64 and produce an Electron portable archive plus a Tauri Debian package. ### "electron-builder not found" diff --git a/README.md b/README.md index 8da35441..946ce8cc 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,9 @@ Download the latest installer for your platform from [Releases](https://github.c |----------|---------| | macOS | DMG, ZIP (Universal: Intel + Apple Silicon) | | Windows | NSIS Installer, ZIP (x64, ARM64) | -| Linux | AppImage, deb, tar.gz (x64, ARM64) | +| Linux | Tauri deb, Electron portable tar.gz (x64) | + +The Tauri deb is currently built and installation-tested on Ubuntu 24.04. Compatibility with older Debian-based distributions is not yet guaranteed. ### 💻 CodeNomad Server @@ -169,7 +171,7 @@ On Intel Macs, also check **System Settings → Privacy & Security** on first la WebKitGTK DMA-BUF/GBM issue. Run with: ```bash -WEBKIT_DISABLE_DMABUF_RENDERER=1 codenomad +WEBKIT_DISABLE_DMABUF_RENDERER=1 codenomad-tauri ``` See full workaround in the original README. diff --git a/dev-docs/technical-implementation.md b/dev-docs/technical-implementation.md index 5d7e6d68..bbfc6443 100644 --- a/dev-docs/technical-implementation.md +++ b/dev-docs/technical-implementation.md @@ -584,7 +584,7 @@ npm run package # Create distributable - macOS: DMG + auto-update - Windows: NSIS installer + auto-update -- Linux: AppImage + deb/rpm +- Linux: Electron portable tar.gz + Tauri deb ## Configuration Files diff --git a/package-lock.json b/package-lock.json index 5d08c142..6777e1d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13454,6 +13454,7 @@ "@fastify/cors": "^8.5.0", "@fastify/reply-from": "^9.8.0", "@fastify/static": "^7.0.4", + "@opencode-ai/sdk": "^1.17.8", "commander": "^12.1.0", "fastify": "^4.28.1", "fuzzysort": "^2.0.4", diff --git a/packages/cloudflare/release-config.json b/packages/cloudflare/release-config.json index bb49f85b..50efd5fa 100644 --- a/packages/cloudflare/release-config.json +++ b/packages/cloudflare/release-config.json @@ -1,4 +1,4 @@ { - "minServerVersion": "0.17.0", + "minServerVersion": "0.18.0", "latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest" } diff --git a/packages/electron-app/package.json b/packages/electron-app/package.json index 8bb58526..02c2c4b5 100644 --- a/packages/electron-app/package.json +++ b/packages/electron-app/package.json @@ -131,12 +131,10 @@ "createStartMenuShortcut": true }, "linux": { + "executableName": "CodeNomad", "target": [ { - "target": "zip" - }, - { - "target": "AppImage" + "target": "tar.gz" } ], "artifactName": "CodeNomad-Electron-linux-${arch}-${version}.${ext}", diff --git a/packages/server/package.json b/packages/server/package.json index 15228764..3ca35925 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -28,6 +28,7 @@ "@fastify/cors": "^8.5.0", "@fastify/reply-from": "^9.8.0", "@fastify/static": "^7.0.4", + "@opencode-ai/sdk": "^1.17.8", "commander": "^12.1.0", "fastify": "^4.28.1", "fuzzysort": "^2.0.4", diff --git a/packages/server/src/api-types.ts b/packages/server/src/api-types.ts index d2349cde..88419326 100644 --- a/packages/server/src/api-types.ts +++ b/packages/server/src/api-types.ts @@ -38,6 +38,7 @@ export interface WorkspaceDescriptor { export interface WorkspaceCreateRequest { path: string name?: string + forceNew?: boolean } export interface WorkspaceCloneRequest { @@ -50,7 +51,10 @@ export interface WorkspaceCloneResponse { path: string } -export type WorkspaceCreateResponse = WorkspaceDescriptor +export type WorkspaceCreateResponse = WorkspaceDescriptor & { + /** True when an active workspace with the same canonical path was returned. */ + reused?: true +} export type WorkspaceListResponse = WorkspaceDescriptor[] export type WorkspaceDetailResponse = WorkspaceDescriptor @@ -365,6 +369,10 @@ export interface VoiceModeStateResponse { enabled: boolean } +export interface YoloStateResponse { + enabled: boolean +} + export interface RemoteServerProfile { id: string name: string @@ -414,6 +422,8 @@ export type WorkspaceEventType = | "instance.dataChanged" | "instance.event" | "instance.eventStatus" + | "yolo.stateChanged" + | "yolo.autoAccepted" export type WorkspaceEventPayload = | { type: "workspace.created"; workspace: WorkspaceDescriptor } @@ -428,6 +438,8 @@ export type WorkspaceEventPayload = | { type: "instance.dataChanged"; instanceId: string; data: InstanceData } | { type: "instance.event"; instanceId: string; event: InstanceStreamEvent } | { type: "instance.eventStatus"; instanceId: string; status: InstanceStreamStatus; reason?: string } + | { type: "yolo.stateChanged"; instanceId: string; sessionId: string; enabled: boolean } + | { type: "yolo.autoAccepted"; instanceId: string; sessionId: string; permissionId: string } export interface NetworkAddress { ip: string diff --git a/packages/server/src/events/bus.ts b/packages/server/src/events/bus.ts index fd1e3ce6..7929c7e2 100644 --- a/packages/server/src/events/bus.ts +++ b/packages/server/src/events/bus.ts @@ -31,6 +31,8 @@ export class EventBus extends EventEmitter { this.on("instance.dataChanged", handler) this.on("instance.event", handler) this.on("instance.eventStatus", handler) + this.on("yolo.stateChanged", handler) + this.on("yolo.autoAccepted", handler) return () => { this.off("workspace.created", handler) this.off("workspace.started", handler) @@ -44,6 +46,8 @@ export class EventBus extends EventEmitter { this.off("instance.dataChanged", handler) this.off("instance.event", handler) this.off("instance.eventStatus", handler) + this.off("yolo.stateChanged", handler) + this.off("yolo.autoAccepted", handler) } } } diff --git a/packages/server/src/permissions/auto-accept-manager.test.ts b/packages/server/src/permissions/auto-accept-manager.test.ts new file mode 100644 index 00000000..629ccf99 --- /dev/null +++ b/packages/server/src/permissions/auto-accept-manager.test.ts @@ -0,0 +1,668 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { EventBus } from "../events/bus" +import { AutoAcceptManager, type PermissionReplier, type AutoAcceptReply } from "./auto-accept-manager" +import type { InstanceStreamEvent } from "../api-types" +import type { Logger } from "../logger" + +const noopLogger: Logger = { + debug() {}, + info() {}, + warn() {}, + error() {}, + trace() {}, + isLevelEnabled() { + return false + }, + child() { + return noopLogger + }, +} as unknown as Logger + +function publishInstanceEvent(bus: EventBus, instanceId: string, event: Record) { + bus.publish({ type: "instance.event", instanceId, event: { ...event } as InstanceStreamEvent }) +} + +/** Publish a `session.*` event using the real OpenCode shape (`properties.info`). */ +function publishSession( + bus: EventBus, + instanceId: string, + eventType: "session.updated" | "session.created" | "session.deleted", + info: Record, +) { + publishInstanceEvent(bus, instanceId, { type: eventType, properties: { info: { ...info } } }) +} + +describe("AutoAcceptManager session tree", () => { + it("ingests session.updated to build the parent chain", () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "master", parentID: null }) + publishSession(bus, "inst", "session.updated", { id: "child", parentID: "master" }) + + assert.equal(manager.isEnabled("inst", "master"), false) + manager.toggle("inst", "child") + assert.equal(manager.isEnabled("inst", "child"), true) + assert.equal(manager.isEnabled("inst", "master"), true) + + manager.stop() + }) + + it("treats a session with revert as a fork root", () => { + const bus = new EventBus(noopLogger) + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier() }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "master", parentID: null }) + publishSession(bus, "inst", "session.updated", { + id: "fork", + parentID: "master", + revert: { messageID: "m", partID: "p" }, + }) + + manager.toggle("inst", "fork") + assert.equal(manager.isEnabled("inst", "fork"), true) + assert.equal(manager.isEnabled("inst", "master"), false) + + manager.stop() + }) + + it("session.deleted removes the tree entry but keeps the toggle", () => { + const bus = new EventBus(noopLogger) + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier() }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "master", parentID: null }) + manager.toggle("inst", "master") + publishSession(bus, "inst", "session.deleted", { id: "master" }) + + // toggle is independent of the tree (survives deletion) + assert.equal(manager.isEnabled("inst", "master"), true) + + manager.stop() + }) +}) + +describe("AutoAcceptManager permission interception", () => { + it("auto-replies to a v2 permission on an enabled family", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const accepted: Record[] = [] + bus.on("yolo.autoAccepted", (e) => accepted.push(e)) + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "master", parentID: null }) + publishSession(bus, "inst", "session.updated", { id: "child", parentID: "master" }) + manager.toggle("inst", "child") // enable the whole family root + + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "perm-1", sessionID: "child", action: "edit", resources: ["a.ts"] }, + }) + + await flushMicrotasks() + + assert.equal(replier.calls.length, 1) + const call = replier.calls[0] + assert.equal(call.instanceId, "inst") + assert.equal(call.permissionId, "perm-1") + assert.equal(call.sessionId, "child") + assert.equal(call.source, "v2") + assert.equal(call.reply, "once") + assert.equal(accepted.length, 1) + assert.equal((accepted[0] as any).permissionId, "perm-1") + + manager.stop() + }) + + it("auto-replies to a legacy permission.asked event", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + manager.toggle("inst", "solo") + + publishInstanceEvent(bus, "inst", { + type: "permission.asked", + properties: { id: "perm-2", sessionID: "solo", type: "bash" }, + }) + + await flushMicrotasks() + + assert.equal(replier.calls.length, 1) + assert.equal(replier.calls[0].source, "legacy") + assert.equal(replier.calls[0].permissionId, "perm-2") + + manager.stop() + }) + + it("does not reply when the family is disabled", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "perm-3", sessionID: "solo" }, + }) + + await flushMicrotasks() + assert.equal(replier.calls.length, 0) + + manager.stop() + }) + + it("ignores permission events without an id or sessionID", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + manager.toggle("inst", "solo") + + publishInstanceEvent(bus, "inst", { type: "permission.v2.asked", properties: { sessionID: "solo" } }) + publishInstanceEvent(bus, "inst", { type: "permission.v2.asked", properties: { id: "x" } }) + await flushMicrotasks() + + assert.equal(replier.calls.length, 0) + manager.stop() + }) + + it("deduplicates repeated emission of the same permission", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + manager.toggle("inst", "solo") + + for (let i = 0; i < 3; i++) { + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "perm-dup", sessionID: "solo" }, + }) + } + await flushMicrotasks() + + assert.equal(replier.calls.length, 1) + manager.stop() + }) + + it("clears in-flight tracking after the reply resolves so it can retry", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + manager.toggle("inst", "solo") + + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "perm-retry", sessionID: "solo" }, + }) + await flushMicrotasks() + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "perm-retry", sessionID: "solo" }, + }) + await flushMicrotasks() + + assert.equal(replier.calls.length, 2) + manager.stop() + }) +}) + +describe("AutoAcceptManager state events", () => { + it("publishes yolo.stateChanged with the new enabled value on toggle", () => { + const bus = new EventBus(noopLogger) + const changes: Record[] = [] + bus.on("yolo.stateChanged", (e) => changes.push(e)) + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier() }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "master", parentID: null }) + + manager.toggle("inst", "master") + manager.toggle("inst", "master") + + assert.equal(changes.length, 2) + assert.equal((changes[0] as any).enabled, true) + assert.equal((changes[1] as any).enabled, false) + assert.equal((changes[0] as any).sessionId, "master") + assert.equal((changes[0] as any).instanceId, "inst") + + manager.stop() + }) +}) + +describe("AutoAcceptManager lifecycle", () => { + it("clearInstance drops tree and enabled state for the instance", () => { + const bus = new EventBus(noopLogger) + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier() }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "master", parentID: null }) + manager.toggle("inst", "master") + manager.clearInstance("inst") + + assert.equal(manager.isEnabled("inst", "master"), false) + manager.stop() + }) + + it("clears state when the workspace stops", () => { + const bus = new EventBus(noopLogger) + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier() }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "master", parentID: null }) + manager.toggle("inst", "master") + bus.publish({ type: "workspace.stopped", workspaceId: "inst" }) + + assert.equal(manager.isEnabled("inst", "master"), false) + manager.stop() + }) + + it("stop() unsubscribes so no further events are processed", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + manager.stop() + + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + manager.toggle("inst", "solo") + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "p", sessionID: "solo" }, + }) + await flushMicrotasks() + + assert.equal(replier.calls.length, 0) + }) +}) + +describe("AutoAcceptManager pending permissions drain", () => { + it("drains a pending permission that arrived before enable", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + + // permission arrives while yolo is OFF + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "perm-pending", sessionID: "solo" }, + }) + await flushMicrotasks() + assert.equal(replier.calls.length, 0) + + // enabling yolo should drain the pending permission + manager.toggle("inst", "solo") + await flushMicrotasks() + + assert.equal(replier.calls.length, 1) + assert.equal(replier.calls[0].permissionId, "perm-pending") + + manager.stop() + }) + + it("drains pending permissions for the same family only", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "root-a", parentID: null }) + publishSession(bus, "inst", "session.updated", { id: "root-b", parentID: null }) + + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "perm-a", sessionID: "root-a" }, + }) + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "perm-b", sessionID: "root-b" }, + }) + await flushMicrotasks() + assert.equal(replier.calls.length, 0) + + manager.toggle("inst", "root-a") + await flushMicrotasks() + + assert.equal(replier.calls.length, 1) + assert.equal(replier.calls[0].permissionId, "perm-a") + + manager.stop() + }) + + it("does not re-drain already-auto-accepted permissions", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + manager.toggle("inst", "solo") + + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "perm-1", sessionID: "solo" }, + }) + await flushMicrotasks() + assert.equal(replier.calls.length, 1) + + // toggling off then on should not re-drain the already-replied permission + manager.toggle("inst", "solo") // off + manager.toggle("inst", "solo") // on — drain runs but pending set is empty + await flushMicrotasks() + + assert.equal(replier.calls.length, 1) + + manager.stop() + }) + + it("re-drains pending when late session ancestry joins an enabled family", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + + // master exists, yolo enabled on master + publishSession(bus, "inst", "session.updated", { id: "master", parentID: null }) + manager.toggle("inst", "master") + + // child permission arrives BEFORE child's session ancestry is known. + // At this point "child" is unknown to the tree, so it resolves as its + // own root and the permission is NOT auto-accepted. + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "perm-late", sessionID: "child" }, + }) + await flushMicrotasks() + assert.equal(replier.calls.length, 0) + + // child session ancestry arrives — child.parentID = "master". + // ingestSession → upsertSession → migrateEnabledRoots makes "child" + // resolve to "master" (enabled). drainPending should fire and accept + // the previously-pending permission. + publishSession(bus, "inst", "session.updated", { id: "child", parentID: "master" }) + await flushMicrotasks() + + assert.equal(replier.calls.length, 1) + assert.equal(replier.calls[0].permissionId, "perm-late") + assert.equal(replier.calls[0].sessionId, "child") + + manager.stop() + }) +}) + +describe("AutoAcceptManager permission replied cleanup", () => { + it("removes a pending permission on permission.v2.replied", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "perm-x", sessionID: "solo" }, + }) + await flushMicrotasks() + + // user manually replies → replied event cleans up pending + publishInstanceEvent(bus, "inst", { + type: "permission.v2.replied", + properties: { id: "perm-x" }, + }) + + // enabling yolo now should NOT drain the already-replied permission + manager.toggle("inst", "solo") + await flushMicrotasks() + + assert.equal(replier.calls.length, 0) + manager.stop() + }) + + it("removes a pending permission on legacy permission.replied", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + + publishInstanceEvent(bus, "inst", { + type: "permission.asked", + properties: { id: "perm-y", sessionID: "solo" }, + }) + await flushMicrotasks() + + publishInstanceEvent(bus, "inst", { + type: "permission.replied", + properties: { requestID: "perm-y" }, + }) + + manager.toggle("inst", "solo") + await flushMicrotasks() + + assert.equal(replier.calls.length, 0) + manager.stop() + }) +}) + +describe("AutoAcceptManager clearInstance clears pending", () => { + it("drops pending permissions on clearInstance", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "perm-z", sessionID: "solo" }, + }) + await flushMicrotasks() + + manager.clearInstance("inst") + + // re-create session and enable — pending set should be empty + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + manager.toggle("inst", "solo") + await flushMicrotasks() + + assert.equal(replier.calls.length, 0) + manager.stop() + }) +}) + +describe("AutoAcceptManager permission.updated source inference", () => { + it("preserves the original v2 source when permission.updated arrives", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + // yolo is OFF — permission goes to pending with source "v2" + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "perm-v2", sessionID: "solo" }, + }) + await flushMicrotasks() + + // enable yolo, then send permission.updated — should keep source "v2" + manager.toggle("inst", "solo") + publishInstanceEvent(bus, "inst", { + type: "permission.updated", + properties: { id: "perm-v2", sessionID: "solo" }, + }) + await flushMicrotasks() + + assert.equal(replier.calls.length, 1) + assert.equal(replier.calls[0].source, "v2") + + manager.stop() + }) + + it("skips permission.updated for a permission not in pending", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + manager.toggle("inst", "solo") + + // permission.updated for a permission that was never asked (not in pending) + publishInstanceEvent(bus, "inst", { + type: "permission.updated", + properties: { id: "perm-unknown", sessionID: "solo" }, + }) + await flushMicrotasks() + + assert.equal(replier.calls.length, 0) + manager.stop() + }) +}) + +describe("AutoAcceptManager replier failure handling", () => { + it("keeps permission pending and does not emit autoAccepted on failure", async () => { + const bus = new EventBus(noopLogger) + const autoAccepted: unknown[] = [] + bus.on("yolo.autoAccepted", (e) => autoAccepted.push(e)) + const failingReplier: PermissionReplier = async () => { + throw new Error("connection refused") + } + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: failingReplier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + manager.toggle("inst", "solo") + + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "perm-fail", sessionID: "solo" }, + }) + await flushMicrotasks() + + assert.equal(autoAccepted.length, 0) + // permission should still be pending (enabling again would try again) + manager.toggle("inst", "solo") // off + manager.toggle("inst", "solo") // on — drain retries + await flushMicrotasks() + + assert.equal(autoAccepted.length, 0) + + manager.stop() + }) + + it("stops retrying after MAX_REPLY_ATTEMPTS", async () => { + const bus = new EventBus(noopLogger) + let callCount = 0 + const failingReplier: PermissionReplier = async () => { + callCount++ + throw new Error("timeout") + } + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: failingReplier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + manager.toggle("inst", "solo") + + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "perm-stuck", sessionID: "solo" }, + }) + await flushMicrotasks() + const attemptsAfterFirst = callCount + + // trigger drains via session events + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + await flushMicrotasks() + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + await flushMicrotasks() + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + await flushMicrotasks() + + // should have attempted at most MAX_REPLY_ATTEMPTS times total + assert.ok(callCount <= 3, `expected at most 3 attempts, got ${callCount}`) + assert.equal(callCount, attemptsAfterFirst + 2) // 2 more retries (total 3), then stops + + manager.stop() + }) +}) + +describe("AutoAcceptManager workspace.error cleanup", () => { + it("clears state when the workspace errors", () => { + const bus = new EventBus(noopLogger) + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier() }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + manager.toggle("inst", "solo") + assert.equal(manager.isEnabled("inst", "solo"), true) + + bus.publish({ type: "workspace.error", workspace: { id: "inst" } as any }) + + assert.equal(manager.isEnabled("inst", "solo"), false) + manager.stop() + }) +}) + +describe("AutoAcceptManager session.deleted clears pending", () => { + it("removes pending permissions for a deleted session", async () => { + const bus = new EventBus(noopLogger) + const replier = makeRecordingReplier() + const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) + manager.start() + + publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) + + // permission arrives while yolo is OFF → goes to pending + publishInstanceEvent(bus, "inst", { + type: "permission.v2.asked", + properties: { id: "perm-doomed", sessionID: "solo" }, + }) + await flushMicrotasks() + + // session deleted → pending should be cleared + publishSession(bus, "inst", "session.deleted", { id: "solo" }) + + // enabling yolo should not drain the deleted session's permission + manager.toggle("inst", "solo") + await flushMicrotasks() + + assert.equal(replier.calls.length, 0) + manager.stop() + }) +}) + +function makeRecordingReplier() { + const calls: AutoAcceptReply[] = [] + const replier: PermissionReplier = async (reply) => { + calls.push(reply) + } + return Object.assign(replier, { calls }) as PermissionReplier & { calls: AutoAcceptReply[] } +} + +function flushMicrotasks() { + return new Promise((resolve) => setImmediate(resolve)) +} diff --git a/packages/server/src/permissions/auto-accept-manager.ts b/packages/server/src/permissions/auto-accept-manager.ts new file mode 100644 index 00000000..07b79d15 --- /dev/null +++ b/packages/server/src/permissions/auto-accept-manager.ts @@ -0,0 +1,296 @@ +import type { EventBus } from "../events/bus" +import type { Logger } from "../logger" +import { AutoAcceptStore } from "./auto-accept-store" + +/** + * Server-side owner of Yolo (permission auto-accept). + * + * Subscribes to the instance SSE stream that the server already consumes + * (`InstanceEventBridge` -> EventBus `instance.event`) and: + * - maintains a per-instance session tree so family-root inheritance can + * be resolved identically to the previous frontend implementation + * - when a permission request arrives for an enabled family, auto-replies + * via the injected {@link PermissionReplier} (same `"once"` semantics the + * UI used to send) + * - emits `yolo.stateChanged` / `yolo.autoAccepted` events on the EventBus + * so the UI stays a pure view + */ + +export type PermissionSource = "v2" | "legacy" +export type PermissionReplyValue = "once" + +export interface AutoAcceptReply { + instanceId: string + permissionId: string + sessionId: string + source: PermissionSource + reply: PermissionReplyValue +} + +export type PermissionReplier = (reply: AutoAcceptReply) => Promise + +interface PendingPermission { + permissionId: string + sessionId: string + source: PermissionSource +} + +interface AutoAcceptManagerDeps { + eventBus: EventBus + logger: Logger + replier: PermissionReplier +} + +const PERMISSION_ASK_TYPES = new Set(["permission.v2.asked", "permission.asked", "permission.updated"]) +const PERMISSION_REPLIED_TYPES = new Set(["permission.v2.replied", "permission.replied"]) +const SESSION_UPSERT_TYPES = new Set(["session.updated", "session.created"]) +const SESSION_REMOVE_TYPES = new Set(["session.deleted"]) + +export class AutoAcceptManager { + private static readonly MAX_REPLY_ATTEMPTS = 3 + private readonly store = new AutoAcceptStore() + /** instanceId:permissionId entries currently being replied, to dedupe re-emissions */ + private readonly inFlight = new Set() + /** instanceId -> (permissionId -> pending permission) awaiting a reply */ + private readonly pending = new Map>() + /** instanceId:permissionId -> failure count, to stop retrying stuck permissions */ + private readonly replyAttempts = new Map() + private unsubscribe?: () => void + + constructor(private readonly deps: AutoAcceptManagerDeps) {} + + start(): void { + if (this.unsubscribe) return + const handler = (payload: { instanceId?: string; event?: InstanceStreamPayload }) => { + if (!payload || !payload.instanceId || !payload.event) return + this.handleInstanceEvent(payload.instanceId, payload.event) + } + const onStopped = (event: { workspaceId?: string }) => { + if (event?.workspaceId) this.clearInstance(event.workspaceId) + } + const onError = (event: { workspace?: { id?: string } }) => { + if (event?.workspace?.id) this.clearInstance(event.workspace.id) + } + this.deps.eventBus.on("instance.event", handler) + this.deps.eventBus.on("workspace.stopped", onStopped) + this.deps.eventBus.on("workspace.error", onError) + this.unsubscribe = () => { + this.deps.eventBus.off("instance.event", handler) + this.deps.eventBus.off("workspace.stopped", onStopped) + this.deps.eventBus.off("workspace.error", onError) + } + } + + stop(): void { + this.unsubscribe?.() + this.unsubscribe = undefined + } + + isEnabled(instanceId: string, sessionId: string): boolean { + return this.store.isEnabled(instanceId, sessionId) + } + + toggle(instanceId: string, sessionId: string): boolean { + const enabled = this.store.toggle(instanceId, sessionId) + this.deps.eventBus.publish({ type: "yolo.stateChanged", instanceId, sessionId, enabled }) + if (enabled) { + this.drainPending(instanceId, sessionId) + } + return enabled + } + + clearInstance(instanceId: string): void { + this.store.clearInstance(instanceId) + this.pending.delete(instanceId) + const prefix = `${instanceId}:` + for (const key of Array.from(this.inFlight.keys())) { + if (key.startsWith(prefix)) this.inFlight.delete(key) + } + for (const key of Array.from(this.replyAttempts.keys())) { + if (key.startsWith(prefix)) this.replyAttempts.delete(key) + } + } + + handleInstanceEvent(instanceId: string, event: InstanceStreamPayload): void { + if (!event || typeof event.type !== "string") return + + if (SESSION_UPSERT_TYPES.has(event.type)) { + this.ingestSession(instanceId, event.properties) + return + } + if (SESSION_REMOVE_TYPES.has(event.type)) { + const info = (event.properties as { info?: SessionProperties } | undefined)?.info + const id = readString(info?.id) ?? readString(event.properties?.id) + if (id) { + this.store.removeSession(instanceId, id) + this.removePendingForSession(instanceId, id) + } + return + } + if (PERMISSION_REPLIED_TYPES.has(event.type)) { + this.handlePermissionReplied(instanceId, event.properties) + return + } + if (PERMISSION_ASK_TYPES.has(event.type)) { + this.handlePermissionRequest(instanceId, event.type, event.properties) + } + } + + private ingestSession(instanceId: string, properties: unknown): void { + // OpenCode wraps session records under `properties.info` for + // session.created/updated/deleted (see SDK EventSessionUpdated). Accept a + // flat fallback only for defensive compatibility. + const info = (properties as { info?: SessionProperties } | SessionProperties | undefined) + const session = (info && typeof info === "object" && "info" in info ? info.info : info) as + | SessionProperties + | undefined + if (!session || typeof session.id !== "string") return + const parentId = session.parentID ?? session.parentId ?? null + const revert = session.revert ?? undefined + this.store.upsertSession(instanceId, { id: session.id, parentId, revert }) + // Session ancestry may have changed (parent discovered, revert toggled). + // Re-drain pending permissions whose family root may have migrated into + // an enabled family — mirrors the old UI's drainAutoAcceptPermissions- + // ForInstance trigger on session.updated (#497). + this.drainPending(instanceId, session.id) + } + + private handlePermissionRequest(instanceId: string, eventType: string, permission: unknown): void { + const request = permission as PermissionProperties | undefined + if (!request) return + const permissionId = readString(request.id) + const sessionId = readString(request.sessionID) ?? readString(request.sessionId) + if (!permissionId || !sessionId) return + + // Infer source from the event type, but prefer the already-tracked source + // for permission.updated (which may belong to a v2 permission). + const existing = this.pending.get(instanceId)?.get(permissionId) + const source: PermissionSource = eventType === "permission.v2.asked" ? "v2" : (existing?.source ?? "legacy") + + // `permission.updated` represents a detail change for a permission that + // is *already* pending. If it is no longer in our pending set it was + // already replied to (by us or the user) — skip to avoid a duplicate reply. + if (eventType === "permission.updated" && !this.pending.get(instanceId)?.has(permissionId)) { + return + } + + this.addPending(instanceId, { permissionId, sessionId, source }) + + if (!this.store.isEnabled(instanceId, sessionId)) return + this.tryAutoAccept(instanceId, permissionId, sessionId, source) + } + + private handlePermissionReplied(instanceId: string, properties: unknown): void { + const request = (properties as PermissionRepliedProperties | undefined) ?? {} + const permissionId = + readString(request.id) ?? + readString(request.requestID) ?? + readString(request.permissionID) ?? + readString(request.requestId) ?? + readString(request.permissionId) + if (permissionId) this.removePending(instanceId, permissionId) + } + + private tryAutoAccept( + instanceId: string, + permissionId: string, + sessionId: string, + source: PermissionSource, + ): void { + const key = `${instanceId}:${permissionId}` + if (this.inFlight.has(key)) return + const attempts = this.replyAttempts.get(key) ?? 0 + if (attempts >= AutoAcceptManager.MAX_REPLY_ATTEMPTS) return + this.inFlight.add(key) + this.replyAttempts.set(key, attempts + 1) + + const reply: AutoAcceptReply = { instanceId, permissionId, sessionId, source, reply: "once" } + + void this.deps.replier(reply) + .then(() => { + this.replyAttempts.delete(key) + this.removePending(instanceId, permissionId) + this.deps.eventBus.publish({ type: "yolo.autoAccepted", instanceId, sessionId, permissionId }) + }) + .catch((error) => { + this.deps.logger.error({ instanceId, permissionId, err: error, attempt: attempts + 1 }, "Yolo auto-accept reply failed") + if (attempts + 1 >= AutoAcceptManager.MAX_REPLY_ATTEMPTS) { + this.removePending(instanceId, permissionId) + } + }) + .finally(() => { + this.inFlight.delete(key) + }) + } + + /** Auto-accept all pending permissions belonging to the same family root. */ + private drainPending(instanceId: string, sessionId: string): void { + const instancePending = this.pending.get(instanceId) + if (!instancePending || instancePending.size === 0) return + const root = this.store.familyRoot(instanceId, sessionId) + for (const entry of Array.from(instancePending.values())) { + if (this.store.familyRoot(instanceId, entry.sessionId) === root) { + this.tryAutoAccept(instanceId, entry.permissionId, entry.sessionId, entry.source) + } + } + } + + private addPending(instanceId: string, entry: PendingPermission): void { + let instancePending = this.pending.get(instanceId) + if (!instancePending) { + instancePending = new Map() + this.pending.set(instanceId, instancePending) + } + instancePending.set(entry.permissionId, entry) + } + + private removePending(instanceId: string, permissionId: string): void { + const instancePending = this.pending.get(instanceId) + if (instancePending?.delete(permissionId)) { + this.replyAttempts.delete(`${instanceId}:${permissionId}`) + if (instancePending.size === 0) this.pending.delete(instanceId) + } + } + + private removePendingForSession(instanceId: string, sessionId: string): void { + const instancePending = this.pending.get(instanceId) + if (!instancePending) return + for (const [permId, entry] of Array.from(instancePending)) { + if (entry.sessionId === sessionId) { + instancePending.delete(permId) + this.replyAttempts.delete(`${instanceId}:${permId}`) + } + } + if (instancePending.size === 0) this.pending.delete(instanceId) + } +} + +interface InstanceStreamPayload { + type?: string + properties?: Record +} + +interface SessionProperties { + id?: string + parentID?: string | null + parentId?: string | null + revert?: unknown +} + +interface PermissionProperties { + id?: string + sessionID?: string + sessionId?: string +} + +interface PermissionRepliedProperties { + id?: string + requestID?: string + permissionID?: string + requestId?: string + permissionId?: string +} + +function readString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined +} diff --git a/packages/server/src/permissions/auto-accept-store.test.ts b/packages/server/src/permissions/auto-accept-store.test.ts new file mode 100644 index 00000000..5ccfd540 --- /dev/null +++ b/packages/server/src/permissions/auto-accept-store.test.ts @@ -0,0 +1,182 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { AutoAcceptStore, resolveFamilyRoot } from "./auto-accept-store" + +describe("resolveFamilyRoot", () => { + it("returns the session id itself when no info is known", () => { + assert.equal(resolveFamilyRoot("orphan", () => undefined), "orphan") + }) + + it("keeps a loaded child as root when its parent is missing", () => { + const root = resolveFamilyRoot("child", (id) => + id === "child" ? { id: "child", parentId: "parent" } : undefined, + ) + assert.equal(root, "child") + }) + + it("resolves to the master session when the full parent chain is loaded", () => { + const root = resolveFamilyRoot("grandchild", (id) => { + if (id === "grandchild") return { id: "grandchild", parentId: "child" } + if (id === "child") return { id: "child", parentId: "master" } + if (id === "master") return { id: "master", parentId: null } + return undefined + }) + assert.equal(root, "master") + }) + + it("keeps a fork session (with revert) as its own root", () => { + const root = resolveFamilyRoot("fork", (id) => { + if (id === "fork") + return { id: "fork", parentId: "master", revert: { messageID: "msg", partID: "part" } } + if (id === "master") return { id: "master", parentId: null } + return undefined + }) + assert.equal(root, "fork") + }) + + it("terminates on cyclic parent chains without looping forever", () => { + const root = resolveFamilyRoot("a", (id) => { + if (id === "a") return { id: "a", parentId: "b" } + if (id === "b") return { id: "b", parentId: "a" } + return undefined + }) + // cycle: last known id before re-entering the cycle is returned + assert.ok(root === "a" || root === "b") + }) +}) + +describe("AutoAcceptStore inheritance", () => { + it("is disabled by default for an unknown session", () => { + const store = new AutoAcceptStore() + assert.equal(store.isEnabled("inst", "s1"), false) + }) + + it("enabling a parent enables every descendant that resolves to it", () => { + const store = new AutoAcceptStore() + store.upsertSession("inst", { id: "master", parentId: null }) + store.upsertSession("inst", { id: "child", parentId: "master" }) + store.upsertSession("inst", { id: "grandchild", parentId: "child" }) + + store.setEnabled("inst", "master", true) + + assert.equal(store.isEnabled("inst", "master"), true) + assert.equal(store.isEnabled("inst", "child"), true) + assert.equal(store.isEnabled("inst", "grandchild"), true) + }) + + it("enabling a child also covers the parent family root and siblings", () => { + const store = new AutoAcceptStore() + store.upsertSession("inst", { id: "master", parentId: null }) + store.upsertSession("inst", { id: "child-a", parentId: "master" }) + store.upsertSession("inst", { id: "child-b", parentId: "master" }) + + store.setEnabled("inst", "child-a", true) + + assert.equal(store.isEnabled("inst", "child-a"), true) + assert.equal(store.isEnabled("inst", "child-b"), true) + assert.equal(store.isEnabled("inst", "master"), true) + }) + + it("a fork session is isolated: enabling it does not enable its parent", () => { + const store = new AutoAcceptStore() + store.upsertSession("inst", { id: "master", parentId: null }) + store.upsertSession("inst", { + id: "fork", + parentId: "master", + revert: { messageID: "msg", partID: "part" }, + }) + + store.setEnabled("inst", "fork", true) + + assert.equal(store.isEnabled("inst", "fork"), true) + assert.equal(store.isEnabled("inst", "master"), false) + }) + + it("disabling the family root clears the setting for all descendants", () => { + const store = new AutoAcceptStore() + store.upsertSession("inst", { id: "master", parentId: null }) + store.upsertSession("inst", { id: "child", parentId: "master" }) + + store.setEnabled("inst", "child", true) + assert.equal(store.isEnabled("inst", "child"), true) + + store.setEnabled("inst", "master", false) + assert.equal(store.isEnabled("inst", "child"), false) + assert.equal(store.isEnabled("inst", "master"), false) + }) + + it("toggle flips the resolved family-root state and reports the new value", () => { + const store = new AutoAcceptStore() + store.upsertSession("inst", { id: "master", parentId: null }) + store.upsertSession("inst", { id: "child", parentId: "master" }) + + assert.equal(store.toggle("inst", "child"), true) + assert.equal(store.isEnabled("inst", "child"), true) + assert.equal(store.toggle("inst", "master"), false) + assert.equal(store.isEnabled("inst", "child"), false) + }) + + it("keeps per-instance state independent", () => { + const store = new AutoAcceptStore() + store.upsertSession("inst-a", { id: "root", parentId: null }) + store.upsertSession("inst-b", { id: "root", parentId: null }) + + store.setEnabled("inst-a", "root", true) + assert.equal(store.isEnabled("inst-a", "root"), true) + assert.equal(store.isEnabled("inst-b", "root"), false) + }) +}) + +describe("AutoAcceptStore session tree maintenance", () => { + it("migrates enabled root when a parent is discovered later", () => { + const store = new AutoAcceptStore() + store.upsertSession("inst", { id: "child", parentId: "parent" }) + // parent unknown -> child is its own root + store.setEnabled("inst", "child", true) + + // later the parent shows up — root should migrate from "child" to "parent" + store.upsertSession("inst", { id: "parent", parentId: null }) + assert.equal(store.isEnabled("inst", "parent"), true) + assert.equal(store.isEnabled("inst", "child"), true) + }) + + it("removing a session does not clear an enabled family root", () => { + const store = new AutoAcceptStore() + store.upsertSession("inst", { id: "master", parentId: null }) + store.setEnabled("inst", "master", true) + store.removeSession("inst", "master") + // the toggle is independent of the session tree: it survives session deletion + assert.equal(store.isEnabled("inst", "master"), true) + }) + + it("clearInstance drops both tree and enabled state", () => { + const store = new AutoAcceptStore() + store.upsertSession("inst", { id: "master", parentId: null }) + store.setEnabled("inst", "master", true) + store.clearInstance("inst") + assert.equal(store.isEnabled("inst", "master"), false) + store.upsertSession("inst", { id: "master", parentId: null }) + assert.equal(store.isEnabled("inst", "master"), false) + }) + + it("changing revert status re-roots a session as a fork", () => { + const store = new AutoAcceptStore() + store.upsertSession("inst", { id: "master", parentId: null }) + store.upsertSession("inst", { id: "child", parentId: "master" }) + store.setEnabled("inst", "child", true) + // parent family enabled + assert.equal(store.isEnabled("inst", "master"), true) + + // child becomes a fork + store.upsertSession("inst", { + id: "child", + parentId: "master", + revert: { messageID: "m", partID: "p" }, + }) + // now child resolves to itself; the family setting was on "master" so still on for master + assert.equal(store.isEnabled("inst", "master"), true) + // child is its own root now, not enabled unless toggled + assert.equal(store.isEnabled("inst", "child"), false) + }) +}) diff --git a/packages/server/src/permissions/auto-accept-store.ts b/packages/server/src/permissions/auto-accept-store.ts new file mode 100644 index 00000000..0e34a24e --- /dev/null +++ b/packages/server/src/permissions/auto-accept-store.ts @@ -0,0 +1,128 @@ +/** + * In-memory permission auto-accept (Yolo) state, owned by the server. + * + * This is a faithful port of the previous frontend implementation + * (`packages/ui/src/stores/permission-auto-accept.ts`) so the inheritance + * semantics are preserved exactly: + * - state is keyed by the resolved *family root* session id + * - a session with a `revert` snapshot is treated as its own root (fork) + * - enabling any session enables its whole family root and vice-versa + * + * No persistence: state is lost on server restart, matching the "no + * persistence for now" milestone. + */ + +export interface AutoAcceptSessionInfo { + id: string + parentId?: string | null + /** Truthy value marks the session as a fork that roots at itself. */ + revert?: unknown +} + +type SessionLookup = (sessionId: string) => AutoAcceptSessionInfo | undefined + +/** + * Resolve the family-root session id for `sessionId` by walking the parent + * chain. Mirrors `resolvePermissionAutoAcceptFamilyRoot` from the UI so + * inheritance behaviour does not change. + */ +export function resolveFamilyRoot(sessionId: string, getSession: SessionLookup): string { + let currentId = sessionId + let lastKnownId = sessionId + const seen = new Set() + + while (currentId && !seen.has(currentId)) { + seen.add(currentId) + const session = getSession(currentId) + if (!session) return lastKnownId + lastKnownId = session.id + if (session.revert) return session.id + if (!session.parentId) return session.id + currentId = session.parentId + } + + return currentId || sessionId +} + +export class AutoAcceptStore { + /** instanceId -> set of enabled family-root session ids */ + private readonly enabled = new Map>() + /** instanceId -> (sessionId -> info) */ + private readonly sessions = new Map>() + + isEnabled(instanceId: string, sessionId: string): boolean { + const root = this.familyRoot(instanceId, sessionId) + return this.enabled.get(instanceId)?.has(root) ?? false + } + + setEnabled(instanceId: string, sessionId: string, enabled: boolean): void { + const root = this.familyRoot(instanceId, sessionId) + let roots = this.enabled.get(instanceId) + if (!roots) { + if (!enabled) return + roots = new Set() + this.enabled.set(instanceId, roots) + } + if (enabled) { + roots.add(root) + } else { + roots.delete(root) + if (roots.size === 0) { + this.enabled.delete(instanceId) + } + } + } + + toggle(instanceId: string, sessionId: string): boolean { + const next = !this.isEnabled(instanceId, sessionId) + this.setEnabled(instanceId, sessionId, next) + return next + } + + upsertSession(instanceId: string, info: AutoAcceptSessionInfo): void { + let tree = this.sessions.get(instanceId) + if (!tree) { + tree = new Map() + this.sessions.set(instanceId, tree) + } + tree.set(info.id, { + id: info.id, + parentId: info.parentId ?? null, + revert: info.revert, + }) + this.migrateEnabledRoots(instanceId) + } + + removeSession(instanceId: string, sessionId: string): void { + this.sessions.get(instanceId)?.delete(sessionId) + } + + clearInstance(instanceId: string): void { + this.sessions.delete(instanceId) + this.enabled.delete(instanceId) + } + + /** Resolves the family-root session id for the given session. */ + familyRoot(instanceId: string, sessionId: string): string { + const tree = this.sessions.get(instanceId) + return resolveFamilyRoot(sessionId, (id) => tree?.get(id)) + } + + /** + * Re-resolves every enabled family root for an instance after the session + * tree changes (new session, updated parent/revert). If a root now resolves + * to a different id, the enabled entry is migrated so toggles survive late + * ancestry discovery. + */ + private migrateEnabledRoots(instanceId: string): void { + const roots = this.enabled.get(instanceId) + if (!roots || roots.size === 0) return + for (const oldRoot of Array.from(roots)) { + const newRoot = this.familyRoot(instanceId, oldRoot) + if (newRoot !== oldRoot) { + roots.delete(oldRoot) + roots.add(newRoot) + } + } + } +} diff --git a/packages/server/src/permissions/opencode-replier.ts b/packages/server/src/permissions/opencode-replier.ts new file mode 100644 index 00000000..2ed9bfc9 --- /dev/null +++ b/packages/server/src/permissions/opencode-replier.ts @@ -0,0 +1,47 @@ +import type { WorkspaceManager } from "../workspaces/manager" +import type { Logger } from "../logger" +import { createInstanceClient } from "../workspaces/instance-client" +import type { AutoAcceptReply, PermissionReplier } from "./auto-accept-manager" + +interface OpencodeReplierDeps { + workspaceManager: WorkspaceManager + logger: Logger +} + +/** + * Default {@link PermissionReplier} that calls the OpenCode instance via the + * generated SDK client over loopback, using the same `"once"` reply the UI + * previously sent. + * + * Uses `createInstanceClient` so routes and body shapes are always correct + * for the installed SDK version — no hand-assembled URLs. + */ +export function createOpencodePermissionReplier(deps: OpencodeReplierDeps): PermissionReplier { + return async (reply: AutoAcceptReply) => { + const client = createInstanceClient(deps.workspaceManager, reply.instanceId) + if (!client) { + throw new Error(`Yolo: instance ${reply.instanceId} has no open port`) + } + + const opts = { throwOnError: true } as const + + if (reply.source === "v2") { + await client.v2.session.permission.reply( + { + sessionID: reply.sessionId, + requestID: reply.permissionId, + reply: reply.reply, + }, + opts, + ) + } else { + await client.permission.reply( + { + requestID: reply.permissionId, + reply: reply.reply, + }, + opts, + ) + } + } +} diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index 5dd85468..a5b13b37 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -22,6 +22,7 @@ import { registerEventRoutes } from "./routes/events" import { registerStorageRoutes } from "./routes/storage" import { registerPluginRoutes } from "./routes/plugin" import { registerBackgroundProcessRoutes } from "./routes/background-processes" +import { registerYoloRoutes } from "./routes/yolo" import { registerWorktreeRoutes } from "./routes/worktrees" import { registerSpeechRoutes } from "./routes/speech" import { registerRemoteServerRoutes } from "./routes/remote-servers" @@ -31,6 +32,8 @@ import { registerPreviewRoutes } from "./routes/previews" import { ServerMeta } from "../api-types" import { InstanceStore } from "../storage/instance-store" import { BackgroundProcessManager } from "../background-processes/manager" +import { AutoAcceptManager } from "../permissions/auto-accept-manager" +import { createOpencodePermissionReplier } from "../permissions/opencode-replier" import type { AuthManager } from "../auth/manager" import { registerAuthRoutes } from "./routes/auth" import { sendUnauthorized, wantsHtml } from "../auth/http-auth" @@ -192,6 +195,16 @@ export function createHttpServer(deps: HttpServerDeps) { logger: deps.logger.child({ component: "background-processes" }), }) + const yoloManager = new AutoAcceptManager({ + eventBus: deps.eventBus, + logger: deps.logger.child({ component: "yolo" }), + replier: createOpencodePermissionReplier({ + workspaceManager: deps.workspaceManager, + logger: deps.logger.child({ component: "yolo" }), + }), + }) + yoloManager.start() + registerAuthRoutes(app, { authManager: deps.authManager }) app.addHook("preHandler", (request, reply, done) => { @@ -309,6 +322,7 @@ export function createHttpServer(deps: HttpServerDeps) { voiceModeManager: deps.voiceModeManager, }) registerBackgroundProcessRoutes(app, { backgroundProcessManager }) + registerYoloRoutes(app, { yoloManager }) registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger }) @@ -371,6 +385,7 @@ export function createHttpServer(deps: HttpServerDeps) { return { port: actualPort, url: serverUrl, displayHost } }, stop: () => { + yoloManager.stop() closeSseClients() return app.close() }, diff --git a/packages/server/src/server/routes/workspaces.ts b/packages/server/src/server/routes/workspaces.ts index 7bb8a1b0..5450f38e 100644 --- a/packages/server/src/server/routes/workspaces.ts +++ b/packages/server/src/server/routes/workspaces.ts @@ -14,6 +14,7 @@ interface RouteDeps { const WorkspaceCreateSchema = z.object({ path: z.string(), name: z.string().optional(), + forceNew: z.boolean().optional(), }) const WorkspaceCloneSchema = z.object({ @@ -68,9 +69,9 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) { app.post("/api/workspaces", async (request, reply) => { try { const body = WorkspaceCreateSchema.parse(request.body ?? {}) - const workspace = await deps.workspaceManager.create(body.path, body.name) + const result = await deps.workspaceManager.create(body.path, body.name, { forceNew: body.forceNew }) reply.code(201) - return workspace + return result.created ? result.workspace : { ...result.workspace, reused: true as const } } catch (error) { request.log.error({ err: error }, "Failed to create workspace") const message = error instanceof Error ? error.message : "Failed to create workspace" diff --git a/packages/server/src/server/routes/yolo.ts b/packages/server/src/server/routes/yolo.ts new file mode 100644 index 00000000..ddd66985 --- /dev/null +++ b/packages/server/src/server/routes/yolo.ts @@ -0,0 +1,26 @@ +import { FastifyInstance } from "fastify" +import type { AutoAcceptManager } from "../../permissions/auto-accept-manager" + +interface RouteDeps { + yoloManager: AutoAcceptManager +} + +export function registerYoloRoutes(app: FastifyInstance, deps: RouteDeps) { + app.get<{ Params: { id: string; sessionId: string } }>( + "/workspaces/:id/yolo/sessions/:sessionId", + async (request) => { + const { id, sessionId } = request.params + return { enabled: deps.yoloManager.isEnabled(id, sessionId) } + }, + ) + + app.post<{ Params: { id: string; sessionId: string } }>( + "/workspaces/:id/yolo/sessions/:sessionId/toggle", + async (request, reply) => { + const { id, sessionId } = request.params + const enabled = deps.yoloManager.toggle(id, sessionId) + reply.code(200) + return { enabled } + }, + ) +} diff --git a/packages/server/src/workspaces/__tests__/workspace-identity.test.ts b/packages/server/src/workspaces/__tests__/workspace-identity.test.ts new file mode 100644 index 00000000..b3686cbe --- /dev/null +++ b/packages/server/src/workspaces/__tests__/workspace-identity.test.ts @@ -0,0 +1,256 @@ +import assert from "node:assert/strict" +import { mkdtemp, mkdir, rm, symlink } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { afterEach, describe, it } from "node:test" +import pino from "pino" + +import { EventBus } from "../../events/bus" +import { WorkspaceManager } from "../manager" +import { normalizeWorkspaceIdentityPath, resolveWorkspaceIdentity } from "../workspace-identity" + +const temporaryDirectories: string[] = [] + +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, resolve, reject } +} + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { force: true, recursive: true }))) +}) + +async function createLinkedWorkspace(): Promise<{ root: string; target: string; link: string }> { + const root = await mkdtemp(path.join(os.tmpdir(), "codenomad-workspace-identity-")) + temporaryDirectories.push(root) + const target = path.join(root, "target") + const link = path.join(root, "link") + await mkdir(target) + await symlink(target, link, process.platform === "win32" ? "junction" : "dir") + return { root, target, link } +} + +function createManager(rootDir: string): WorkspaceManager { + const logger = pino({ level: "silent" }) + const manager = new WorkspaceManager({ + rootDir, + settings: { getOwner: () => ({ environmentVariables: {} }) }, + binaryResolver: { + resolveDefault: () => ({ path: process.execPath, label: "Node.js", version: process.version }), + }, + eventBus: new EventBus(logger), + logger, + getServerBaseUrl: () => "http://127.0.0.1:3000", + } as unknown as ConstructorParameters[0]) + + const internal = manager as any + internal.runtime.launch = async () => ({ + pid: 123, + port: 4321, + exitPromise: new Promise(() => {}), + getLastOutput: () => "", + }) + internal.waitForWorkspaceReadiness = async () => undefined + return manager +} + +describe("workspace identity", () => { + it("normalizes Windows drive and UNC paths without affecting POSIX case", () => { + assert.equal(normalizeWorkspaceIdentityPath("C:\\Projects\\CodeNomad\\", "win32"), "c:\\projects\\codenomad\\") + assert.equal(normalizeWorkspaceIdentityPath(String.raw`\\Server\Share\Repo`, "win32"), String.raw`\\server\share\repo`) + assert.equal(normalizeWorkspaceIdentityPath("/Projects/CodeNomad/", "linux"), "/Projects/CodeNomad/") + }) + + it("resolves a symlink and its target to the same canonical launch path", async () => { + const { root, target, link } = await createLinkedWorkspace() + const targetResult = await resolveWorkspaceIdentity(target, root) + const linkResult = await resolveWorkspaceIdentity(link, root) + + assert.equal(linkResult.identityKey, targetResult.identityKey) + assert.equal(linkResult.workspacePath, targetResult.workspacePath) + assert.notEqual(linkResult.workspacePath, path.normalize(link)) + }) + + it("falls back to a normalized absolute identity when realpath fails", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "codenomad-workspace-missing-")) + temporaryDirectories.push(root) + const result = await resolveWorkspaceIdentity("missing", root) + const expected = path.resolve(root, "missing") + + assert.equal(result.workspacePath, expected) + assert.equal(result.identityKey, normalizeWorkspaceIdentityPath(expected)) + }) + + it("atomically reuses an active workspace reached through a symlink", async () => { + const { root, target, link } = await createLinkedWorkspace() + const manager = createManager(root) + const [targetResult, linkResult] = await Promise.all([manager.create(target), manager.create(link)]) + + assert.equal(Number(targetResult.created) + Number(linkResult.created), 1) + assert.equal(targetResult.workspace.id, linkResult.workspace.id) + assert.equal(manager.list().length, 1) + }) + + it("shares an in-flight startup between canonical aliases", async () => { + const { root, target, link } = await createLinkedWorkspace() + const manager = createManager(root) + let launches = 0 + ;(manager as any).runtime.launch = async () => { + launches += 1 + await new Promise((resolve) => setTimeout(resolve, 20)) + return { + pid: 123, + port: 4321, + exitPromise: new Promise(() => {}), + getLastOutput: () => "", + } + } + + const [first, second] = await Promise.all([manager.create(target), manager.create(link)]) + + assert.equal(launches, 1) + assert.equal(Number(first.created) + Number(second.created), 1) + assert.equal(first.workspace.id, second.workspace.id) + assert.equal(first.workspace.status, "ready") + assert.equal(second.workspace.status, "ready") + }) + + it("releases a failed identity reservation so creation can be retried", async () => { + const { root, target, link } = await createLinkedWorkspace() + const manager = createManager(root) + let launches = 0 + ;(manager as any).runtime.launch = async () => { + launches += 1 + await new Promise((resolve) => setTimeout(resolve, 20)) + throw new Error("launch failed") + } + + const failed = await Promise.allSettled([manager.create(target), manager.create(link)]) + assert.deepEqual(failed.map((result) => result.status), ["rejected", "rejected"]) + assert.equal(launches, 1) + + ;(manager as any).runtime.launch = async () => ({ + pid: 456, + port: 5432, + exitPromise: new Promise(() => {}), + getLastOutput: () => "", + }) + const retry = await manager.create(target) + + assert.equal(retry.created, true) + assert.equal(retry.workspace.status, "ready") + }) + + it("allows an explicit second workspace for the same canonical path", async () => { + const { root, target, link } = await createLinkedWorkspace() + const manager = createManager(root) + const [first, second] = await Promise.all([ + manager.create(target, undefined, { forceNew: true }), + manager.create(link, undefined, { forceNew: true }), + ]) + + assert.equal(first.created, true) + assert.equal(second.created, true) + assert.notEqual(first.workspace.id, second.workspace.id) + assert.equal(manager.list().length, 2) + }) + + it("keeps the canonical reservation when a forced duplicate is deleted", async () => { + const { root, target, link } = await createLinkedWorkspace() + const manager = createManager(root) + const normalLaunch = deferred<{ + pid: number + port: number + exitPromise: Promise + getLastOutput: () => string + }>() + const forcedLaunch = deferred<{ + pid: number + port: number + exitPromise: Promise + getLastOutput: () => string + }>() + let launches = 0 + ;(manager as any).runtime.launch = () => { + launches += 1 + return launches === 1 ? normalLaunch.promise : forcedLaunch.promise + } + + const first = manager.create(target) + while (manager.list().length < 1) { + await new Promise((resolve) => setImmediate(resolve)) + } + const forced = manager.create(link, undefined, { forceNew: true }) + const forcedRejected = assert.rejects(forced, /Workspace creation cancelled/) + while (manager.list().length < 2) { + await new Promise((resolve) => setImmediate(resolve)) + } + const forcedWorkspace = manager.list().find((workspace) => workspace.id !== manager.list()[0].id)! + await manager.delete(forcedWorkspace.id) + + const reused = manager.create(link) + normalLaunch.resolve({ pid: 123, port: 4321, exitPromise: new Promise(() => {}), getLastOutput: () => "" }) + forcedLaunch.resolve({ pid: 456, port: 5432, exitPromise: new Promise(() => {}), getLastOutput: () => "" }) + const [firstResult, reusedResult] = await Promise.all([first, reused]) + + await forcedRejected + assert.equal(launches, 2) + assert.equal(firstResult.workspace.id, reusedResult.workspace.id) + assert.equal(reusedResult.created, false) + }) + + it("cancels an in-flight startup when its workspace is deleted", async () => { + const { root, target } = await createLinkedWorkspace() + const manager = createManager(root) + const launch = deferred<{ + pid: number + port: number + exitPromise: Promise + getLastOutput: () => string + }>() + ;(manager as any).runtime.launch = () => launch.promise + const events: any[] = [] + ;(manager as any).options.eventBus.onEvent((event: any) => events.push(event)) + + const creation = manager.create(target) + const creationRejected = assert.rejects(creation, /Workspace creation cancelled/) + while (manager.list().length === 0) { + await new Promise((resolve) => setImmediate(resolve)) + } + const workspaceId = manager.list()[0].id + await manager.delete(workspaceId) + launch.resolve({ pid: 123, port: 4321, exitPromise: new Promise(() => {}), getLastOutput: () => "" }) + + await creationRejected + assert.equal(manager.get(workspaceId), undefined) + assert.equal(events.filter((event) => event.type === "workspace.stopped" && event.workspaceId === workspaceId).length, 1) + }) + + it("waits for and cancels forced creations during shutdown", async () => { + const { root, target } = await createLinkedWorkspace() + const manager = createManager(root) + const launch = deferred<{ + pid: number + port: number + exitPromise: Promise + getLastOutput: () => string + }>() + ;(manager as any).runtime.launch = () => launch.promise + + const creation = manager.create(target, undefined, { forceNew: true }) + const creationRejected = assert.rejects(creation, /Workspace creation cancelled/) + while (manager.list().length === 0) { + await new Promise((resolve) => setImmediate(resolve)) + } + const shutdown = manager.shutdown() + launch.resolve({ pid: 123, port: 4321, exitPromise: new Promise(() => {}), getLastOutput: () => "" }) + + await Promise.all([creationRejected, shutdown]) + assert.equal(manager.list().length, 0) + }) +}) diff --git a/packages/server/src/workspaces/instance-client.ts b/packages/server/src/workspaces/instance-client.ts new file mode 100644 index 00000000..76be830c --- /dev/null +++ b/packages/server/src/workspaces/instance-client.ts @@ -0,0 +1,49 @@ +import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2/client" +import type { WorkspaceManager } from "./manager" + +const INSTANCE_HOST = "127.0.0.1" +const LOOPBACK_TIMEOUT_MS = 10_000 + +/** + * Creates an OpenCode SDK client for direct loopback communication with a + * running workspace instance. + * + * Routes and body shapes come from the auto-generated SDK contract + * (`@opencode-ai/sdk`), eliminating handwritten URL construction that can + * drift between SDK versions. Other server modules that need to call the + * OpenCode instance directly should use this factory rather than building + * `http://127.0.0.1:{port}/...` URLs by hand. + * + * All requests carry a 10-second timeout — loopback calls should be + * near-instant; a hang indicates a stuck instance. + * + * The client is cheap to create (object only, no connection); create one per + * call or cache per instance as needed. Returns `null` when the instance has + * no open port yet. + */ +export function createInstanceClient( + workspaceManager: WorkspaceManager, + instanceId: string, +): OpencodeClient | null { + const port = workspaceManager.getInstancePort(instanceId) + if (!port) return null + + const headers: Record = {} + const authorization = workspaceManager.getInstanceAuthorizationHeader(instanceId) + if (authorization) { + headers.authorization = authorization + } + + const workspace = workspaceManager.get(instanceId) + + return createOpencodeClient({ + baseUrl: `http://${INSTANCE_HOST}:${port}/`, + headers, + fetch: (url, init) => + fetch(url, { + ...(init as RequestInit), + signal: (init as RequestInit)?.signal ?? AbortSignal.timeout(LOOPBACK_TIMEOUT_MS), + }), + ...(workspace?.path ? { directory: workspace.path } : {}), + }) +} diff --git a/packages/server/src/workspaces/manager.ts b/packages/server/src/workspaces/manager.ts index c497db2d..8c7fb233 100644 --- a/packages/server/src/workspaces/manager.ts +++ b/packages/server/src/workspaces/manager.ts @@ -1,5 +1,6 @@ import path from "path" import { spawnSync } from "child_process" +import { randomUUID } from "node:crypto" import { connect } from "net" import { EventBus } from "../events/bus" import type { SettingsService } from "../settings/service" @@ -22,6 +23,7 @@ import { OPENCODE_SERVER_USERNAME_ENV, resolveOpencodeServerAuth, } from "./opencode-auth" +import { resolveWorkspaceIdentity } from "./workspace-identity" const STARTUP_STABILITY_DELAY_MS = 1500 @@ -38,8 +40,19 @@ interface WorkspaceManagerOptions { interface WorkspaceRecord extends WorkspaceDescriptor {} +export interface WorkspaceCreateResult { + workspace: WorkspaceDescriptor + created: boolean +} + export class WorkspaceManager { private readonly workspaces = new Map() + private readonly workspaceIdentities = new Map() + private readonly pendingWorkspaceCreations = new Map>() + private readonly pendingWorkspaceOwners = new Map() + private readonly activeWorkspaceCreations = new Set>() + private readonly cancelledWorkspaceCreations = new Set() + private shuttingDown = false private readonly runtime: WorkspaceRuntime private readonly codeNomadPluginUrl: string private readonly opencodeAuth = new Map() @@ -65,6 +78,12 @@ export class WorkspaceManager { return this.opencodeAuth.get(id)?.authorization } + private findReadyWorkspaceByIdentity(identityKey: string): WorkspaceDescriptor | undefined { + return Array.from(this.workspaces.values()).find((workspace) => { + return workspace.status === "ready" && this.workspaceIdentities.get(workspace.id) === identityKey + }) + } + listFiles(workspaceId: string, relativePath = "."): FileSystemEntry[] { const workspace = this.requireWorkspace(workspaceId) const browser = new FileSystemBrowser({ rootDir: workspace.path }) @@ -114,12 +133,65 @@ export class WorkspaceManager { browser.writeFile(relativePath, contents) } - async create(folder: string, name?: string): Promise { - - const id = `${Date.now().toString(36)}` + async create(folder: string, name?: string, options?: { forceNew?: boolean }): Promise { + const { workspacePath, identityKey } = await resolveWorkspaceIdentity(folder, this.options.rootDir) + if (this.shuttingDown) { + throw new Error("Workspace manager is shutting down") + } + if (options?.forceNew) { + return this.trackWorkspaceCreation(this.createResolvedWorkspace(workspacePath, identityKey, name)) + } + + const existing = this.findReadyWorkspaceByIdentity(identityKey) + if (existing) { + this.options.logger.info({ workspaceId: existing.id, folder: workspacePath }, "Reusing existing workspace") + return { workspace: existing, created: false } + } + + const pending = this.pendingWorkspaceCreations.get(identityKey) + if (pending) { + const result = await pending + return { workspace: result.workspace, created: false } + } + + const creation = this.trackWorkspaceCreation( + this.createResolvedWorkspace(workspacePath, identityKey, name, (workspaceId) => { + this.pendingWorkspaceOwners.set(identityKey, workspaceId) + }), + ) + this.pendingWorkspaceCreations.set(identityKey, creation) + try { + return await creation + } finally { + if (this.pendingWorkspaceCreations.get(identityKey) === creation) { + this.pendingWorkspaceCreations.delete(identityKey) + this.pendingWorkspaceOwners.delete(identityKey) + } + } + } + + private trackWorkspaceCreation(creation: Promise): Promise { + let tracked!: Promise + tracked = (async () => { + try { + return await creation + } finally { + this.activeWorkspaceCreations.delete(tracked) + } + })() + this.activeWorkspaceCreations.add(tracked) + return tracked + } + + private async createResolvedWorkspace( + workspacePath: string, + identityKey: string, + name?: string, + onReserved?: (workspaceId: string) => void, + ): Promise { + const id = randomUUID() const binary = this.options.binaryResolver.resolveDefault() const resolvedBinaryPath = this.resolveBinaryPath(binary.path) - const workspacePath = path.isAbsolute(folder) ? folder : path.resolve(this.options.rootDir, folder) clearWorkspaceSearchCache(workspacePath) this.options.logger.info({ workspaceId: id, folder: workspacePath, binary: resolvedBinaryPath }, "Creating workspace") @@ -141,45 +213,47 @@ export class WorkspaceManager { } this.workspaces.set(id, descriptor) + this.workspaceIdentities.set(id, identityKey) + onReserved?.(id) - this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor }) - - const serverConfig = this.options.settings.getOwner("config", "server") - const envVars = (serverConfig as any)?.environmentVariables - const userEnvironment = envVars && typeof envVars === "object" && !Array.isArray(envVars) ? (envVars as any) : {} - const opencodeConfigContent = buildOpencodeConfigContent( - resolveExistingOpencodeConfigContent(userEnvironment), - this.codeNomadPluginUrl, - ) - const serverBaseUrl = this.options.getServerBaseUrl() - const normalizedServerBaseUrl = serverBaseUrl.replace(/\/+$/, "") - - const { username: opencodeUsername, password: opencodePassword } = resolveOpencodeServerAuth({ - userEnvironment, - processEnv: process.env, - }) - const authorization = buildOpencodeBasicAuthHeader({ username: opencodeUsername, password: opencodePassword }) - if (!authorization) { - throw new Error("Failed to build OpenCode auth header") - } - this.opencodeAuth.set(id, { username: opencodeUsername, password: opencodePassword, authorization }) - - const environment = { - ...userEnvironment, - OPENCODE_CONFIG_CONTENT: opencodeConfigContent, - OPENCODE_EXPERIMENTAL_WORKSPACES: "true", - CODENOMAD_INSTANCE_ID: id, - CODENOMAD_BASE_URL: serverBaseUrl, - ...(this.options.nodeExtraCaCertsPath ? { NODE_EXTRA_CA_CERTS: this.options.nodeExtraCaCertsPath } : {}), - [OPENCODE_SERVER_BASE_URL_ENV]: `${normalizedServerBaseUrl}${proxyPath}`, - [OPENCODE_SERVER_USERNAME_ENV]: opencodeUsername, - [OPENCODE_SERVER_PASSWORD_ENV]: opencodePassword, - } - - const logLevel = (serverConfig as any)?.logLevel - try { + this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor }) + + const serverConfig = this.options.settings.getOwner("config", "server") + const envVars = (serverConfig as any)?.environmentVariables + const userEnvironment = envVars && typeof envVars === "object" && !Array.isArray(envVars) ? (envVars as any) : {} + const opencodeConfigContent = buildOpencodeConfigContent( + resolveExistingOpencodeConfigContent(userEnvironment), + this.codeNomadPluginUrl, + ) + const serverBaseUrl = this.options.getServerBaseUrl() + const normalizedServerBaseUrl = serverBaseUrl.replace(/\/+$/, "") + + const { username: opencodeUsername, password: opencodePassword } = resolveOpencodeServerAuth({ + userEnvironment, + processEnv: process.env, + }) + const authorization = buildOpencodeBasicAuthHeader({ username: opencodeUsername, password: opencodePassword }) + if (!authorization) { + throw new Error("Failed to build OpenCode auth header") + } + this.opencodeAuth.set(id, { username: opencodeUsername, password: opencodePassword, authorization }) + + const environment = { + ...userEnvironment, + OPENCODE_CONFIG_CONTENT: opencodeConfigContent, + OPENCODE_EXPERIMENTAL_WORKSPACES: "true", + CODENOMAD_INSTANCE_ID: id, + CODENOMAD_BASE_URL: serverBaseUrl, + ...(this.options.nodeExtraCaCertsPath ? { NODE_EXTRA_CA_CERTS: this.options.nodeExtraCaCertsPath } : {}), + [OPENCODE_SERVER_BASE_URL_ENV]: `${normalizedServerBaseUrl}${proxyPath}`, + [OPENCODE_SERVER_USERNAME_ENV]: opencodeUsername, + [OPENCODE_SERVER_PASSWORD_ENV]: opencodePassword, + } + + const logLevel = (serverConfig as any)?.logLevel + this.throwIfWorkspaceCreationCancelled(id) const { pid, port, exitPromise, getLastOutput } = await this.runtime.launch({ workspaceId: id, folder: workspacePath, @@ -188,25 +262,43 @@ export class WorkspaceManager { logLevel, onExit: (info) => this.handleProcessExit(info.workspaceId, info), }) + descriptor.pid = pid + descriptor.port = port + this.throwIfWorkspaceCreationCancelled(id) const runtimeVersion = await this.waitForWorkspaceReadiness({ workspaceId: id, port, exitPromise, getLastOutput }) if (runtimeVersion) { descriptor.binaryVersion = runtimeVersion } + this.throwIfWorkspaceCreationCancelled(id) - descriptor.pid = pid - descriptor.port = port descriptor.status = "ready" descriptor.updatedAt = new Date().toISOString() this.options.eventBus.publish({ type: "workspace.started", workspace: descriptor }) this.options.logger.info({ workspaceId: id, port }, "Workspace ready") - return descriptor + return { workspace: descriptor, created: true } } catch (error) { + let stopFailure: unknown + await this.runtime.stop(id).catch((stopError) => { + stopFailure = stopError + this.options.logger.warn({ workspaceId: id, err: stopError }, "Failed to stop workspace after startup error") + }) + const cancelled = this.cancelledWorkspaceCreations.delete(id) || !this.workspaces.has(id) + if (cancelled && !stopFailure) { + throw new Error("Workspace creation cancelled") + } descriptor.status = "error" - descriptor.error = error instanceof Error ? error.message : String(error) + descriptor.error = stopFailure instanceof Error + ? `Workspace startup failed and its process could not be stopped: ${stopFailure.message}` + : error instanceof Error ? error.message : String(error) descriptor.updatedAt = new Date().toISOString() - this.options.eventBus.publish({ type: "workspace.error", workspace: descriptor }) + if (this.workspaces.has(id)) { + this.options.eventBus.publish({ type: "workspace.error", workspace: descriptor }) + } this.options.logger.error({ workspaceId: id, err: error }, "Workspace failed to start") + if (cancelled) { + throw new Error(descriptor.error) + } throw error } } @@ -216,29 +308,54 @@ export class WorkspaceManager { if (!workspace) return undefined this.options.logger.info({ workspaceId: id }, "Stopping workspace") + const wasStarting = workspace.status === "starting" const wasRunning = Boolean(workspace.pid) - if (wasRunning) { - await this.runtime.stop(id).catch((error) => { + if (wasStarting) { + this.cancelledWorkspaceCreations.add(id) + } + if (wasStarting || wasRunning) { + try { + await this.runtime.stop(id) + } catch (error) { this.options.logger.warn({ workspaceId: id, err: error }, "Failed to stop workspace process cleanly") - }) + workspace.status = "error" + workspace.error = error instanceof Error ? error.message : String(error) + workspace.updatedAt = new Date().toISOString() + this.options.eventBus.publish({ type: "workspace.error", workspace }) + throw error + } } + const identityKey = this.workspaceIdentities.get(id) + if (identityKey && this.pendingWorkspaceOwners.get(identityKey) === id) { + this.pendingWorkspaceCreations.delete(identityKey) + this.pendingWorkspaceOwners.delete(identityKey) + } + const stoppedEventPublished = workspace.status === "stopped" this.workspaces.delete(id) + this.workspaceIdentities.delete(id) this.opencodeAuth.delete(id) + if (!wasStarting) { + this.cancelledWorkspaceCreations.delete(id) + } clearWorkspaceSearchCache(workspace.path) - if (!wasRunning) { + if (!stoppedEventPublished) { this.options.eventBus.publish({ type: "workspace.stopped", workspaceId: id }) } return workspace } async shutdown() { + this.shuttingDown = true this.options.logger.info("Shutting down all workspaces") const stopTasks: Array> = [] for (const [id, workspace] of this.workspaces) { - if (!workspace.pid) { + if (workspace.status === "starting") { + this.cancelledWorkspaceCreations.add(id) + } + if (!workspace.pid && workspace.status !== "starting") { this.options.logger.debug({ workspaceId: id }, "Workspace already stopped") continue } @@ -247,15 +364,28 @@ export class WorkspaceManager { stopTasks.push( this.runtime.stop(id).catch((error) => { this.options.logger.error({ workspaceId: id, err: error }, "Failed to stop workspace during shutdown") + throw error }), ) } if (stopTasks.length > 0) { - await Promise.allSettled(stopTasks) + const stopResults = await Promise.allSettled(stopTasks) + const failedStop = stopResults.find((result): result is PromiseRejectedResult => result.status === "rejected") + if (failedStop) { + throw failedStop.reason + } + } + if (this.activeWorkspaceCreations.size > 0) { + await Promise.allSettled(this.activeWorkspaceCreations) } this.workspaces.clear() + this.workspaceIdentities.clear() + this.pendingWorkspaceCreations.clear() + this.pendingWorkspaceOwners.clear() + this.activeWorkspaceCreations.clear() + this.cancelledWorkspaceCreations.clear() this.opencodeAuth.clear() this.options.logger.info("All workspaces cleared") } @@ -268,6 +398,12 @@ export class WorkspaceManager { return workspace } + private throwIfWorkspaceCreationCancelled(id: string): void { + if (this.shuttingDown || this.cancelledWorkspaceCreations.has(id) || !this.workspaces.has(id)) { + throw new Error("Workspace creation cancelled") + } + } + private resolveBinaryPath(identifier: string): string { if (!identifier) { return identifier diff --git a/packages/server/src/workspaces/workspace-identity.ts b/packages/server/src/workspaces/workspace-identity.ts new file mode 100644 index 00000000..80307015 --- /dev/null +++ b/packages/server/src/workspaces/workspace-identity.ts @@ -0,0 +1,32 @@ +import { realpath, stat } from "node:fs/promises" +import path from "node:path" + +export function normalizeWorkspaceIdentityPath(value: string, platform: NodeJS.Platform = process.platform): string { + const pathApi = platform === "win32" ? path.win32 : path.posix + const normalized = pathApi.normalize(value) + return platform === "win32" ? normalized.toLowerCase() : normalized +} + +export async function resolveWorkspaceIdentity( + folder: string, + rootDir: string, +): Promise<{ workspacePath: string; identityKey: string }> { + const submittedPath = path.isAbsolute(folder) ? path.normalize(folder) : path.resolve(rootDir, folder) + + try { + const workspacePath = path.normalize(await realpath(submittedPath)) + const metadata = await stat(workspacePath, { bigint: true }) + return { + workspacePath, + identityKey: metadata.ino > 0n + ? `fs:${metadata.dev.toString()}:${metadata.ino.toString()}` + : normalizeWorkspaceIdentityPath(workspacePath), + } + } catch { + // Preserve the existing launch behavior when the path cannot be resolved yet. + return { + workspacePath: submittedPath, + identityKey: normalizeWorkspaceIdentityPath(submittedPath), + } + } +} diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json index 5f9cd234..e6f63b22 100644 --- a/packages/server/tsconfig.json +++ b/packages/server/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "target": "ES2020", "module": "ESNext", - "moduleResolution": "Node", + "moduleResolution": "Bundler", "strict": true, "esModuleInterop": true, "resolveJsonModule": true, diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 866a896b..b4a5f505 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -31,7 +31,7 @@ import { showFolderSelection, setShowFolderSelection, } from "./stores/ui" -import { useConfig } from "./stores/preferences" +import { recentFolders, useConfig } from "./stores/preferences" import { createInstance, getExistingInstanceForFolder, @@ -42,6 +42,7 @@ import { } from "./stores/instances" import { getSessions, + getSessionRoot, activeSessionId, setActiveParentSession, clearActiveParentSession, @@ -70,7 +71,6 @@ import { selectInstanceTab, selectSidecarTab, } from "./stores/app-tabs" - const log = getLogger("actions") const App: Component = () => { @@ -280,14 +280,15 @@ const App: Component = () => { if (!folderPath) { return } + const selectedBinary = binaryPath || serverSettings().opencodeBinary || "opencode" const projectName = getProjectNameForFolder(folderPath) - recordWorkspaceLaunch(folderPath, selectedBinary) clearLaunchError() if (!options?.forceNew) { const existingInstance = getExistingInstanceForFolder(folderPath) if (existingInstance) { + recordWorkspaceLaunch(existingInstance.folder, selectedBinary, folderPath) setAlreadyOpenFolderChoice({ folderPath, binaryPath: selectedBinary, instanceId: existingInstance.id }) return } @@ -295,13 +296,20 @@ const App: Component = () => { setIsSelectingFolder(true) try { - const instanceId = await createInstance(folderPath, selectedBinary, projectName) - selectInstanceTab(instanceId) + const result = await createInstance(folderPath, selectedBinary, projectName, { forceNew: options?.forceNew }) + if (result.reused) { + recordWorkspaceLaunch(instances().get(result.instanceId)?.folder ?? folderPath, selectedBinary, folderPath) + setAlreadyOpenFolderChoice({ folderPath, binaryPath: selectedBinary, instanceId: result.instanceId }) + return + } + + recordWorkspaceLaunch(instances().get(result.instanceId)?.folder ?? folderPath, selectedBinary, folderPath) + selectInstanceTab(result.instanceId) setShowFolderSelection(false) log.info("Created instance", { - instanceId, - port: instances().get(instanceId)?.port, + instanceId: result.instanceId, + port: instances().get(result.instanceId)?.port, }) } catch (error) { const message = formatLaunchErrorMessage(error, t("app.launchError.fallbackMessage")) @@ -408,12 +416,8 @@ const App: Component = () => { return } - const parentSessionId = session.parentId ?? session.id - const parentSession = sessions.find((s) => s.id === parentSessionId) - - if (!parentSession || parentSession.parentId !== null) { - return - } + const parentSession = getSessionRoot(instanceId, sessionId) + if (!parentSession) return clearActiveParentSession(instanceId) @@ -500,7 +504,7 @@ const App: Component = () => { const tauriBridge = (window as { __TAURI__?: { event?: { listen: (event: string, handler: (event: { payload: unknown }) => void) => Promise<() => void> } } }).__TAURI__ if (tauriBridge?.event) { let unlistenMenu: (() => void) | null = null - + tauriBridge.event.listen("menu:newInstance", () => { handleNewInstanceRequest() }).then((unlisten) => { @@ -542,7 +546,7 @@ const App: Component = () => {

{t("app.launchError.binaryPathLabel")}

{launchErrorPath()}

- +

{t("app.launchError.errorOutputLabel")}

@@ -667,7 +671,7 @@ const App: Component = () => {
- + setSidecarPickerOpen(false)} onOpenSidecar={handleOpenSidecar} /> @@ -694,7 +698,7 @@ const App: Component = () => { - + { - setActiveParentSession(instanceId, parentId) - setActiveSession(instanceId, sessionId) - }) + if (!allInstanceSessions().has(sessionId)) return + setActiveSessionFromList(instanceId, sessionId) } return { diff --git a/packages/ui/src/components/message-block.tsx b/packages/ui/src/components/message-block.tsx index fa705335..a39ee56b 100644 --- a/packages/ui/src/components/message-block.tsx +++ b/packages/ui/src/components/message-block.tsx @@ -8,7 +8,7 @@ import { buildRecordDisplayData, clearRecordDisplayCacheForInstance } from "../s import type { MessageRecord } from "../stores/message-v2/types" import { messageStoreBus } from "../stores/message-v2/bus" import { formatTokenTotal } from "../lib/formatters" -import { sessions, setActiveParentSession, setActiveSession } from "../stores/sessions" +import { ensureSessionAncestorsExpanded, sessions, setActiveSessionFromList } from "../stores/sessions" import { selectInstanceTab } from "../stores/app-tabs" import { showAlertDialog } from "../stores/alerts" import { deleteMessage } from "../stores/session-actions" @@ -33,7 +33,6 @@ function DeleteUpToIcon() { const USER_BORDER_COLOR = "var(--message-user-border)" const ASSISTANT_BORDER_COLOR = "var(--message-assistant-border)" const NO_STEP_BORDER = "none" -const REASONING_SCROLL_SENTINEL_MARGIN_PX = 48 const LazyToolCall = lazy(() => import("./tool-call")) @@ -136,11 +135,8 @@ function findTaskSessionLocation(sessionId: string, preferredInstanceId?: string function navigateToTaskSession(location: TaskSessionLocation) { selectInstanceTab(location.instanceId) - const parentToActivate = location.parentId ?? location.sessionId - setActiveParentSession(location.instanceId, parentToActivate) - if (location.parentId) { - setActiveSession(location.instanceId, location.sessionId) - } + ensureSessionAncestorsExpanded(location.instanceId, location.sessionId) + setActiveSessionFromList(location.instanceId, location.sessionId) } interface CachedBlockEntry { @@ -642,6 +638,7 @@ interface MessageBlockProps { onDeleteHoverChange?: (state: DeleteHoverState) => void selectedMessageIds?: () => Set selectedToolPartKeys?: () => Set + selectedCompanionPartKeys?: () => Set onToggleSelectedMessage?: (messageId: string, selected: boolean) => void onRevert?: (messageId: string) => void onDeleteMessagesUpTo?: (messageId: string) => void | Promise @@ -661,8 +658,7 @@ export default function MessageBlock(props: MessageBlockProps) { const isDeleteMessageHovered = () => { const hover = props.deleteHover?.() ?? ({ kind: "none" } as DeleteHoverState) - const selected = props.selectedMessageIds?.() ?? new Set() - if (selected.has(props.messageId)) { + if (props.selectedMessageIds?.().has(props.messageId)) { return true } @@ -966,6 +962,7 @@ export default function MessageBlock(props: MessageBlockProps) { onDeleteHoverChange={props.onDeleteHoverChange} onDeleteMessagesUpTo={props.onDeleteMessagesUpTo} selectedMessageIds={props.selectedMessageIds} + selectedCompanionPartKeys={props.selectedCompanionPartKeys} onToggleSelectedMessage={props.onToggleSelectedMessage} /> @@ -983,6 +980,7 @@ export default function MessageBlock(props: MessageBlockProps) { onDeleteHoverChange={props.onDeleteHoverChange} onDeleteMessagesUpTo={props.onDeleteMessagesUpTo} selectedMessageIds={props.selectedMessageIds} + selectedCompanionPartKeys={props.selectedCompanionPartKeys} onToggleSelectedMessage={props.onToggleSelectedMessage} onContentRendered={props.onContentRendered} /> @@ -1016,6 +1014,7 @@ export default function MessageBlock(props: MessageBlockProps) { onDeleteHoverChange={props.onDeleteHoverChange} onDeleteMessagesUpTo={props.onDeleteMessagesUpTo} selectedMessageIds={props.selectedMessageIds} + selectedCompanionPartKeys={props.selectedCompanionPartKeys} onToggleSelectedMessage={props.onToggleSelectedMessage} onContentRendered={props.onContentRendered} forceExpanded={activeSearchMatch()?.partId === (item() as ReasoningDisplayItem).partId} @@ -1044,6 +1043,7 @@ interface StepCardProps { onDeleteHoverChange?: (state: DeleteHoverState) => void onDeleteMessagesUpTo?: (messageId: string) => void | Promise selectedMessageIds?: () => Set + selectedCompanionPartKeys?: () => Set onToggleSelectedMessage?: (messageId: string, selected: boolean) => void onContentRendered?: () => void } @@ -1172,6 +1172,14 @@ function StepCard(props: StepCardProps) { const [deletingMessage, setDeletingMessage] = createSignal(false) const [deletingUpTo, setDeletingUpTo] = createSignal(false) const isSelectedForDeletion = () => Boolean(props.messageId && props.selectedMessageIds?.().has(props.messageId)) + const isSelectedCompanion = () => { + const partId = (props.part as { id?: unknown })?.id + return Boolean( + props.messageId && + typeof partId === "string" && + props.selectedCompanionPartKeys?.().has(`${props.messageId}:${partId}`), + ) + } const timestamp = () => { const value = props.messageInfo?.time?.created ?? (props.part as any)?.time?.start ?? Date.now() const date = new Date(value) @@ -1201,7 +1209,7 @@ function StepCard(props: StepCardProps) { } const info = props.messageInfo const part = props.part as any - + // step-finish parts have tokens embedded; also check messageInfo const partTokens = part?.tokens const infoTokens = info && info.role === "assistant" ? info.tokens : undefined @@ -1209,7 +1217,7 @@ function StepCard(props: StepCardProps) { if (!tokens) { return null } - + return { input: tokens.input ?? 0, output: tokens.output ?? 0, @@ -1336,7 +1344,11 @@ function StepCard(props: StepCardProps) { return null } return ( -
+
void onDeleteMessagesUpTo?: (messageId: string) => void | Promise selectedMessageIds?: () => Set + selectedCompanionPartKeys?: () => Set onToggleSelectedMessage?: (messageId: string, selected: boolean) => void onContentRendered?: () => void forceExpanded?: boolean @@ -1419,7 +1432,6 @@ function ReasoningStreamOutput(props: { const followScroll = createFollowScroll({ getScrollTopSnapshot: props.scrollTopSnapshot, setScrollTopSnapshot: props.setScrollTopSnapshot, - sentinelMarginPx: REASONING_SCROLL_SENTINEL_MARGIN_PX, sentinelClassName: "reasoning-scroll-sentinel", }) @@ -1480,6 +1492,13 @@ function ReasoningCard(props: ReasoningCardProps) { const [deletingUpTo, setDeletingUpTo] = createSignal(false) const [scrollTopSnapshot, setScrollTopSnapshot] = createSignal(0) const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId)) + const isSelectedCompanion = () => { + const partId = (props.part as { id?: unknown })?.id + return Boolean( + typeof partId === "string" && + props.selectedCompanionPartKeys?.().has(`${props.messageId}:${partId}`), + ) + } createEffect(() => { setExpanded(Boolean(props.defaultExpanded)) @@ -1699,6 +1718,7 @@ function ReasoningCard(props: ReasoningCardProps) { return (
diff --git a/packages/ui/src/components/message-section.tsx b/packages/ui/src/components/message-section.tsx index 9492a919..bd834c53 100644 --- a/packages/ui/src/components/message-section.tsx +++ b/packages/ui/src/components/message-section.tsx @@ -5,7 +5,7 @@ import BrandedEmptyState from "./branded-empty-state" import MessageBlock from "./message-block" import { getMessageAnchorId } from "./message-anchors" import MessageTimeline, { buildTimelineSegments, type TimelineSegment } from "./message-timeline" -import VirtualFollowList, { type VirtualFollowBottomIntent, type VirtualFollowListApi, type VirtualFollowListState, type VirtualFollowScrollSnapshot } from "./virtual-follow-list" +import VirtualFollowList, { type VirtualExplicitBottomPinIntent, type VirtualFollowListApi, type VirtualFollowListState, type VirtualFollowScrollSnapshot } from "./virtual-follow-list" import { isSnapshotAutoFollowing } from "./virtual-follow-behavior" import { useConfig } from "../stores/preferences" import { getSessionInfo } from "../stores/sessions" @@ -23,8 +23,8 @@ import { getPartCharCount } from "../lib/token-utils" import { buildSessionSearchMatches } from "../lib/session-search" import type { SessionSearchMatch } from "../lib/session-search" import { resolveThinkingExpansionDefault } from "./tool-call/tool-registry" +import { collectToolDeletionCompanionPartIds, executeBulkDeletionPlan } from "./tool-deletion-companions" -const SCROLL_SENTINEL_MARGIN_PX = 8 const MESSAGE_SCROLL_CACHE_SCOPE = "message-stream" const QUOTE_SELECTION_MAX_LENGTH = 2000 const STREAMING_TEXT_HOLD_TOP_THRESHOLD_PX = 8 @@ -49,7 +49,8 @@ export interface MessageSectionProps { onReloadMessages?: () => void isActive?: boolean sessionStreamingActive?: boolean - bottomFollowIntent?: VirtualFollowBottomIntent | null + explicitBottomPinIntent?: VirtualExplicitBottomPinIntent | null + onExplicitBottomPinCancelled?: () => void } export default function MessageSection(props: MessageSectionProps) { @@ -502,6 +503,34 @@ export default function MessageSection(props: MessageSectionProps) { } return set }) + const deleteCompanionParts = createMemo(() => { + sessionRevision() + const selectedByMessage = new Map>() + for (const entry of deleteToolParts()) { + const selected = selectedByMessage.get(entry.messageId) ?? new Set() + selected.add(entry.partId) + selectedByMessage.set(entry.messageId, selected) + } + + const companions: { messageId: string; partId: string }[] = [] + const s = store() + for (const [messageId, selectedToolPartIds] of selectedByMessage) { + const record = s.getMessage(messageId) + if (!record) continue + const partIds = collectToolDeletionCompanionPartIds( + record.partIds ?? [], + (partId) => record.parts?.[partId]?.data, + selectedToolPartIds, + ) + for (const partId of partIds) { + companions.push({ messageId, partId }) + } + } + return companions + }) + const deleteCompanionPartKeys = createMemo(() => + new Set(deleteCompanionParts().map((entry) => `${entry.messageId}:${entry.partId}`)), + ) const isDeleteMode = createMemo(() => deleteMessageIds().size > 0 || deleteToolParts().length > 0) const selectedDeleteCount = createMemo(() => deleteMessageIds().size + deleteToolParts().length) @@ -551,6 +580,10 @@ export default function MessageSection(props: MessageSectionProps) { } total += Math.max(Math.round(chars / 4), 1) } + for (const { messageId, partId } of deleteCompanionParts()) { + const part = s.getMessage(messageId)?.parts?.[partId]?.data + if (part) total += Math.max(Math.round(getPartCharCount(part) / 4), 1) + } } return total }) @@ -628,15 +661,22 @@ export default function MessageSection(props: MessageSectionProps) { } } + const companionParts = deleteCompanionParts() + try { - for (const messageId of toDelete) { - await deleteMessage(props.instanceId, props.sessionId, messageId) - } - for (const { messageId, partId } of toolParts) { - if (!allowed.has(messageId)) continue - await deleteMessagePart(props.instanceId, props.sessionId, messageId, partId) - } - clearDeleteMode() + await executeBulkDeletionPlan( + { + messageIds: toDelete, + companionParts: companionParts.filter(({ messageId }) => allowed.has(messageId)), + toolParts: toolParts.filter(({ messageId }) => allowed.has(messageId)), + }, + { + clearSelection: clearDeleteMode, + deleteMessage: (messageId) => deleteMessage(props.instanceId, props.sessionId, messageId), + deletePart: ({ messageId, partId }) => + deleteMessagePart(props.instanceId, props.sessionId, messageId, partId), + }, + ) } catch (error) { showAlertDialog(t("messageSection.bulkDelete.failedMessage"), { title: t("messageSection.bulkDelete.failedTitle"), @@ -735,16 +775,6 @@ export default function MessageSection(props: MessageSectionProps) { return } - const element = streamElement() - if (!allowCapture || !canCapture) return - if (!element) return - const scrollTop = element.scrollTop - const maxScrollTop = Math.max(element.scrollHeight - element.clientHeight, 0) - const scrollRatio = maxScrollTop > 0 ? scrollTop / maxScrollTop : 0 - const atBottom = element.scrollHeight - (element.scrollTop + element.clientHeight) <= 48 - const snapshot = { scrollTop, scrollRatio, maxScrollTop, atBottom } - setLastGoodScrollSnapshot(sessionId, snapshot) - store().setScrollSnapshot(sessionId, MESSAGE_SCROLL_CACHE_SCOPE, snapshot) } // Persist scroll position when switching sessions. This effect's cleanup runs @@ -783,7 +813,7 @@ export default function MessageSection(props: MessageSectionProps) { setScrollControlsOpen(false) } - function openScrollControlsFromTrigger(event: PointerEvent) { + function openScrollControlsFromTrigger(event: MouseEvent) { event.preventDefault() event.stopPropagation() if (scrollControlsOpen()) return @@ -795,7 +825,7 @@ export default function MessageSection(props: MessageSectionProps) { event.preventDefault() event.stopPropagation() action() - setScrollControlsHoverSuppressed(event.pointerType !== "mouse") + setScrollControlsHoverSuppressed(false) closeScrollControls() } @@ -836,7 +866,7 @@ export default function MessageSection(props: MessageSectionProps) { const api = listApi() if (!api) return if (props.registerScrollToBottom) { - props.registerScrollToBottom(() => api.scrollToBottom({ immediate: true, suppressHold: true })) + props.registerScrollToBottom(() => api.scrollToBottom({ immediate: true })) onCleanup(() => props.registerScrollToBottom?.(null)) } }) @@ -1264,7 +1294,7 @@ export default function MessageSection(props: MessageSectionProps) { if (!match || !isSearchOpen()) return if (match.id === lastScrolledSearchMatchId) return lastScrolledSearchMatchId = match.id - listApi()?.scrollToKey(match.messageId, { behavior: "smooth", block: "start", setAutoScroll: false }) + listApi()?.scrollToKey(match.messageId, { behavior: "smooth", block: "start" }) }) @@ -1367,8 +1397,6 @@ export default function MessageSection(props: MessageSectionProps) { getKey={(messageId) => messageId} getAnchorId={getMessageAnchorId} overscanPx={800} - scrollSentinelMarginPx={SCROLL_SENTINEL_MARGIN_PX} - suspendMeasurements={() => !isActive()} streamingActive={streamingActive} isActive={isActive} scrollToBottomOnActivate={() => false} @@ -1376,7 +1404,8 @@ export default function MessageSection(props: MessageSectionProps) { initialAutoScroll={initialAutoScroll} resetKey={() => props.sessionId} followToken={followToken} - forceBottomFollowIntent={() => props.bottomFollowIntent ?? null} + explicitBottomPinIntent={() => props.explicitBottomPinIntent ?? null} + onExplicitBottomPinCancelled={props.onExplicitBottomPinCancelled} autoPinHoldEnabled={holdLongAssistantRepliesEnabled} autoPinHoldTargetKey={autoPinHoldTargetKey} autoPinHoldTopThresholdPx={STREAMING_TEXT_HOLD_TOP_THRESHOLD_PX} @@ -1429,7 +1458,7 @@ export default function MessageSection(props: MessageSectionProps) {