diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index f15d991e16..0848453e15 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -10,7 +10,8 @@ "version": "22", "pnpmVersion": "10.27.0" }, - "ghcr.io/devcontainers/features/github-cli:1": {} + "ghcr.io/devcontainers/features/github-cli:1": {}, + "ghcr.io/devcontainers/features/docker-in-docker:2": {} }, "customizations": { diff --git a/.github/workflows/studio-linux-ci.yml b/.github/workflows/studio-linux-ci.yml new file mode 100644 index 0000000000..f20aa3b466 --- /dev/null +++ b/.github/workflows/studio-linux-ci.yml @@ -0,0 +1,288 @@ +name: studio-linux-ci + +on: + schedule: + - cron: '0 3 * * *' # Nightly 03:00 UTC + workflow_dispatch: + inputs: + studio_version: + description: 'Override Studio version hash (leave empty for latest)' + required: false + push: + branches: [main] + paths: + - 'tools/studio-bridge/docker/**' + - 'tools/studio-bridge/src/**' + - '.github/workflows/studio-linux-ci.yml' + pull_request: + paths: + - 'tools/studio-bridge/docker/**' + - 'tools/studio-bridge/src/**' + - 'tools/nevermore-cli-helpers/src/auth/**' + - '.github/workflows/studio-linux-ci.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +env: + REGISTRY: ghcr.io + IMAGE_NAME: quenty/nevermore-studio-linux + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + outputs: + tag: ${{ steps.tag.outputs.tag }} + steps: + - name: Resolve Studio version + id: resolve + run: | + if [ -n "${{ inputs.studio_version }}" ]; then + VERSION="${{ inputs.studio_version }}" + else + VERSION=$(curl -s https://clientsettingscdn.roblox.com/v2/client-version/WindowsStudio64 | jq -r .clientVersionUpload) + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "short=${VERSION:0:12}" >> "$GITHUB_OUTPUT" + echo "Resolved Studio version: $VERSION" + + # Canary builds for PRs and non-main branches + BRANCH="${{ github.head_ref || github.ref_name }}" + if [ "$BRANCH" != "main" ]; then + BRANCH_SLUG="${BRANCH//\//-}" + SHORT_SHA="${GITHUB_SHA:0:8}" + echo "is_canary=true" >> "$GITHUB_OUTPUT" + echo "canary_tag=canary-${BRANCH_SLUG}-${SHORT_SHA}" >> "$GITHUB_OUTPUT" + echo "branch_tag=canary-${BRANCH_SLUG}" >> "$GITHUB_OUTPUT" + echo "Canary build: canary-${BRANCH_SLUG}-${SHORT_SHA}" + else + echo "is_canary=false" >> "$GITHUB_OUTPUT" + echo "canary_tag=" >> "$GITHUB_OUTPUT" + fi + + - name: Compute image tag for downstream jobs + id: tag + run: | + if [ "${{ steps.resolve.outputs.is_canary }}" = "true" ]; then + echo "tag=${{ steps.resolve.outputs.branch_tag }}" >> "$GITHUB_OUTPUT" + else + echo "tag=latest" >> "$GITHUB_OUTPUT" + fi + + - name: Check if image already exists + id: check + run: | + # Always rebuild canary images + if [ "${{ steps.resolve.outputs.is_canary }}" = "true" ]; then + echo "exists=false" >> "$GITHUB_OUTPUT" + echo "Canary build — always rebuild" + exit 0 + fi + + if docker manifest inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.resolve.outputs.version }} > /dev/null 2>&1; then + echo "exists=true" >> "$GITHUB_OUTPUT" + echo "Image already exists for version ${{ steps.resolve.outputs.version }}, skipping build" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + echo "Image not found, will build" + fi + env: + DOCKER_CLI_EXPERIMENTAL: enabled + + - name: Checkout repository + if: steps.check.outputs.exists != 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'push' + uses: actions/checkout@v6 + + - name: Set up Docker Buildx + if: steps.check.outputs.exists != 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'push' + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + if: steps.check.outputs.exists != 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'push' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Compute image tags + if: steps.check.outputs.exists != 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'push' + id: tags + run: | + if [ "${{ steps.resolve.outputs.is_canary }}" = "true" ]; then + # Tag with both the SHA-specific and stable branch tags + TAGS="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.resolve.outputs.canary_tag }} + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.resolve.outputs.branch_tag }}" + else + TAGS="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.resolve.outputs.version }} + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.resolve.outputs.short }}" + fi + echo "tags<> "$GITHUB_OUTPUT" + echo "$TAGS" >> "$GITHUB_OUTPUT" + echo "ENDOFTAGS" >> "$GITHUB_OUTPUT" + + - name: Build and push image + if: steps.check.outputs.exists != 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'push' + uses: docker/build-push-action@v6 + with: + context: tools/studio-bridge/docker + build-contexts: workspace=. + build-args: | + STUDIO_VERSION=${{ steps.resolve.outputs.version }} + push: true + tags: ${{ steps.tags.outputs.tags }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Clean up old images + if: steps.check.outputs.exists != 'true' && steps.resolve.outputs.is_canary != 'true' + continue-on-error: true + uses: snok/container-retention-policy@v3.0.0 + with: + account: quenty + token: ${{ secrets.GITHUB_TOKEN }} + image-names: nevermore-studio-linux + cut-off: 30d + keep-n-most-recent: 5 + + e2e: + needs: build + if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + timeout-minutes: 30 + container: + image: ghcr.io/quenty/nevermore-studio-linux:${{ needs.build.outputs.tag }} + credentials: + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + options: --user studio --dns 8.8.8.8 --dns 8.8.4.4 --cap-add NET_RAW + env: + ROBLOSECURITY: ${{ secrets.ROBLOSECURITY }} + steps: + - name: Start display server and Wine networking + run: | + # GitHub Actions overrides the container ENTRYPOINT, so we must + # start Xvfb + openbox and refresh Wine networking manually. + echo "Wine prefix before Xvfb:" + ls -la $WINEPREFIX/system.reg $WINEPREFIX/user.reg 2>/dev/null || echo " No prefix files at $WINEPREFIX" + ls -la /home/studio/.wine/system.reg 2>/dev/null || echo " No prefix at /home/studio/.wine" + + Xvfb "${DISPLAY:-:99}" -screen 0 1024x768x24 & + sleep 0.5 + DISPLAY="${DISPLAY:-:99}" openbox & + sleep 0.5 + # Re-detect network interfaces so Wine sees the runtime network + wineboot -u > /dev/null 2>&1 || true + + echo "Wine ipconfig after wineboot -u:" + wine ipconfig /all 2>/dev/null || echo " ipconfig failed" + + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Install aftman tools + run: aftman install --no-trust-check + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup registries + run: | + echo -e "\n//npm.pkg.github.com/:_authToken=$GITHUB_TOKEN" >> .npmrc + echo -e "\n//npm.pkg.github.com/:_authToken=$GITHUB_TOKEN" >> ~/.npmrc + echo -e "\n//registry.npmjs.org/:_authToken=$NPM_TOKEN" >> .npmrc + echo -e "\n//registry.npmjs.org/:_authToken=$NPM_TOKEN" >> ~/.npmrc + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build all tools + run: pnpm -r --filter './tools/**' run build + + - name: Install studio-bridge CLI + run: npm install --ignore-scripts --force -g . + working-directory: tools/studio-bridge + + - name: Verify environment health (pre-auth) + run: studio-bridge linux status + + - name: Diagnose Wine networking + if: always() + run: | + echo "=== /etc/resolv.conf ===" + cat /etc/resolv.conf + echo "" + echo "=== Host DNS test (curl) ===" + curl -sI https://clientsettingscdn.roblox.com/ 2>&1 | head -5 || echo "curl failed" + echo "" + echo "=== /sys/class/net ===" + ls -la /sys/class/net/ 2>/dev/null || echo "/sys/class/net not accessible" + echo "" + echo "=== /proc/net/route ===" + cat /proc/net/route 2>/dev/null || echo "/proc/net/route not accessible" + echo "" + echo "=== /proc/net/if_inet6 ===" + cat /proc/net/if_inet6 2>/dev/null || echo "No IPv6 info" + echo "" + echo "=== Wine ipconfig (before wineboot -u) ===" + wine ipconfig /all 2>/dev/null || echo "wine ipconfig failed" + echo "" + echo "=== Running wineboot -u ===" + WINEDEBUG=+nsi wineboot -u 2>&1 | head -30 || echo "wineboot -u failed" + echo "" + echo "=== Wine ipconfig (after wineboot -u) ===" + wine ipconfig /all 2>/dev/null || echo "wine ipconfig failed" + echo "" + echo "=== Wine prefix files ===" + ls -la $WINEPREFIX/system.reg $WINEPREFIX/user.reg 2>/dev/null || echo "No Wine prefix files" + + - name: Inject authentication + if: ${{ env.ROBLOSECURITY != '' }} + run: studio-bridge linux inject-credentials --verbose + env: + ROBLOSECURITY: ${{ secrets.ROBLOSECURITY }} + + - name: Execute script through Studio bridge + if: ${{ env.ROBLOSECURITY != '' }} + run: studio-bridge process run --verbose --timeout 60000 'print("E2E test passed!")' + timeout-minutes: 5 + env: + ROBLOSECURITY: ${{ secrets.ROBLOSECURITY }} + + - name: Print logs + if: always() + run: | + echo "=== Environment ===" + echo "DISPLAY=$DISPLAY WINEPREFIX=$WINEPREFIX STUDIO_DIR=$STUDIO_DIR HOME=$HOME" + echo "Xvfb running: $(pgrep -x Xvfb > /dev/null && echo yes || echo no)" + echo "openbox running: $(pgrep -x openbox > /dev/null && echo yes || echo no)" + echo "Wine procs: $(pgrep -c wine 2>/dev/null || echo 0)" + echo "" + echo "=== Wine log (last 100 lines) ===" + tail -100 /tmp/studio-bridge-wine.log 2>/dev/null || echo "No Wine log" + echo "" + echo "=== Studio logs ===" + find $WINEPREFIX/drive_c/users/ -name "*.log" -path "*/Roblox/logs/*" 2>/dev/null | head -5 + tail -50 $WINEPREFIX/drive_c/users/*/AppData/Local/Roblox/logs/*.log 2>/dev/null || echo "No Studio logs" + echo "" + echo "=== Wine prefix check ===" + ls -la $WINEPREFIX/system.reg 2>/dev/null || echo "No system.reg at WINEPREFIX=$WINEPREFIX" + ls -la /home/studio/.wine/system.reg 2>/dev/null || echo "No system.reg at /home/studio/.wine" + + - name: Upload logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: studio-bridge-logs + path: | + /tmp/studio-bridge-wine.log + /home/studio/.wine/drive_c/users/*/AppData/Local/Roblox/logs/ + if-no-files-found: ignore diff --git a/docs/testing/testing.md b/docs/testing/testing.md index 96bd941330..ff30216698 100644 --- a/docs/testing/testing.md +++ b/docs/testing/testing.md @@ -204,6 +204,23 @@ When tests fail in CI, the `post-test-results` command parses Jest-lua output an The resolver code lives in `tools/nevermore-cli/src/utils/sourcemap/` and is shared with the `strip-sourcemap-jest` command. +## Linux headless testing + +Studio can run headlessly on Linux via Wine, enabling E2E tests in devcontainers and GitHub Actions without a display or GPU. The `studio-bridge` CLI handles all environment setup: + +```bash +# One-time setup +studio-bridge linux setup --install-deps +studio-bridge linux inject-credentials # reads $ROBLOSECURITY env var + +# Run tests the same as on Windows/macOS +nevermore test +``` + +Prerequisites (Wine 11, Xvfb, openbox, Mesa llvmpipe) are documented in `tools/studio-bridge/src/linux/README.md`. The `linux setup --install-deps` flag installs everything on Debian/Ubuntu but is opt-in — it never runs sudo automatically. + +For CI, set `ROBLOSECURITY` as a repository or Codespace secret. The `.github/workflows/studio-linux-e2e.yml` workflow demonstrates the full flow. + ## CI design principles - **Workflows should be thin.** All logic lives in `nevermore-cli` commands — GitHub Actions workflows just call them. This keeps CI debuggable locally. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ec35ab8785..c572a3d23c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5634,6 +5634,9 @@ importers: '@quenty/cli-output-helpers': specifier: workspace:* version: link:../cli-output-helpers + inquirer: + specifier: ^13.2.0 + version: 13.2.5(@types/node@18.19.130) latest-version: specifier: ^9.0.0 version: 9.0.0 @@ -5656,6 +5659,9 @@ importers: typescript-memoize: specifier: ^1.1.1 version: 1.1.1 + vitest: + specifier: ^3.0.0 + version: 3.2.4(@types/node@18.19.130)(yaml@2.8.2) tools/nevermore-template-helpers: dependencies: @@ -11117,7 +11123,7 @@ snapshots: lodash.get: 4.4.2 make-error: 1.3.6 ts-node: 9.1.1(typescript@5.9.3) - tslib: 2.1.0 + tslib: 2.8.1 transitivePeerDependencies: - typescript diff --git a/studio-bridge/plans/execution/README.md b/studio-bridge/plans/execution/README.md deleted file mode 100644 index 8f337f93a4..0000000000 --- a/studio-bridge/plans/execution/README.md +++ /dev/null @@ -1,338 +0,0 @@ -# Execution Plan: Studio-Bridge Persistent Sessions - -This directory contains the execution plan for building persistent sessions into studio-bridge. The plan is split into per-phase files covering tasks, dependencies, acceptance criteria, testing strategy, risk mitigation, and sub-agent assignment. Each phase maps to one or more tech specs and is scoped tightly enough to be handed to a developer or AI agent with clear acceptance criteria. - -References: -- PRD: `studio-bridge/plans/prd/main.md` -- Tech spec overview: `studio-bridge/plans/tech-specs/00-overview.md` -- Protocol: `studio-bridge/plans/tech-specs/01-protocol.md` -- Command system: `studio-bridge/plans/tech-specs/02-command-system.md` -- Persistent plugin: `studio-bridge/plans/tech-specs/03-persistent-plugin.md` -- Action specs: `studio-bridge/plans/tech-specs/04-action-specs.md` -- Split server mode: `studio-bridge/plans/tech-specs/05-split-server.md` -- MCP server: `studio-bridge/plans/tech-specs/06-mcp-server.md` -- Bridge Network layer: `studio-bridge/plans/tech-specs/07-bridge-network.md` -- Host failover: `studio-bridge/plans/tech-specs/08-host-failover.md` -- Output modes: `studio-bridge/plans/execution/output-modes-plan.md` - -Base path for all file references: `/workspaces/NevermoreEngine/tools/studio-bridge/` - ---- - -## Reading Order - -1. **Start with `phases/`** to understand what gets built in each phase -- tasks, dependencies, acceptance criteria, and testing strategy. -2. **Use `agent-prompts/`** when assigning tasks to sub-agents. Each file contains self-contained prompts for automatable tasks and handoff notes for tasks requiring orchestrator coordination or review. -3. **Use `validation/`** to understand acceptance criteria and test plans -- unit tests, integration tests, e2e tests, phase gates, regression tests, performance tests, and security tests. -4. **`output-modes-plan.md`** is a standalone detailed design for Phase 0 (output mode utilities). It lives at the top level because it modifies `tools/cli-output-helpers/`, not `tools/studio-bridge/`. - ---- - -## Phase-to-Tech-Spec Mapping - -| Phase | Execution file | Primary tech specs | -|-------|---------------|-------------------| -| 0 | `phases/00-prerequisites.md` | (no tech spec -- see `output-modes-plan.md`) | -| 0.5 | `phases/00.5-plugin-modules.md` | `01-protocol.md`, `03-persistent-plugin.md`, `04-action-specs.md` | -| 1 | `phases/01-bridge-network.md` | `07-bridge-network.md`, `01-protocol.md`, `02-command-system.md` | -| 1b | `phases/01b-failover.md` | `08-host-failover.md` | -| 2 | `phases/02-plugin.md` | `03-persistent-plugin.md`, `04-action-specs.md` | -| 3 | `phases/03-commands.md` | `04-action-specs.md`, `02-command-system.md` | -| 4 | `phases/04-split-server.md` | `05-split-server.md` | -| 5 | `phases/05-mcp-server.md` | `06-mcp-server.md` | -| 6 | `phases/06-integration.md` | `00-overview.md` (architecture) | - ---- - -## Phase Dependencies - -The longest dependency chain determines the minimum number of sequential steps to reach a fully functional system: - -``` -Phase 0.5 (plugin modules) --+ - +--> 2.1 (Layer 2 glue) -1.1 (protocol v2) -----------+ | - -> 1.5 (v2 handshake) +--> 2.2 (execute action) - -> 1.6 (action dispatch) +--> 2.5 (detection + fallback) - +--> 3.1-3.4 (new actions) <- needs 1.7a + 1.7b + 2.1 - +--> 3.5 (wire terminal adapter) - +--> 5.1 (MCP scaffold) - +--> 5.2 (MCP tools) - +--> 6.2 (e2e tests) -``` - -Key dependency rules: - -- **Phase 0 and Phase 0.5 are independent** of each other and of Phase 1. Both can run in parallel with everything. Phase 0 modifies `tools/cli-output-helpers/`. Phase 0.5 creates pure Luau modules testable via Lune. -- **Phase 1 core (Tasks 1.1-1.7b)** is independent of Phase 0 (except Task 1.7a needs Phase 0 for output mode utilities). Phase 1 core does NOT include failover -- basic `SessionDisconnectedError` handling is in Task 1.3b. -- **Task 1.3d has been split into 5 subtasks (1.3d1-1.3d5).** Subtasks 1.3d1-1.3d4 are agent-assignable and run in sequence (each builds on the previous). Subtask 1.3d5 (barrel export and API surface review) is a review checkpoint (~30 minutes) that a review agent or the orchestrator can verify against the tech spec checklist. The orchestrator should dispatch 1.3d1-1.3d4 to agents after Wave 2 completes, then dispatch 1.3d5 to a review agent. Do NOT dispatch Wave 3.5+ tasks until 1.3d5 is validated and merged. Other Wave 3 tasks that do not depend on 1.3d (0.5.4, 1.6, 2.1) may continue in parallel. -- **Phase 1b (failover: Tasks 1.8-1.10)** depends only on Task 1.3a. It runs in parallel with Phases 2-3 and is NOT a gate for them. -- **Phase 2 depends on Phase 0.5** (Layer 1 plugin modules) **+ Phase 1 core**. Task 2.1 needs Phase 0.5 for the pure Luau modules and Task 1.1 for message format. Task 2.6 needs Tasks 1.3, 1.4, and 1.7a. -- **Phase 3 depends on Tasks 1.7a + 1.7b + 2.1.** Task 1.7b (reference `sessions` command) establishes the pattern that Phase 3 commands follow. -- **Phase 4 depends only on Phase 1 core** (bridge module). It can proceed in parallel with Phases 2-3. **Tasks 4.2, 4.3, and 6.5 must be sequential** (4.2 -> 4.3 -> 6.5) because all three modify `bridge-connection.ts`. Do NOT run them in parallel. -- **Phase 5 depends on Phase 3** (all command handlers must exist before the MCP adapter can wrap them). Phase 5 also extracts reusable MCP utilities (Task 1.7c) from the sessions command pattern. -- **Phase 6 (Studio E2E, human)** depends on all prior phases. Manual Studio verification is consolidated here. Task 6.5 (CI integration) has an additional dependency on Task 4.3 due to the `bridge-connection.ts` sequential chain. - -**Tasks that block the most downstream work**: -1. **Task 1.1 (protocol v2)** -- blocks everything in Phases 2, 3, and 5. -2. **Task 1.3 (bridge module)** -- blocks Task 1.4 (StudioBridge wrapper), Tasks 1.7a-1.7b (CLI utilities + reference command), all of Phase 4 (split server), Task 2.3 (health endpoint), and Task 2.6 (exec/run refactor). This is the largest foundation task. **Task 1.3d has been split into 5 subtasks**: 1.3d1-1.3d4 are agent-assignable; 1.3d5 (barrel export review, ~30 min) is a review checkpoint verifiable by a review agent. -3. **Tasks 1.7a + 1.7b (shared CLI utilities + reference command)** -- blocks all command implementations in Phases 2-3 (2.6, 3.1-3.4) and the MCP adapter (5.2). -4. **Task 1.6 (action dispatch)** -- blocks all action implementations in Phase 3. -5. **Phase 0.5 (plugin modules)** -- blocks Task 2.1 (Layer 2 glue). However, Phase 0.5 has no upstream dependencies so it can start immediately. -6. **Task 2.1 (plugin Layer 2 glue)** -- blocks all plugin-side action handlers. - -Tasks 1.1, 1.3, 0.1-0.4, and 0.5.1-0.5.3 should be prioritized above all others and can all proceed in parallel. - -For the full critical path analysis, risk mitigation strategies, and sub-agent assignment recommendations, see `phases/06-integration.md`. - ---- - -## Cross-Task File Modification Index - -This table maps each source file to the tasks that create or modify it. Use it to identify merge conflict risks (files modified by multiple tasks), scheduling constraints (tasks that share files must be sequenced), and to quickly find which task is responsible for a given file. - -All paths are relative to `/workspaces/NevermoreEngine/tools/studio-bridge/` unless otherwise noted. Paths prefixed with `(plugin)` are relative to `templates/studio-bridge-plugin/`. Paths prefixed with `(cli-helpers)` are relative to `tools/cli-output-helpers/`. - -### Phase 0 -- Output mode utilities - -| Source File | Created By | Modified By | -|-------------|-----------|-------------| -| `(cli-helpers) src/output-modes/table-formatter.ts` | 0.1 | | -| `(cli-helpers) src/output-modes/table-formatter.test.ts` | 0.1 | | -| `(cli-helpers) src/output-modes/json-formatter.ts` | 0.2 | | -| `(cli-helpers) src/output-modes/json-formatter.test.ts` | 0.2 | | -| `(cli-helpers) src/output-modes/watch-renderer.ts` | 0.3 | | -| `(cli-helpers) src/output-modes/watch-renderer.test.ts` | 0.3 | | -| `(cli-helpers) src/output-modes/output-mode.ts` | 0.4 | | -| `(cli-helpers) src/output-modes/output-mode.test.ts` | 0.4 | | -| `(cli-helpers) src/output-modes/index.ts` | 0.4 | | - -### Phase 0.5 -- Lune-testable plugin modules - -| Source File | Created By | Modified By | -|-------------|-----------|-------------| -| `(plugin) test/roblox-mocks.luau` | 0.5.1 | | -| `(plugin) test/test-runner.luau` | 0.5.1 | | -| `(plugin) src/Shared/Protocol.luau` | 0.5.1 | | -| `(plugin) test/protocol.test.luau` | 0.5.1 | | -| `(plugin) src/Shared/DiscoveryStateMachine.luau` | 0.5.2 | | -| `(plugin) test/discovery.test.luau` | 0.5.2 | | -| `(plugin) src/Shared/ActionRouter.luau` | 0.5.3 | | -| `(plugin) src/Shared/MessageBuffer.luau` | 0.5.3 | | -| `(plugin) test/actions.test.luau` | 0.5.3 | | -| `(plugin) test/integration/lune-bridge.test.luau` | 0.5.4 | | - -### Phase 1 -- Foundation (bridge networking, protocol, CLI utilities) - -| Source File | Created By | Modified By | -|-------------|-----------|-------------| -| `src/server/web-socket-protocol.ts` | -- | 1.1 | -| `src/server/pending-request-map.ts` | 1.2 | | -| `src/server/pending-request-map.test.ts` | 1.2 | | -| `src/bridge/internal/transport-server.ts` | 1.3a | | -| `src/bridge/internal/bridge-host.ts` | 1.3a | 1.8, 1.10 | -| `src/bridge/internal/health-endpoint.ts` | 1.3a | 1.10 | -| `src/bridge/internal/bridge-host.test.ts` | 1.3a | | -| `src/bridge/internal/transport-server.test.ts` | 1.3a | | -| `src/bridge/internal/session-tracker.ts` | 1.3b | | -| `src/bridge/bridge-session.ts` | 1.3b | 1.8 | -| `src/bridge/types.ts` | 1.3b | | -| `src/bridge/internal/session-tracker.test.ts` | 1.3b | | -| `src/bridge/bridge-session.test.ts` | 1.3b | | -| `src/bridge/internal/bridge-client.ts` | 1.3c | 1.8, 1.10 | -| `src/bridge/internal/host-protocol.ts` | 1.3c | | -| `src/bridge/internal/transport-client.ts` | 1.3c | | -| `src/bridge/internal/bridge-client.test.ts` | 1.3c | | -| `src/bridge/internal/transport-client.test.ts` | 1.3c | | -| `src/bridge/bridge-connection.ts` | 1.3d1 | 1.3d2, 1.3d3, 1.3d4, 1.10, 2.5, 4.2, 4.3, 6.5 | -| `src/bridge/internal/environment-detection.ts` | 1.3d1 | 4.3 | -| `src/bridge/bridge-connection.test.ts` | 1.3d1 | | -| `src/bridge/internal/environment-detection.test.ts` | 1.3d1 | | -| `src/bridge/index.ts` | 1.3d5 | | -| `src/index.ts` | -- | 1.4, 6.4 | -| `src/server/studio-bridge-server.ts` | -- | 1.5, 1.6 | -| `src/server/action-dispatcher.ts` | 1.6 | | -| `src/cli/resolve-session.ts` | 1.7a | | -| `src/cli/format-output.ts` | 1.7a | | -| `src/cli/types.ts` | 1.7a | | -| `src/cli/resolve-session.test.ts` | 1.7a | | -| `src/commands/sessions.ts` | 1.7b | 1.10 | -| `src/commands/index.ts` | 1.7b | 2.4, 2.6, 3.1, 3.2, 3.3, 3.4, 4.1, 5.1 | -| `src/cli/commands/sessions-command.ts` | 1.7b | | -| `src/cli/cli.ts` | -- | 1.7b, 2.6 | - -### Phase 1b -- Failover - -| Source File | Created By | Modified By | -|-------------|-----------|-------------| -| `src/bridge/internal/hand-off.ts` | 1.8 | 1.10 | -| `src/bridge/internal/hand-off.test.ts` | 1.8 | | -| `src/bridge/internal/__tests__/failover-graceful.test.ts` | 1.9 | | -| `src/bridge/internal/__tests__/failover-crash.test.ts` | 1.9 | | -| `src/bridge/internal/__tests__/failover-plugin-reconnect.test.ts` | 1.9 | | -| `src/bridge/internal/__tests__/failover-inflight.test.ts` | 1.9 | | -| `src/bridge/internal/__tests__/failover-timing.test.ts` | 1.9 | | - -### Phase 2 -- Persistent plugin - -| Source File | Created By | Modified By | -|-------------|-----------|-------------| -| `(plugin) src/StudioBridgePlugin.server.lua` | -- | 2.1, 3.3 | -| `(plugin) src/Actions/` (directory) | 2.1 | | -| `(plugin) default.project.json` | -- | 2.1 | -| `(plugin) src/Actions/ExecuteAction.lua` | 2.1 | 2.2 | -| `src/plugins/plugin-manager.ts` | 2.4 | | -| `src/plugins/plugin-template.ts` | 2.4 | | -| `src/plugins/plugin-discovery.ts` | 2.4 | | -| `src/plugins/types.ts` | 2.4 | | -| `src/plugins/index.ts` | 2.4 | | -| `src/commands/install-plugin.ts` | 2.4 | | -| `src/commands/uninstall-plugin.ts` | 2.4 | | -| `src/plugin/plugin-injector.ts` | -- | 2.4, 2.5 | -| `src/commands/exec.ts` | 2.6 | | -| `src/commands/run.ts` | 2.6 | | -| `src/commands/launch.ts` | 2.6 | | -| `src/cli/args/global-args.ts` | -- | 2.6, 4.2 | -| `src/cli/commands/exec-command.ts` | -- | 2.6 | -| `src/cli/commands/run-command.ts` | -- | 2.6 | -| `src/cli/commands/terminal/terminal-mode.ts` | -- | 2.6, 3.5 | - -### Phase 3 -- New action commands - -| Source File | Created By | Modified By | -|-------------|-----------|-------------| -| `(plugin) src/Actions/StateAction.lua` | 3.1 | | -| `src/server/actions/query-state.ts` | 3.1 | | -| `src/commands/state.ts` | 3.1 | | -| `(plugin) src/Actions/ScreenshotAction.lua` | 3.2 | | -| `src/server/actions/capture-screenshot.ts` | 3.2 | | -| `src/commands/screenshot.ts` | 3.2 | | -| `(plugin) src/Actions/LogAction.lua` | 3.3 | | -| `src/server/actions/query-logs.ts` | 3.3 | | -| `src/commands/logs.ts` | 3.3 | | -| `(plugin) src/Actions/DataModelAction.lua` | 3.4 | | -| `(plugin) src/ValueSerializer.lua` | 3.4 | | -| `src/server/actions/query-datamodel.ts` | 3.4 | | -| `src/commands/query.ts` | 3.4 | | -| `src/commands/connect.ts` | 3.5 | | -| `src/commands/disconnect.ts` | 3.5 | | -| `src/cli/commands/terminal/terminal-editor.ts` | -- | 3.5 | - -### Phase 4 -- Split server mode - -| Source File | Created By | Modified By | -|-------------|-----------|-------------| -| `src/commands/serve.ts` | 4.1 | | - -Note: Tasks 4.2 and 4.3 modify files listed in Phase 1 (`bridge-connection.ts`, `environment-detection.ts`, `global-args.ts`). See the Phase 1 and Phase 2 tables for those entries. - -### Phase 5 -- MCP integration - -| Source File | Created By | Modified By | -|-------------|-----------|-------------| -| `src/mcp/mcp-server.ts` | 5.1 | 5.3 | -| `src/mcp/index.ts` | 5.1 | | -| `src/commands/mcp.ts` | 5.1 | | -| `package.json` | -- | 5.1 | -| `src/mcp/adapters/mcp-adapter.ts` | 5.2 | | - -### Phase 6 -- Polish and integration - -| Source File | Created By | Modified By | -|-------------|-----------|-------------| -| `src/server/studio-bridge-server.test.ts` | -- | 6.1 | -| `src/server/web-socket-protocol.test.ts` | -- | 6.1 | -| `src/test/e2e/persistent-session.test.ts` | 6.2 | | -| `src/test/e2e/split-server.test.ts` | 6.2 | | -| `src/test/e2e/hand-off.test.ts` | 6.2 | | -| `src/test/helpers/mock-plugin-client.ts` | 6.2 | | - -Note: Tasks 6.4 and 6.5 modify files listed in Phase 1 (`index.ts`, `bridge-connection.ts`). See the Phase 1 table for those entries. - -### High-contention files (modified by 3+ tasks) - -These files are the most likely merge conflict sources and require careful sequencing: - -| Source File | All Tasks | Scheduling Constraint | -|-------------|-----------|----------------------| -| `src/bridge/bridge-connection.ts` | Created: 1.3d1. Modified: 1.3d2, 1.3d3, 1.3d4, 1.10, 2.5, 4.2, 4.3, 6.5 | 1.3d1-1.3d4 are sequential. 4.2 -> 4.3 -> 6.5 are sequential. Other modifications must be sequenced by the orchestrator. | -| `src/commands/index.ts` | Created: 1.7b. Modified: 2.4, 2.6, 3.1, 3.2, 3.3, 3.4, 4.1, 5.1 | Append-only barrel file -- designed for auto-mergeable parallel edits. | -| `src/server/studio-bridge-server.ts` | Modified: 1.5, 1.6 | 1.5 must complete before 1.6 (dependency). | -| `src/cli/cli.ts` | Modified: 1.7b, 2.6 | 1.7b must complete before 2.6 (dependency). | -| `src/bridge/internal/bridge-host.ts` | Created: 1.3a. Modified: 1.8, 1.10 | 1.8 depends on 1.3a. 1.10 depends on 1.8. | -| `(plugin) src/StudioBridgePlugin.server.lua` | Modified: 2.1, 3.3 | 2.1 must complete before 3.3 (dependency via 2.2 -> 2.5). | - ---- - -## How to Use Agent Prompts - -Each file in `agent-prompts/` contains self-contained prompts that can be copy-pasted directly to a sub-agent (AI or human). The prompts follow these conventions: - -- **One prompt per task** -- each prompt is scoped to a single task from the execution plan. -- **Context block** -- every prompt starts with the relevant tech spec references and file paths so the agent has full context without needing the rest of the plan. -- **Acceptance criteria** -- every prompt ends with the acceptance criteria from the execution plan so the agent knows exactly what "done" looks like. -- **Handoff notes** -- for tasks that require orchestrator coordination, a review agent, or Roblox Studio validation, the file includes brief handoff notes instead of full prompts. These describe what needs to happen and any real constraints (e.g., Studio runtime testing). - -To assign a task: -1. Open the agent-prompts file for the relevant phase (e.g., `agent-prompts/01-bridge-network.md`). -2. Copy the prompt for the specific task you want to assign. -3. Verify the task's dependencies are complete (check the phase file for the dependency graph). -4. Paste the prompt to the sub-agent along with any additional context about the current state of the codebase. - ---- - -## Execution Model: 2-Agent Split with Sync Points - -This plan is designed for exactly 2 concurrent agents. Do NOT run more than 2 sub-agents -- merge overhead and conflict risk exceed the parallelism gain above 2 agents. File ownership boundaries between the two agents eliminate merge conflicts entirely. - -**Agent A** (TypeScript infrastructure) owns `src/bridge/`, `src/server/`, `src/mcp/`, and `tools/cli-output-helpers/`. Agent A builds the protocol types, bridge networking layer, transport, health endpoint, split server mode, and MCP integration. - -**Agent B** (Luau plugin + CLI commands) owns `templates/`, `src/commands/`, `src/cli/`, and `src/plugins/`. Agent B builds the Lune-testable plugin modules, failover, plugin wiring, CLI commands, and integration polish. - -### Sync points - -There are 6 sync points where Agent A's output unblocks Agent B. At each sync point, the orchestrator merges both agents' branches and runs `npm run test` on the merged result before Agent B proceeds. This catches "works in isolation, fails combined" issues early. - -1. **SP-1: After 1.1 (protocol types)** -- B can use types in plugin modules -2. **SP-2: After 1.3a (transport)** -- B can start Lune integration tests and failover -3. **SP-3: After 1.3d5 (BridgeConnection)** -- B can start plugin wiring (Phase 2). Subtasks 1.3d1-1.3d4 are agent-assignable; 1.3d5 (barrel export review, ~30 min) is a review checkpoint verified by a review agent. -4. **SP-4: After 1.7a + 1.7b (shared utils + reference command)** -- B can start action commands (Phase 3) -5. **SP-5: After 2.3 (health endpoint)** -- B can integrate plugin discovery -6. **SP-6: After Phase 4** -- B can add devcontainer-aware behavior to CLI - -For the full sync point table, realistic parallelism per wave, post-merge validation procedures, and failure recovery steps, see `TODO.md` under "Two-agent execution model", "Sync points", and "Post-merge validation". - ---- - -## Cross-References - -### Phase files -- `phases/00-prerequisites.md` -- Phase 0: Output mode utilities -- `phases/00.5-plugin-modules.md` -- Phase 0.5: Lune-testable plugin modules (pure Luau, no Roblox deps) -- `phases/01-bridge-network.md` -- Phase 1: Foundation (bridge networking, protocol, CLI utilities, reference command) -- `phases/01b-failover.md` -- Phase 1b: Failover (decoupled from Phase 1 gate, runs in parallel with Phases 2-3) -- `phases/02-plugin.md` -- Phase 2: Persistent plugin (Layer 2 glue) + installer + exec/run refactor -- `phases/03-commands.md` -- Phase 3: New action commands (state, screenshot, logs, query) -- `phases/04-split-server.md` -- Phase 4: Split server / devcontainer support -- `phases/05-mcp-server.md` -- Phase 5: MCP integration -- `phases/06-integration.md` -- Phase 6: Studio E2E (human), polish, migration + critical path + risk mitigation + sub-agent assignment - -### Agent prompts -- `agent-prompts/00-prerequisites.md` -- `agent-prompts/01-bridge-network.md` -- `agent-prompts/02-plugin.md` -- `agent-prompts/03-commands.md` -- `agent-prompts/04-split-server.md` -- `agent-prompts/05-mcp-server.md` -- `agent-prompts/06-integration.md` - -### Validation -- `validation/01-bridge-network.md` -- `validation/02-plugin.md` -- `validation/03-commands.md` -- `validation/04-split-server.md` -- `validation/05-mcp-server.md` -- `validation/06-integration.md` - -### Standalone -- `output-modes-plan.md` -- Phase 0 detailed design diff --git a/studio-bridge/plans/execution/TODO.md b/studio-bridge/plans/execution/TODO.md deleted file mode 100644 index 68f4b0ae0d..0000000000 --- a/studio-bridge/plans/execution/TODO.md +++ /dev/null @@ -1,872 +0,0 @@ -# Studio Bridge — Execution TODO - -> **Living document.** Update this as tasks are started, completed, or blocked. -> Last updated: 2026-02-23 (de-risking restructure: Phase 0.5 plugin modules, Phase 1b failover, 1.7 split, manual testing deferred to Phase 6) - -## How to Use This Document - -- A coordinator (human or AI agent) uses this to track progress and delegate work -- Check off tasks as they complete: `- [x]` -- Mark blocked tasks with `BLOCKED:` and the reason -- Mark in-progress tasks with `IN PROGRESS:` and the assignee -- When delegating to a sub-agent, reference the agent-prompt file for that phase -- After completing a phase, verify all gate criteria in the corresponding validation file -- Base path for all studio-bridge source files: `/workspaces/NevermoreEngine/tools/studio-bridge/` -- Phase 0 modifies `tools/cli-output-helpers/`, not `tools/studio-bridge/` - ---- - -## Status Overview - -| Phase | Status | Tasks | Completed | Blocked | -|-------|--------|-------|-----------|---------| -| 0: Prerequisites (Output Modes) | Not started | 4 | 0 | 0 | -| 0.5: Plugin Modules | Not started | 4 | 0 | 0 | -| 1: Bridge Network Foundation | Not started | 15 | 0 | 0 | -| 1b: Failover | Not started | 3 | 0 | 0 | -| 2: Persistent Plugin | Not started | 6 | 0 | 0 | -| 3: New Action Commands | Not started | 5 | 0 | 0 | -| 4: Split Server Mode | Not started | 3 | 0 | 0 | -| 5: MCP Integration | Not started | 3 | 0 | 0 | -| 6: Polish & Integration | Not started | 5 | 0 | 0 | -| **Total** | | **48** | **0** | **0** | - ---- - -## Phase 0: Prerequisites (Output Modes) - -> Plan: `phases/00-prerequisites.md` | Agent prompts: `agent-prompts/00-prerequisites.md` | Validation: see `output-modes-plan.md` -> Modifies: `tools/cli-output-helpers/src/output-modes/` -> Independent of all other phases. Can run in parallel with Phase 1. - -### Parallelization - -``` -0.1 (table) --------+ -0.2 (json) ---------+---> 0.4 (barrel + output mode selector) -0.3 (watch) --------+ -``` - -### Tasks - -- [ ] **0.1** Table formatter (S) - - File: `tools/cli-output-helpers/src/output-modes/table-formatter.ts` - - Test: `tools/cli-output-helpers/src/output-modes/table-formatter.test.ts` - - Dependencies: none - - Agent-assignable: yes - - Acceptance: auto-sized columns, ANSI-safe alignment, empty rows = empty string, right-align support - -- [ ] **0.2** JSON formatter (XS) - - File: `tools/cli-output-helpers/src/output-modes/json-formatter.ts` - - Test: `tools/cli-output-helpers/src/output-modes/json-formatter.test.ts` - - Dependencies: none - - Agent-assignable: yes - - Acceptance: TTY pretty-print (2-space), non-TTY compact, explicit override - -- [ ] **0.3** Watch renderer (S) - - File: `tools/cli-output-helpers/src/output-modes/watch-renderer.ts` - - Test: `tools/cli-output-helpers/src/output-modes/watch-renderer.test.ts` - - Dependencies: none - - Agent-assignable: yes - - Acceptance: TTY rewrite mode, non-TTY append mode, start/stop/update lifecycle - -- [ ] **0.4** Output mode selector + barrel export (XS) - - Files: `tools/cli-output-helpers/src/output-modes/output-mode.ts`, `tools/cli-output-helpers/src/output-modes/index.ts` - - Test: `tools/cli-output-helpers/src/output-modes/output-mode.test.ts` - - Dependencies: 0.1, 0.2, 0.3 - - Agent-assignable: yes - - Acceptance: `--json` -> json, TTY -> table, non-TTY -> text; barrel exports all modules - ---- - -## Phase 0.5: Plugin Modules - -> Plan: `phases/02-plugin.md` (Luau module specs) | Validation: `validation/02-plugin.md` -> Modifies: `templates/studio-bridge-plugin/src/Shared/` -> Independent of Phase 1. Can run in parallel with Phase 0 and Phase 1 (Wave 1). -> Cross-language integration test (0.5.4) depends on 1.3a for the TypeScript server side. - -### Parallelization - -``` -0.5.1 (protocol) ------+ - +---> 0.5.4 (Lune integration tests) [also needs 1.3a] -0.5.2 (discovery) -----+ -0.5.3 (action router) -+ -``` - -### Tasks - -- [ ] **0.5.1** Protocol module (S) - - File: `templates/studio-bridge-plugin/src/Shared/Protocol.luau` - - Dependencies: none - - Agent-assignable: yes - - Acceptance: Luau module encoding/decoding v2 protocol messages, round-trip tests for all message types - -- [ ] **0.5.2** Discovery state machine (M) - - File: `templates/studio-bridge-plugin/src/Shared/DiscoveryStateMachine.luau` - - Dependencies: 0.5.1 - - Agent-assignable: yes - - Acceptance: States: searching/connecting/connected/reconnecting, port scanning, health check, backoff with jitter, shutdown message resets to searching with zero delay - -- [ ] **0.5.3** Action router + message buffer (S) - - Files: `templates/studio-bridge-plugin/src/Shared/ActionRouter.luau`, `MessageBuffer.luau` - - Dependencies: 0.5.1 - - Agent-assignable: yes - - Acceptance: Route incoming action requests to registered handlers, buffer outgoing messages during reconnection, flush on reconnect - -- [ ] **0.5.4** Lune integration tests (M) - - Cross-language: Lune client + TypeScript server - - Dependencies: 0.5.1, 0.5.2, 0.5.3, 1.3a - - Agent-assignable: yes - - Acceptance: Lune script drives plugin modules against a real TypeScript WebSocket server, verifies handshake round-trip, action dispatch, reconnection after drop - -### Phase 0.5 Gate - -- [ ] All Lune unit tests pass for Protocol, DiscoveryStateMachine, ActionRouter, MessageBuffer -- [ ] Integration round-trip works (Lune client <-> TypeScript server) - ---- - -## Phase 1: Bridge Network Foundation - -> Plan: `phases/01-bridge-network.md` | Agent prompts: `agent-prompts/01-bridge-network.md` | Validation: `validation/01-bridge-network.md` -> Failover tasks (1.8, 1.9, 1.10) have moved to Phase 1b and run in parallel with Phases 2-3. - -### Parallelization - -``` -Phase 0 (runs in parallel): -0.1-0.3 --> 0.4 (barrel) - | -Phase 0.5 (runs in parallel): -0.5.1 --> 0.5.2, 0.5.3 --> 0.5.4 (also needs 1.3a) - -Phase 1: | -1.1 (protocol v2) ------+ - +---> 1.5 (v2 handshake) --> 1.6 (action dispatch) -1.2 (pending requests) --+ ^ - | -1.3a (transport + host) --+--> 1.3b (sessions) --+ | - | | | - +--> 1.3c (client) -----+ | - | | - 1.3d1 (role detection) -+ | - 1.3d2 (listSessions) ---+ | - 1.3d3 (resolveSession) -+ | - 1.3d4 (waitForSession) -+ | - 1.3d5 (barrel) [REVIEW] -+ | - | | -1.3d5 --> 1.4 (StudioBridge wrapper) | | - --+ | | - +---> 1.7a (shared CLI utils) --> 1.7b (sessions cmd) -0.4 (barrel) --+ | - | -1.2 ----------------------------------------------------->-+ - -Phase 1b (runs in parallel with Phases 2-3): -1.3a --> 1.8 (failover impl) --> 1.9 (failover tests) -1.3d5 + 1.8 --> 1.10 (failover observability) -``` - -### Tasks - -- [ ] **1.1** Protocol v2 type definitions (M) - - File: `src/server/web-socket-protocol.ts` (modify) - - Dependencies: none - - Agent-assignable: yes - - Acceptance: all v2 message types exported, `decodePluginMessage` handles v2 types, new `decodeServerMessage` function, existing tests pass unchanged, round-trip tests for every v2 type - -- [ ] **1.2** Request/response correlation layer (S) - - File: `src/server/pending-request-map.ts` (create) - - Test: `src/server/pending-request-map.test.ts` - - Dependencies: none - - Agent-assignable: yes - - Acceptance: resolve/reject by ID, timeout, cancelAll, unknown ID is no-op - -- [ ] **1.3a** Transport layer and bridge host (M) -- CRITICAL PATH - - Files: `src/bridge/internal/transport-server.ts`, `src/bridge/internal/bridge-host.ts`, `src/bridge/internal/health-endpoint.ts` + tests - - Dependencies: 1.1 - - Agent-assignable: yes - - Acceptance: WebSocket server with `/plugin`, `/client`, `/health` paths; `reuseAddr: true`; port binding with clean `EADDRINUSE` reporting - -- [ ] **1.3b** Session tracker and bridge session (M) - - Files: `src/bridge/internal/session-tracker.ts`, `src/bridge/bridge-session.ts`, `src/bridge/types.ts` + tests - - Dependencies: 1.3a - - Agent-assignable: yes - - Acceptance: session map with `(instanceId, context)` grouping, `SessionInfo`/`InstanceInfo` types, session lifecycle events, `BridgeSession` action dispatch - -- [ ] **1.3c** Bridge client and host protocol (M) - - Files: `src/bridge/internal/bridge-client.ts`, `src/bridge/internal/host-protocol.ts`, `src/bridge/internal/transport-client.ts` + tests - - Dependencies: 1.3a - - Agent-assignable: yes - - Acceptance: WebSocket client on `/client`, `HostEnvelope`/`HostResponse` types, command forwarding through host, automatic reconnection with backoff - -- [ ] **1.3d** BridgeConnection and role detection (M) -- CRITICAL PATH (split into 5 subtasks) - - Files: `src/bridge/bridge-connection.ts`, `src/bridge/internal/environment-detection.ts`, `src/bridge/index.ts` + tests - - Dependencies: 1.3a, 1.3b, 1.3c - - NOTE: Blocks the most downstream work -- 1.4, 1.7a, 1.7b, Phase 1b (1.9, 1.10), all of Phase 4, 2.3, 2.6 - - **Subtasks run in sequence** (each builds on the previous). Only 1.3d5 requires a review checkpoint. - - **ORCHESTRATOR INSTRUCTION**: Subtasks 1.3d1-1.3d4 are agent-assignable. After 1.3d4 completes, dispatch 1.3d5 to a review agent (or have the orchestrator verify the checklist). Do NOT proceed to Wave 3.5 or later until 1.3d5 is validated and merged. Other Wave 3 tasks (0.5.4, 1.6, 2.1) that do not depend on 1.3d5 may continue in parallel. - - - [ ] **1.3d1** `BridgeConnection.connectAsync()` and role detection (M) - - Files: `src/bridge/bridge-connection.ts`, `src/bridge/internal/environment-detection.ts` + tests - - Dependencies: 1.3a, 1.3b, 1.3c - - Agent-assignable: **yes** - - Acceptance: host/client auto-detection on port 38741 (try bind -> host; EADDRINUSE -> client; stale -> retry), `disconnectAsync`, idle exit with 5s grace, `role` and `isConnected` getters - - Test: two concurrent connections on same port -> first is host, second is client - - - [ ] **1.3d2** `BridgeConnection.listSessions()` and `listInstances()` (S) - - Files: `src/bridge/bridge-connection.ts` (modify) - - Dependencies: 1.3d1 - - Agent-assignable: **yes** - - Acceptance: `listSessions` returns connected plugins, `listInstances` groups by instanceId, `getSession` by ID, works in both host and client mode - - Test: connect mock plugin, verify session appears in list - - - [ ] **1.3d3** `BridgeConnection.resolveSession()` (S) - - Files: `src/bridge/bridge-connection.ts` (modify) - - Dependencies: 1.3d2 - - Agent-assignable: **yes** - - Acceptance: instance-aware resolution algorithm from tech-spec 07 section 2.1 (explicit ID, auto-select single instance, context selection, error on multiple) - - Test: 0 sessions -> error; 1 session -> returns it; N sessions -> error with list - - - [ ] **1.3d4** `BridgeConnection.waitForSession()` and events (S) - - Files: `src/bridge/bridge-connection.ts` (modify) - - Dependencies: 1.3d3 - - Agent-assignable: **yes** - - Acceptance: async wait resolves when plugin connects, rejects on timeout, session lifecycle events (session-connected, session-disconnected, instance-connected, instance-disconnected) - - Test: call before plugin connects -> resolves when plugin connects; verify rejects on timeout - - - [ ] **1.3d5** Barrel export and API surface review (XS) -- REVIEW CHECKPOINT - - Files: `src/bridge/index.ts` (create) - - Dependencies: 1.3d4 - - Agent-assignable: **yes** (review agent verifies exports match tech spec) - - Acceptance: barrel export matches tech-spec 07 section 2.1 exactly, nothing from `internal/` re-exported - - NOTE: This is a ~30-minute review task, not a multi-hour integration review - - **Reviewer checklist**: - - [ ] `BridgeConnection` public API matches tech spec `07-bridge-network.md` section 2.1 signature exactly - - [ ] No `any` casts outside constructor boundaries - - [ ] All existing tests still pass (`cd tools/studio-bridge && npm run test`) - - [ ] New integration test covers connect -> execute -> disconnect lifecycle - - [ ] `StudioBridge` wrapper delegates without duplicating logic - -- [ ] **1.4** Integrate BridgeConnection into StudioBridge class (S) - - File: `src/index.ts` (modify) - - Dependencies: 1.3d5 - - Agent-assignable: yes - - Acceptance: `StudioBridge` API unchanged externally, internally delegates to `BridgeConnection`/`BridgeSession`, existing tests pass, new types exported from `index.ts` - -- [ ] **1.5** v2 handshake support in StudioBridgeServer (S) - - File: `src/server/studio-bridge-server.ts` (modify) - - Dependencies: 1.1 - - Agent-assignable: yes - - Acceptance: v1 hello = v1 welcome, v2 hello with capabilities = v2 welcome, register = v2 welcome, heartbeat tracked - -- [ ] **1.6** Action dispatch on the server (M) - - Files: `src/server/action-dispatcher.ts` (create), `src/server/studio-bridge-server.ts` (modify) - - Dependencies: 1.1, 1.2, 1.5 - - Agent-assignable: yes - - Acceptance: `performActionAsync` sends v2 request with `requestId`, resolves on match, rejects on timeout, rejects on plugin error, throws for v1 plugin or missing capability - -- [ ] **1.7a** Shared CLI utilities (S) - - Files: `src/cli/resolve-session.ts`, `format-output.ts`, `types.ts` - - Dependencies: 1.3d5, Phase 0 (0.4) - - Agent-assignable: yes - - Acceptance: `resolveSessionAsync` handles 0/1/N sessions + explicit ID, output mode integration, shared types for command handlers - -- [ ] **1.7b** Reference `sessions` command + barrel export pattern (S) - - Files: `src/commands/sessions.ts`, `src/commands/index.ts` (barrel), `src/cli/cli.ts` (modify to loop over `allCommands`) - - Dependencies: 1.7a - - Agent-assignable: yes - - Acceptance: single handler using shared CLI utils, barrel file (`src/commands/index.ts`) with `allCommands` array, `cli.ts` registers via loop over `allCommands` (never modified per-command again), table output (Session ID, Place, State, Origin, Duration), `--json`, `--watch`, helpful messages for no-host and no-sessions cases - -### Phase 1 Gate - -- [ ] All existing tests pass unchanged (regression) -- [ ] v2 protocol encode/decode round-trips for all message types -- [ ] PendingRequestMap all tests passing -- [ ] BridgeConnection session tracking tests passing -- [ ] `sessions` command lists sessions (1.7b) -- [ ] Gate command: `cd tools/studio-bridge && npm run test` - ---- - -## Phase 1b: Failover - -> Plan: `phases/01-bridge-network.md` | Agent prompts: `agent-prompts/01-bridge-network.md` | Validation: `validation/01-bridge-network.md` -> Runs in parallel with Phases 2-3. No longer blocks downstream work. - -### Tasks - -- [ ] **1.8** Bridge host failover implementation (M) - - Files: `src/bridge/internal/hand-off.ts` (create), `src/bridge/internal/bridge-host.ts`, `src/bridge/internal/bridge-client.ts`, `src/bridge/bridge-session.ts` (modify) + `src/bridge/internal/hand-off.test.ts` - - Dependencies: 1.3a - - Agent-assignable: no (multi-process coordination with timing races, requires careful testing with real sockets) - - Acceptance: graceful shutdown via SIGTERM/SIGINT, crash recovery with jitter 0-500ms, plugin reconnection with backoff, inflight requests reject with `SessionDisconnectedError`, deterministic state machine (connected/taking-over/promoted) - -- [ ] **1.9** Failover integration tests (M) - - Files: `src/bridge/internal/__tests__/failover-graceful.test.ts`, `failover-crash.test.ts`, `failover-plugin-reconnect.test.ts`, `failover-inflight.test.ts`, `failover-timing.test.ts` - - Dependencies: 1.3d5, 1.8 - - Agent-assignable: no (integration tests with multiple concurrent processes, port binding races, timing assertions) - - Acceptance: graceful takeover <2s, hard kill takeover <5s, TIME_WAIT recovery <1s, rapid restart <5s, exactly one host after multi-client takeover, jitter spread >100ms - -- [ ] **1.10** Failover debugging and observability (S) - - Files: `src/bridge/internal/hand-off.ts`, `bridge-host.ts`, `bridge-client.ts`, `bridge-connection.ts`, `src/commands/sessions.ts`, `health-endpoint.ts` (all modify) - - Dependencies: 1.3d5, 1.8 - - Agent-assignable: yes - - Acceptance: structured debug logs for state transitions, `hostUptime`/`lastFailoverAt` in health endpoint, `BridgeConnection.role` updated on promotion, recovery message during failover - -### Phase 1b Gate - -- [ ] Hand-off state machine unit tests passing -- [ ] All failover integration tests passing (1.9) -- [ ] Gate command: `cd tools/studio-bridge && npm run test` - ---- - -## Phase 2: Persistent Plugin - -> Plan: `phases/02-plugin.md` | Agent prompts: `agent-prompts/02-plugin.md` | Validation: `validation/02-plugin.md` -> Depends on Phase 1 (especially Tasks 1.1, 1.3d5, 1.6, 1.7a). Sessions command now in Phase 1 (1.7b). - -### Parallelization - -``` -2.1 (plugin core) --> 2.2 (execute action) --> 2.5 (detection + fallback) - --> 2.4 (plugin manager) --> 2.5 - ^ -2.3 (health endpoint) ----------------------------+ - -2.6 (exec/run refactor) -- needs 1.3d5 + 1.4 + 1.7a -``` - -### Tasks - -- [ ] **2.1** Unified plugin -- upgrade existing template (L) -- REVIEW CHECKPOINT (requires Studio validation) - - Files: `templates/studio-bridge-plugin/src/StudioBridgePlugin.server.lua` (modify), `Discovery.lua`, `Protocol.lua`, `ActionHandler.lua` (create), `default.project.json` (modify) - - Dependencies: 1.1 - - Agent-assignable: no (complex Luau, requires manual testing in Studio) - - Acceptance: boot mode detection, discovery via health check, `register` with all capabilities, fallback to `hello`, heartbeat every 15s, reconnect with backoff, `stateChange` push, ephemeral mode backward-compatible - - **Reviewer checklist**: - - [ ] Plugin enters `connected` state in Studio when bridge host is running - - [ ] Plugin stays in `searching` state when no bridge host is running (no error spam) - - [ ] All Phase 0.5 modules are imported and wired (Protocol, DiscoveryStateMachine, ActionRouter, MessageBuffer) - - [ ] Heartbeat runs independently from script execution - - [ ] Edit plugin survives Play/Stop mode transitions - -- [ ] **2.2** Execute action handler in plugin (S) - - File: `templates/studio-bridge-plugin/src/Actions/ExecuteAction.lua` - - Dependencies: 2.1 - - Agent-assignable: no (Luau in Studio context) - - Acceptance: handles `requestId` correlation, sends `output`+`scriptComplete`, queues concurrent requests, handles `loadstring` failures - -- [ ] **2.3** Health endpoint on bridge host (S) - - File: `src/bridge/bridge-host.ts` (modify) - - Dependencies: 1.3d5 - - Agent-assignable: yes - - Acceptance: `GET /health` returns `{ status, port, protocolVersion, serverVersion, sessions }`, 404 for non-matching paths - -- [ ] **2.4** Universal plugin management module + installer commands (M) - - Files: `src/plugins/plugin-manager.ts`, `plugin-template.ts`, `plugin-discovery.ts`, `types.ts`, `index.ts`, `src/commands/install-plugin.ts`, `src/commands/uninstall-plugin.ts`, `src/commands/index.ts` (add to barrel) - - Dependencies: 2.1, 1.7b (barrel pattern) - - Agent-assignable: yes - - Acceptance: generic `PluginManager` API (works with any `PluginTemplate`), `registerTemplate`, `buildAsync`, `installAsync`, `uninstallAsync`, `isInstalledAsync`, `listInstalledAsync`, hash-based update, generality test with hypothetical second template. Commands registered via `src/commands/index.ts` barrel (NOT by modifying `cli.ts`). - -- [ ] **2.5** Persistent plugin detection and fallback (S) - - Files: `src/bridge/bridge-connection.ts`, `src/plugin/plugin-injector.ts` (modify) - - Dependencies: 2.3, 2.4 - - Agent-assignable: no (integration edge cases need manual verification) - - Acceptance: persistent plugin installed -> wait for discovery; not installed -> fallback to temp injection after grace period; `preferPersistentPlugin` option - -- [ ] **2.6** Refactor exec/run to handler pattern + session selection + launch command (M) - - Files: `src/commands/exec.ts`, `src/commands/run.ts`, `src/commands/launch.ts` (create); `src/commands/index.ts` (add to barrel); `src/cli/args/global-args.ts`, `src/cli/cli.ts` (global options only), `src/cli/commands/exec-command.ts`, `src/cli/commands/run-command.ts`, `src/cli/commands/terminal/terminal-mode.ts` (modify) - - Dependencies: 1.3d5, 1.4, 1.7a, 1.7b (barrel pattern) - - Agent-assignable: no (UX decisions, interactive testing needed) - - Acceptance: single-handler pattern, `--session` flag, auto-select single session, error on multiple without flag, fallback to launch on zero, origin tracking, `launch` command. Commands registered via `src/commands/index.ts` barrel (NOT by adding per-command `.command()` calls to `cli.ts`). - -### Phase 2 Gate - -- [ ] Health endpoint returns correct JSON -- [ ] Full launch flow with mock plugin discovery -- [ ] Plugin fallback to hello on v1 server -- [ ] Plugin reconnection after disconnect -- [ ] `install-plugin` writes to correct path -- [ ] `exec` command session resolution (all three scenarios) -- [ ] `exec` e2e with mock plugin - -> Manual Studio testing deferred to Phase 6 (E2E). See `validation/06-integration.md`. - -**Phase 2 gate reviewer checklist**: -- [ ] `rojo build templates/studio-bridge-plugin/default.project.json -o dist/studio-bridge-plugin.rbxm` succeeds and output is > 1KB -- [ ] `cd tools/studio-bridge && npm run test` passes with zero failures (all Phase 1 + Phase 2 tests) -- [ ] `studio-bridge install-plugin` writes the `.rbxm` to the correct platform-specific plugins folder -- [ ] `studio-bridge exec 'print("hello")'` with one active session auto-selects it and returns output -- [ ] PluginManager generality test passes: second template registers, builds, and installs without PluginManager code changes - ---- - -## Phase 3: New Action Commands - -> Plan: `phases/03-commands.md` | Agent prompts: `agent-prompts/03-commands.md` | Validation: `validation/03-commands.md` -> Depends on Tasks 1.6, 1.7a, 2.1 - -### Parallelization - -``` -1.7a (shared CLI utils) --> 3.1 (state) --------+ - --> 3.2 (screenshot) ----+ - --> 3.3 (logs) ----------+--> 3.5 (wire terminal adapter) - --> 3.4 (query) ---------+ -``` - -### Tasks - -- [ ] **3.1** State query action (S) - - Plugin: `templates/studio-bridge-plugin/src/Actions/StateAction.lua` - - Server: `src/server/actions/query-state.ts` - - Command: `src/commands/state.ts`, `src/commands/index.ts` (add to barrel) - - Dependencies: 1.6, 1.7b (barrel pattern), 2.1 - - Agent-assignable: yes (each layer is simple) - - Acceptance: single handler in `src/commands/state.ts`, registered via `src/commands/index.ts` barrel (NOT `cli.ts`), prints Place/PlaceId/GameId/Mode, `--json`, `--watch` subscribes to stateChange, 5s timeout - -- [ ] **3.2** Screenshot capture action (M) - - Plugin: `templates/studio-bridge-plugin/src/Actions/ScreenshotAction.lua` - - Server: `src/server/actions/capture-screenshot.ts` - - Command: `src/commands/screenshot.ts`, `src/commands/index.ts` (add to barrel) - - Dependencies: 1.6, 1.7b (barrel pattern), 2.1 - - Agent-assignable: no (requires real Studio testing for CaptureService edge cases) - - Acceptance: single handler in `src/commands/screenshot.ts`, registered via `src/commands/index.ts` barrel (NOT `cli.ts`), writes PNG, `--output`, `--base64`, `--open`, 15s timeout, error if CaptureService call fails at runtime - -- [ ] **3.3** Log query action (M) - - Plugin: `templates/studio-bridge-plugin/src/Actions/LogAction.lua` - - Server: `src/server/actions/query-logs.ts` - - Command: `src/commands/logs.ts`, `src/commands/index.ts` (add to barrel) - - Dependencies: 1.6, 1.7b (barrel pattern), 2.1 - - Agent-assignable: yes (well-specified) - - Acceptance: single handler in `src/commands/logs.ts`, registered via `src/commands/index.ts` barrel (NOT `cli.ts`), `--tail`, `--head`, `--follow`, `--level`, `--all`, `--json`, ring buffer (1000 entries) - -- [ ] **3.4** DataModel query action (L) - - Plugin: `templates/studio-bridge-plugin/src/Actions/DataModelAction.lua`, `ValueSerializer.lua` - - Server: `src/server/actions/query-datamodel.ts` - - Command: `src/commands/query.ts`, `src/commands/index.ts` (add to barrel) - - Dependencies: 1.6, 1.7b (barrel pattern), 2.1 - - Agent-assignable: no (complex Roblox type serialization, requires Studio testing) - - Acceptance: single handler in `src/commands/query.ts`, registered via `src/commands/index.ts` barrel (NOT `cli.ts`), dot-path resolution, `--children`, `--descendants`, `--depth`, `--properties`, `--attributes`, SerializedValue with `type` discriminant and flat `value` arrays, 10s timeout - -- [ ] **3.5** Wire terminal adapter registry into terminal-mode.ts (S) - - Files: `src/commands/connect.ts`, `src/commands/disconnect.ts` (create); `src/cli/commands/terminal/terminal-mode.ts`, `src/cli/commands/terminal/terminal-editor.ts` (modify) - - Dependencies: 1.7b, 2.6, 3.1, 3.2, 3.3, 3.4 - - Agent-assignable: no (interactive REPL UX, manual testing) - - Acceptance: `.state`, `.screenshot`, `.logs`, `.query`, `.sessions`, `.connect`, `.disconnect`, `.help` all dispatch through adapter, no handler logic in terminal files - -### Phase 3 Gate -- REVIEW CHECKPOINT - -- [ ] All four action handler unit tests passing (state, screenshot, logs, query) -- [ ] DataModel path prefixing tests -- [ ] CLI command format tests (state, screenshot) -- [ ] Full lifecycle e2e including all actions -- [ ] Concurrent request tests - -> Manual Studio testing deferred to Phase 6 (E2E). See `validation/06-integration.md`. - -**Phase 3 gate reviewer checklist**: -- [ ] All four commands (`state`, `screenshot`, `logs`, `query`) are defined once in `src/commands/` and registered via `src/commands/index.ts` barrel (no per-command `cli.ts` modifications) -- [ ] `studio-bridge state --json` returns valid JSON with Place, PlaceId, GameId, Mode, Context fields -- [ ] `studio-bridge logs --follow` subscribes to `logPush` events via WebSocket push protocol and streams output -- [ ] `studio-bridge query Workspace.NonExistent` returns a clear error message (not a stack trace) -- [ ] `cd tools/studio-bridge && npm run test` passes with zero failures (all Phase 1 + 2 + 3 tests) - ---- - -## Phase 4: Split Server Mode - -> Plan: `phases/04-split-server.md` | Agent prompts: `agent-prompts/04-split-server.md` | Validation: `validation/04-split-server.md` -> Depends on Task 1.3d5 (bridge module). Can proceed in parallel with Phases 2-3. - -### Parallelization - -``` -4.1 (serve command) ------------------------------------------------+ - +--> (both done) -4.2 (remote client) --> 4.3 (auto-detection) --> 6.5 (CI integration)| -``` - -> **Sequential chain**: Tasks 4.2, 4.3, and 6.5 all modify `bridge-connection.ts` and MUST run sequentially (4.2 -> 4.3 -> 6.5) to avoid merge conflicts. Task 6.5 is listed under Phase 6 but is sequenced here because of the shared file dependency. - -### Tasks - -- [ ] **4.1** Serve command -- thin wrapper (S) - - File: `src/commands/serve.ts` (create) - - Dependencies: 1.3d5, 1.7a - - Agent-assignable: yes - - Acceptance: binds port 38741, stays alive until killed, `--port`, `--json`, `--log-level`, `--timeout`, SIGTERM/SIGINT trigger graceful shutdown, clear error if port in use - -- [ ] **4.2** Remote bridge client (devcontainer CLI) (S) - - Files: `src/bridge/bridge-connection.ts` (modify), `src/cli/args/global-args.ts` (modify) - - Dependencies: 1.3d5 - - Agent-assignable: yes - - Acceptance: `--remote localhost:38741` connects as client, `--local` forces local mode, all commands work through remote, clear error messages - -- [ ] **4.3** Devcontainer auto-detection (S) - - Files: `src/bridge/internal/environment-detection.ts` (create), `src/bridge/bridge-connection.ts` (modify) - - Dependencies: 4.2 - - Agent-assignable: yes - - Acceptance: detects `REMOTE_CONTAINERS`/`CODESPACES` env or `/.dockerenv`, auto-connects to remote, falls back to local with warning - -### Phase 4 Gate - -- [ ] Daemon accepts plugin + CLI relay -- [ ] Daemon survives CLI disconnect -- [ ] Devcontainer auto-detection test -- [ ] Manual verification in devcontainer (see `validation/04-split-server.md`) - ---- - -## Phase 5: MCP Integration - -> Plan: `phases/05-mcp-server.md` | Agent prompts: `agent-prompts/05-mcp-server.md` | Validation: `validation/05-mcp-server.md` -> Depends on Phase 3 (all command handlers must exist) and Task 1.7 - -### Parallelization - -``` -5.1 (scaffold) --> 5.2 (MCP adapter / tool generation) - --> 5.3 (transport + config) -``` - -### Tasks - -- [ ] **5.1** MCP server scaffold and `mcp` command (M) - - Files: `src/mcp/mcp-server.ts`, `src/mcp/index.ts`, `src/commands/mcp.ts` (create); `src/commands/index.ts`, `package.json` (modify) - - Dependencies: 1.7a, Phase 3 complete - - Agent-assignable: no (requires integration testing with Claude Code; SDK choice is decided: use `@modelcontextprotocol/sdk`) - - Acceptance: `studio-bridge mcp` starts MCP server via stdio, connects to bridge, advertises all MCP-eligible tools, `mcp` command itself not exposed as tool, logs to stderr - -- [ ] **5.2** MCP adapter (tool generation from CommandDefinitions) (M) - - File: `src/mcp/adapters/mcp-adapter.ts` (create) - - Dependencies: 5.1, 1.7a - - Agent-assignable: yes - - Acceptance: `createMcpTool` generates from `CommandDefinition`, uses `mcpName`/`mcpDescription`, auto-generated JSON Schema from `ArgSpec`, `interactive: false` session resolution, screenshot returns image content block, no per-tool files - -- [ ] **5.3** MCP transport and configuration (S) - - File: `src/mcp/mcp-server.ts` (modify) - - Dependencies: 5.1, 5.2 - - Agent-assignable: yes - - Acceptance: stdio JSON-RPC via `StdioServerTransport`, Claude Code config entry works, `--remote` for devcontainer, `--log-level` controls stderr - -### Phase 5 Gate - -- [ ] MCP server advertises all six tools -- [ ] `studio_exec` returns structured result -- [ ] `studio_state` returns JSON -- [ ] `studio_screenshot` returns image content block -- [ ] Session auto-selection (single + multiple error) -- [ ] Manual verification with Claude Code (see `validation/05-mcp-server.md`) - ---- - -## Phase 6: Polish & Integration -- REVIEW CHECKPOINT (Release Gate) - -> **Hard release gate.** Phase 6 verification (see `validation/06-integration.md`) MUST pass before any public release. A review agent can verify automated test results, code quality, and export correctness. However, items requiring Roblox Studio (E2E plugin testing) require Studio validation -- no agent can run Studio. A release that passes CI but fails the Phase 6 Studio checklist ships a broken product. Treat Phase 6 completion as the release gate, not Phase 5 completion. - -> Plan: `phases/06-integration.md` | Agent prompts: `agent-prompts/06-integration.md` | Validation: `validation/06-integration.md` -> Depends on all phases - -**Release gate reviewer checklist**: -- [ ] All automated test suites pass (`cd tools/studio-bridge && npm run test`) including e2e tests from Task 6.2 -- [ ] Manual Studio E2E validation passes: plugin installs, discovers server, connects, survives Play/Stop transitions (validation/06-integration.md section 4, items 1-9) -- [ ] All six action commands work against a real Studio instance: `exec`, `state`, `screenshot`, `logs`, `query`, `sessions` (validation/06-integration.md section 4, items 10-17) -- [ ] Context-aware commands verified in real Play mode: `--context server` and `--context client` target the correct DataModel (validation/06-integration.md section 4, items 18-23) -- [ ] `index.ts` exports all v1 types unchanged AND all new v2 types (`BridgeConnection`, `BridgeSession`, `SessionInfo`, etc.) - -### Tasks - -- [ ] **6.1** Update existing tests (M) - - Files: `src/server/studio-bridge-server.test.ts`, `web-socket-protocol.test.ts` (modify) - - Dependencies: Phases 1-3 - - Agent-assignable: no (integration tests, understanding of full system needed) - -- [ ] **6.2** End-to-end test suite (L) - - Files: `src/test/e2e/persistent-session.test.ts`, `split-server.test.ts`, `hand-off.test.ts`, `src/test/helpers/mock-plugin-client.ts` (create) - - Dependencies: Phases 1-4 - - Agent-assignable: no (orchestrating multi-process tests) - -- [ ] **6.3** Migration guide (S) - - Dependencies: all phases - - Agent-assignable: no (technical writing requiring understanding of user workflows) - -- [ ] **6.4** Update index.ts exports (S) - - File: `src/index.ts` (modify) - - Dependencies: all phases - - Agent-assignable: yes - -- [ ] **6.5** CI integration (S) - - File: `src/bridge/bridge-connection.ts` (modify) - - Dependencies: 4.3 (sequential chain: 4.2 -> 4.3 -> 6.5 -- all modify `bridge-connection.ts`) - - Agent-assignable: yes - - Acceptance: `CI=true` -> `preferPersistentPlugin: false`, existing CI workflows pass - - NOTE: Must run after 4.3 completes. Tasks 4.2, 4.3, and 6.5 all modify `bridge-connection.ts` and must be sequenced to avoid merge conflicts. - ---- - -## Critical Path - -The longest dependency chain (11 sequential steps) determines the minimum timeline: - -``` -1.1 (protocol v2) - -> 1.5 (v2 handshake) - -> 1.6 (action dispatch) - -> 2.1 (persistent plugin core) - -> 2.2 (execute action in plugin) - -> 2.5 (detection + fallback) - -> 3.1-3.4 (new actions, parallel) <- also needs 1.7a - -> 3.5 (wire terminal adapter) - -> 5.1 (MCP scaffold) - -> 5.2 (MCP tools via adapter) - -> 6.2 (e2e tests) -``` - -### Tasks that block the most downstream work - -1. **Task 1.1** (protocol v2) -- blocks everything in Phases 2, 3, and 5 -2. **Task 1.3a-d** (bridge module) -- 1.3a blocks 1.3b, 1.3c, and Phase 1b (1.8); 1.3d5 blocks 1.4, 1.7a, 1.7b, Phase 1b (1.9, 1.10), all of Phase 4, 2.3, 2.6. Split into sub-tasks: 1.3a (transport + host) -> 1.3b (sessions) + 1.3c (client) in parallel -> 1.3d1 -> 1.3d2 -> 1.3d3 -> 1.3d4 (all agent-assignable) -> 1.3d5 (review checkpoint, ~30 min). -3. **Task 1.7a** (shared CLI utilities) -- blocks all command implementations in Phases 2-3 (2.6, 3.1-3.4) and the MCP adapter (5.2). Sessions command (1.7b) serves as the reference implementation. -4. **Task 1.6** (action dispatch) -- blocks all action implementations in Phase 3 -5. **Task 2.1** (persistent plugin core) -- blocks all plugin-side action handlers - -### Off the critical path but important - -- **Phase 0** (output modes) -- independent, can be done any time before 1.7a. Task 1.7a integrates them. -- **Phase 0.5** (plugin modules) -- runs in parallel with Phase 0 and Phase 1. Only becomes blocking if 0.5.4 (Lune integration tests) is slow. Early completion de-risks Phase 2 plugin work. -- **Phase 1b** (failover: 1.8, 1.9, 1.10) -- runs in parallel with Phases 2-3. No longer blocks downstream work. 1.8 depends on 1.3a (can start early); 1.9 and 1.10 depend on 1.3d5 and 1.8. -- **Sessions command** (1.7b) -- moved earlier into Phase 1, immediately after shared CLI utilities (1.7a). Validates the CLI utility layer before Phase 2 begins. -- **Phase 4** (split server) -- depends only on 1.3d5 and 1.7a, can run in parallel with Phases 2-3. -- **CommandDefinition extraction** -- deferred to Phase 5 (MCP adapter). Phase 3 commands use shared CLI utilities directly. - -### Priority start order - -Tasks 1.1, 1.3a, 0.1-0.4, and 0.5.1 should be prioritized above all others and can all proceed in parallel. Tasks 1.3b and 1.3c should start as soon as 1.3a is complete. Task 1.8 (failover) can also start once 1.3a is done but no longer blocks other work. Subtasks 1.3d1-1.3d4 should start as soon as 1.3b and 1.3c are complete (these are agent-assignable and run in sequence). Task 1.7a should start as soon as 1.3d5 and 0.4 are complete. - ---- - -## Delegation Quick Reference - -### Agent-assignable tasks by phase - -| Phase | Tasks | Agent prompt file | -|-------|-------|-------------------| -| 0 | 0.1, 0.2, 0.3, 0.4 | `agent-prompts/00-prerequisites.md` | -| 0.5 | 0.5.1, 0.5.2, 0.5.3, 0.5.4 | `agent-prompts/02-plugin.md` | -| 1 | 1.1, 1.2, 1.3a, 1.3b, 1.3c, 1.3d1, 1.3d2, 1.3d3, 1.3d4, 1.4, 1.5, 1.6, 1.7a, 1.7b | `agent-prompts/01-bridge-network.md` | -| 1b | 1.10 | `agent-prompts/01-bridge-network.md` | -| 2 | 2.3, 2.4 | `agent-prompts/02-plugin.md` | -| 3 | 3.1, 3.3 | `agent-prompts/03-commands.md` | -| 4 | 4.1, 4.2, 4.3 | `agent-prompts/04-split-server.md` | -| 5 | 5.2, 5.3 | `agent-prompts/05-mcp-server.md` | -| 6 | 6.4, 6.5 | `agent-prompts/06-integration.md` | - -### Requires review agent or orchestrator coordination - -| Task | Reason | Review approach | -|------|--------|----------------| -| 1.3d5 (barrel export + API surface review) | API surface must match tech spec contract. Subtasks 1.3d1-1.3d4 are agent-assignable. | Review agent verifies exports against `07-bridge-network.md` section 2.1 | -| 1.8 (failover impl) | Multi-process coordination, timing races, real sockets. Now in Phase 1b, no longer blocks other phases | Skilled agent implements; review agent verifies state machine correctness and test coverage | -| 1.9 (failover tests) | Integration tests with concurrent processes, port races, timing. Now in Phase 1b | Skilled agent implements; review agent verifies timing assertions and cleanup | -| 2.1 (persistent plugin) | Complex Luau with Roblox service wiring. Requires Studio validation for runtime behavior | Agent implements code + Lune tests; Studio validation deferred to Phase 6 | -| 2.2 (execute action) | Luau in Studio context | Agent implements; review agent checks code quality and test coverage | -| 2.5 (detection + fallback) | Integration edge cases between plugin detection and fallback | Agent implements with thorough tests; review agent verifies edge case coverage | -| 2.6 (exec/run refactor) | Session resolution UX, handler pattern migration | Agent implements; review agent verifies pattern consistency and test coverage | -| 3.2 (screenshot) | CaptureService confirmed working; requires Studio validation for edge cases | Agent implements code + mock tests; Studio validation deferred to Phase 6 | -| 3.4 (DataModel query) | Complex Roblox type serialization, requires Studio validation | Agent implements code + mock tests; Studio validation deferred to Phase 6 | -| 3.5 (terminal adapter) | Interactive REPL wiring to adapter registry | Agent implements; review agent verifies dispatch pattern and dot-command coverage | -| 5.1 (MCP scaffold) | MCP SDK integration; needs Claude Code validation | Agent implements; Claude Code validation is a separate step | -| 6.1, 6.2, 6.3 (polish) | Full-system understanding needed for test updates and migration guide | Agent implements with full codebase context; review agent verifies completeness | - -### Recommended parallelization groups - -These groups of tasks can be delegated simultaneously: - -**Wave 1** (no dependencies): -- 0.1, 0.2, 0.3 (output modes -- different package) -- 0.5.1 (protocol module -- Luau, independent) -- 1.1 (protocol v2) -- 1.2 (pending request map) -- 1.3a (transport + host) -- start early, first step of bridge module - -**Wave 2** (after 1.1 and/or 1.3a complete): -- 0.4 (barrel -- after 0.1-0.3) -- 0.5.2 (discovery state machine -- after 0.5.1) -- 0.5.3 (action router + message buffer -- after 0.5.1) -- 1.3b (sessions -- after 1.3a) -- 1.3c (client -- after 1.3a) -- 1.5 (v2 handshake -- after 1.1) -- 1.8 (failover impl -- after 1.3a; Phase 1b, non-blocking) - -**Wave 3** (after Wave 2): -- 0.5.4 (Lune integration tests -- after 0.5.1-0.5.3, 1.3a) -- **1.3d1-1.3d4 (BridgeConnection subtasks -- after 1.3a, 1.3b, 1.3c) -- Agent-assignable, run in sequence: 1.3d1 -> 1.3d2 -> 1.3d3 -> 1.3d4. These can proceed without human intervention.** -- 1.6 (action dispatch -- after 1.1, 1.2, 1.5) -- 1.9 (failover tests -- after 1.3d5, 1.8; Phase 1b, non-blocking) -- 2.1 (persistent plugin -- after 1.1) - -**Wave 3.5** (after 1.3d4 complete -- review checkpoint on 1.3d5 only): -- **1.3d5 (barrel export + API review) -- REVIEW CHECKPOINT: ~30-minute review task. The orchestrator dispatches this to a review agent (or performs the checklist verification itself) after 1.3d1-1.3d4 are complete. Do NOT dispatch Wave 3.5+ tasks (1.4, 1.7a, etc.) until 1.3d5 is validated and merged.** -- 1.4 (StudioBridge wrapper -- after 1.3d5) -- 1.7a (shared CLI utilities -- after 1.3d5, 0.4) -- 1.10 (failover observability -- after 1.3d5, 1.8; Phase 1b, non-blocking) -- 2.3 (health endpoint -- after 1.3d5) - -**Wave 4** (after 1.7a complete -- Phase 1 gate no longer requires failover): -- 1.7b (sessions command -- after 1.7a) -- 2.2 (execute action -- after 2.1) -- 2.4 (plugin manager -- after 2.1) -- 2.6 (exec/run refactor -- after 1.3d5, 1.4, 1.7a) -- 4.1 (serve command -- after 1.3d5, 1.7a) -- 4.2 (remote client -- after 1.3d5) -- **starts the bridge-connection.ts sequential chain: 4.2 -> 4.3 -> 6.5** - -**Wave 5** (after Phase 2 core): -- 2.5 (detection + fallback -- after 2.3, 2.4) -- 3.1, 3.2, 3.3, 3.4 (all actions -- after 1.6, 1.7a, 2.1) -- 4.3 (auto-detection -- after 4.2) -- **sequential: must complete before 6.5 starts (bridge-connection.ts chain)** -- 6.5 (CI integration -- after 4.3) -- **sequential: last in bridge-connection.ts chain (4.2 -> 4.3 -> 6.5)** - -**Wave 6** (after Phase 3): -- 3.5 (terminal wiring -- after 3.1-3.4, 1.7b, 2.6) -- 5.1 (MCP scaffold -- after Phase 3, 1.7a) - -**Wave 7** (after MCP scaffold): -- 5.2, 5.3 (MCP adapter + transport -- after 5.1) - -**Wave 8** (final): -- 6.1, 6.2, 6.3, 6.4 (polish -- after all phases) - -### Two-agent execution model (required) - -> **Cap parallelism at 2 agents.** The wave table above shows theoretical parallelism of up to 7 concurrent tasks. In practice, merge overhead and conflict risk exceed the parallelism gain above 2 agents. The execution model is exactly 2 agents with file-ownership boundaries that eliminate merge conflicts entirely. Do NOT attempt to run more than 2 concurrent sub-agents. - -**Agent A** (TypeScript infrastructure): -- **Owns**: `src/bridge/`, `src/server/`, `src/mcp/`, `tools/cli-output-helpers/` -- Phase 0 (output modes in `tools/cli-output-helpers/`) -- Phase 1 core (protocol, bridge host/client, session tracker, shared utils) -- Phase 2: health endpoint only (2.3) -- Phase 4 (serve command, remote client, devcontainer) -- Phase 5 (MCP integration) - -Sequence: `0.1-0.4` -> `1.1` -> `1.2` -> `1.3a` -> `1.3b` + `1.3c` (parallel) -> `1.3d1-1.3d5` -> `1.5` -> `1.6` -> `2.3` -> `3.1` -> `3.3` -> `4.1` -> `4.2` -> `4.3` -> `5.1` -> `5.2` -> `5.3` - -**Agent B** (Luau plugin + CLI commands): -- **Owns**: `templates/`, `src/commands/`, `src/cli/`, `src/plugins/` -- Phase 0.5 (Lune-testable plugin modules) -- Phase 1b (failover, parallel with Agent A's Phase 2-3 work) -- Phase 2 (plugin wiring, plugin manager, CLI refactor) -- Phase 3 (action commands + terminal wiring) -- Phase 6 (polish, tests, migration) - -Sequence: `0.5.1-0.5.3` -> `0.5.4` (after A: 1.3a) -> `1.4` (after A: 1.3d5) -> `1.7a` (after A: 0.4, 1.3d5) -> `1.7b` -> `1.8` (after A: 1.3a) -> `1.9` (after A: 1.3d5) -> `1.10` -> `2.1` -> `2.2` -> `2.4` -> `2.5` -> `2.6` -> `3.2` -> `3.4` -> `3.5` -> `6.1` -> `6.2` - -**Zero file overlap between agents.** Merges are always clean -- just combine branches. - -### Realistic parallelism per wave - -The wave table above shows theoretical max parallelism. The table below shows what is realistic with the 2-agent model: - -| Wave | Theoretical Parallelism | Realistic (2 agents) | Why | -|------|------------------------|---------------------|-----| -| 1 | 7 | **2** | Agent A: 0.1-0.3, 1.1, 1.2, 1.3a. Agent B: 0.5.1. All new files, clean merges. | -| 2 | 7 | **2** | Agent A: 0.4, 1.3b, 1.3c, 1.5. Agent B: 0.5.2, 0.5.3, 1.8. Mostly new files. | -| 3 | 5 | **2** | Agent A: 1.3d1-1.3d4 (agent-assignable), 1.6. Agent B: 0.5.4, 2.1. 1.3d5 is a review checkpoint (~30 min). | -| 3.5 | 4 | **2** | Agent A: 2.3. Agent B: 1.4, 1.7a, 1.10. Gated on 1.3d5. | -| 4 | 7 | **2** | Agent A: 4.1, 4.2. Agent B: 1.7b, 2.2, 2.4, 2.6. cli.ts conflicts serialize to B. | -| 5 | 6 | **2** | Agent A: 4.3. Agent B: 2.5, 3.1-3.4. Action commands serialize within B. | -| 6 | 2 | **2** | Agent A: 5.1. Agent B: 3.5. Natural funnel. | -| 7 | 2 | **2** | Agent A: 5.2, 5.3. Agent B: idle or starting 6.x. | -| 8 | 4 | **2** | Agent A: idle. Agent B: 6.1-6.4. Integration needs merged context. | - -### Sync points - -There are 6 sync points where Agent A's output unblocks Agent B. At each sync point, the orchestrator merges Agent A and Agent B branches and runs post-merge validation before Agent B proceeds. - -| # | Sync Point | Agent A has completed | Agent B is unblocked to start | Post-merge validation | -|---|-----------|----------------------|-------------------------------|----------------------| -| SP-1 | After 1.1 (protocol types) | 1.1 (v2 type definitions) | B can use protocol types in plugin modules (2.1) | `cd tools/studio-bridge && npm run test` | -| SP-2 | After 1.3a (transport) | 1.3a (transport + bridge host) | B can start 0.5.4 (Lune integration tests) and 1.8 (failover) | `cd tools/studio-bridge && npm run test` | -| SP-3 | After 1.3d5 (BridgeConnection) | 1.3d1-1.3d5 (BridgeConnection subtasks) | B can start 1.4 (StudioBridge wrapper), 1.7a (shared CLI utils), 1.9 (failover tests), 1.10 (observability), 2.3 (health, assigned to A but unblocks B's plugin discovery) | `cd tools/studio-bridge && npm run test` | -| SP-4 | After 1.7a + 1.7b (shared utils + reference command) | 1.7a (shared CLI utilities), 1.7b (sessions command) | B can start 3.1-3.4 (action commands), 2.6 (exec/run refactor) | `cd tools/studio-bridge && npm run test` | -| SP-5 | After 2.3 (health endpoint) | 2.3 (health endpoint on bridge host) | B can integrate plugin discovery (2.5) | `cd tools/studio-bridge && npm run test` | -| SP-6 | After Phase 4 complete | 4.1 (serve), 4.2 (remote client), 4.3 (auto-detection) | B can add devcontainer-aware behavior to CLI commands | `cd tools/studio-bridge && npm run test` | - -**Orchestrator workflow at each sync point:** -1. Wait for Agent A to complete the specified tasks -2. Wait for Agent B to complete its current in-progress work (do NOT interrupt mid-task) -3. Merge Agent A's branch into Agent B's branch (or both into a shared integration branch) -4. Run the post-merge validation command -5. If validation fails, create a remediation task before Agent B proceeds -6. If validation passes, dispatch Agent B's next tasks - -### Post-merge validation - -After merging Agent A and Agent B branches at each sync point, the orchestrator MUST run validation on the merged result. This catches "works in isolation, fails combined" issues early. - -**Validation command at every sync point:** - -```bash -cd /workspaces/NevermoreEngine/tools/studio-bridge && npm run test -``` - -**What to do when post-merge validation fails:** -1. Identify which test(s) failed and which agent's code is involved -2. If the failure is in Agent A's code: assign a fix task to Agent A before dispatching Agent B's next work -3. If the failure is in Agent B's code: assign a fix task to Agent B -4. If the failure is an integration issue (both agents' code interacts incorrectly): the orchestrator creates a targeted remediation task describing the conflict, assigns it to whichever agent owns the failing file, and provides the other agent's code as read-only context -5. Re-run validation after the fix. Do NOT proceed past the sync point until validation passes. - -**Why this matters:** Each sync point is where Agent B first consumes Agent A's types, APIs, or runtime behavior. Type mismatches, import path errors, and behavioral assumptions are most likely to surface here. Catching them immediately (rather than at Phase 6 E2E) saves significant rework. - ---- - -## Known Risks - -| # | Risk | Mitigation | Contingency | -|---|------|-----------|-------------| -| 1 | **CaptureService runtime failures** -- confirmed working in Studio plugins, but may fail at runtime when minimized or under resource constraints | Wrap in `pcall`, return clear `SCREENSHOT_FAILED` error with details; always advertise `captureScreenshot` capability | Runtime failures return actionable error messages; all other features independent | -| 2 | **WebSocket reliability in Studio** -- silent drops, truncated frames, missing API | Aggressive reconnection + backoff (2.1), heartbeats every 15s, generous frame limits (16MB), base64 for binary data | Fall back to temporary plugin for shorter sessions | -| 3 | **Cross-platform plugin paths** -- macOS vs Windows vs Linux (wine) | `findPluginsFolder()` already handles macOS/Windows; verify in 2.4; print exact path; fail with manual install instructions | Manual install instructions | -| 4 | **Port forwarding in devcontainers** -- 38741 may not auto-forward | Document requirement; recommend `forwardPorts` in devcontainer.json; auto-detection fallback; `--remote` override | `--remote` flag bypasses auto-detection | -| 5 | **Port contention on 38741** -- another process may use the port | `EADDRINUSE` = connect as client; `--port` override; clear error messages | `--port` flag for alternate port | -| 6 | **Orphaned plugins after host crash** -- no clients to take over | Exponential backoff polling (max 30s); next CLI becomes host; hand-off protocol (Phase 1b: 1.8); `SO_REUSEADDR` (1.8); idle grace period (5s) | Plugins reconnect on next CLI invocation | -| 7 | **Failover timing races** -- duplicate hosts, lost sessions, silent failures | Hardened state machine (Phase 1b: 1.8), dedicated test suite (1.9), structured logging (1.10), random jitter (0-500ms), immediate `SessionDisconnectedError`, health endpoint diagnostics | Fall back to non-transferable hosts (restart from scratch on host death) | - ---- - -## Merge Conflict Mitigation - -Two file hotspots have been identified and mitigated: - -### `cli.ts` -- barrel export pattern (7 tasks) - -**Problem**: Tasks 1.7b, 2.4, 2.6, 3.1, 3.2, 3.3, 3.4 all need to register CLI commands. If each task modifies `cli.ts` directly to add `.command()` calls, parallel execution produces merge conflicts at the same lines. - -**Solution**: Barrel export pattern. Task 1.7b establishes it: -1. `src/commands/index.ts` (barrel file) exports all command handlers and an `allCommands` array. -2. `src/cli/cli.ts` imports `allCommands` and registers them in a single loop: `for (const cmd of allCommands) { cli.command(createCliCommand(cmd)); }`. -3. Each subsequent task creates its command handler file in `src/commands/` AND adds an export line to the barrel file. The barrel file is append-only, so concurrent additions auto-merge cleanly. -4. `cli.ts` is NEVER modified again for command registration after Task 1.7b. - -**Impact**: All 7 tasks can run in parallel worktrees without conflict. Each task modifies only its own handler file and appends to the barrel file. - -### `bridge-connection.ts` -- sequential chain (3 tasks) - -**Problem**: Tasks 4.2, 4.3, and 6.5 all modify `src/bridge/bridge-connection.ts`. They touch different sections of the class, but auto-merge is not guaranteed. - -**Solution**: Sequence these tasks: 4.2 -> 4.3 -> 6.5. The time saved by parallelizing three small tasks does not justify the merge risk. Task 6.5 is listed under Phase 6 but executes immediately after 4.3 in the wave schedule. - ---- - -## Notes - -- This document mirrors the structure of the execution plan in `phases/` but is designed for operational tracking -- For detailed task specifications, always refer to the corresponding phase file -- For detailed test specifications, refer to the corresponding validation file -- For agent delegation, use the self-contained prompts in `agent-prompts/` -- The output-modes-plan.md contains the detailed design for Phase 0 including API signatures diff --git a/studio-bridge/plans/execution/agent-prompts/00-prerequisites.md b/studio-bridge/plans/execution/agent-prompts/00-prerequisites.md deleted file mode 100644 index 889648b062..0000000000 --- a/studio-bridge/plans/execution/agent-prompts/00-prerequisites.md +++ /dev/null @@ -1,89 +0,0 @@ -# Phase 0: Prerequisites -- Agent Prompts - -**Phase reference**: [studio-bridge/plans/execution/phases/00-prerequisites.md](../phases/00-prerequisites.md) - -## Overview - -Phase 0 tasks (0.1-0.4) are fully specified in the standalone detailed design document. There are no separate agent prompts for these tasks. - -See [studio-bridge/plans/execution/output-modes-plan.md](../output-modes-plan.md) for complete task specifications, acceptance criteria, and test plans. - -**Task prerequisites**: -- **Tasks 0.1, 0.2, 0.3**: None (independent, can run in parallel). -- **Task 0.4** (barrel export + output mode selector): Tasks 0.1, 0.2, and 0.3 must be completed first. - -## Conventions Reference - -The following conventions apply to all agent prompts across all phases: - -- **TypeScript ESM** with `.js` extensions on all local imports (e.g., `import { Foo } from './foo.js';`) -- **`Async` suffix** on all async functions (e.g., `listSessionsAsync`, `resolveRequestAsync`) -- **Private `_` prefix** on all private fields and methods -- **vitest** for tests: `describe`/`it`/`expect`, test files named `*.test.ts` alongside source -- **No default exports** -- always use named exports -- **yargs `CommandModule` pattern** for CLI commands (class with `command`, `describe`, `builder`, `handler`) -- **`@quenty/` scoped packages** for workspace imports (e.g., `@quenty/cli-output-helpers`) -- **`OutputHelper`** from `@quenty/cli-output-helpers` for all user-facing output - -## Testing Conventions - -The following conventions are **mandatory** for all test files across all phases. Violations will cause test gate failures. - -### Fake timers for timing-sensitive tests - -- ALL timing-sensitive tests MUST use `vi.useFakeTimers()`. This includes any test that involves timeouts, delays, reconnection windows, heartbeat intervals, jitter, grace periods, or any form of scheduled behavior. -- NO wall-clock timing assertions are permitted. Do not assert that something "completes within N seconds" or "takes less than N milliseconds" using `Date.now()` or `performance.now()`. These assertions are non-deterministic and will flake in CI. -- Use `vi.advanceTimersByTime(ms)` to deterministically advance time. For example, to test a 5-second timeout, call `vi.advanceTimersByTime(5000)` instead of waiting 5 real seconds. -- Restore real timers in `afterEach` to prevent leaking fake timer state between tests: - ```typescript - afterEach(() => { - vi.useRealTimers(); - }); - ``` -- When using fake timers with async code, remember to `await` any pending promises after advancing time. Use `vi.advanceTimersByTimeAsync(ms)` when the timer callbacks themselves are async. - -### Shared MockPluginClient - -- All tests that connect a mock plugin MUST use the standardized `MockPluginClient` defined in [shared-test-utilities.md](../validation/shared-test-utilities.md). Do not create ad-hoc WebSocket clients or raw WebSocket mocks for plugin simulation. -- Import from `../test-utils/mock-plugin-client.js` (relative to the test file's location within `tools/studio-bridge/src/`). -- See the shared utilities spec for the full interface, configuration options, and usage examples. - -### Example: timing-sensitive test pattern - -```typescript -import { MockPluginClient } from "../test-utils/mock-plugin-client.js"; - -describe("failover", () => { - afterEach(() => { - vi.useRealTimers(); - }); - - it("client takes over after host disconnect", async () => { - vi.useFakeTimers(); - - const mock = new MockPluginClient({ port: server.port }); - await mock.connectAsync(); - - // Kill the host - await host.disconnectAsync(); - - // Advance past the jitter + takeover window - await vi.advanceTimersByTimeAsync(2000); - - expect(client.role).toBe("host"); - - // Advance past plugin reconnection window - await vi.advanceTimersByTimeAsync(5000); - - expect(mock.isConnected).toBe(true); - await mock.disconnectAsync(); - }); -}); -``` - -## Cross-References - -- Phase plan: [studio-bridge/plans/execution/phases/00-prerequisites.md](../phases/00-prerequisites.md) -- Detailed design: [studio-bridge/plans/execution/output-modes-plan.md](../output-modes-plan.md) -- Shared test utilities: [studio-bridge/plans/execution/validation/shared-test-utilities.md](../validation/shared-test-utilities.md) -- Note: Phase 0 has no separate validation file; tests are specified in `studio-bridge/plans/execution/output-modes-plan.md`. diff --git a/studio-bridge/plans/execution/agent-prompts/00.5-plugin-modules.md b/studio-bridge/plans/execution/agent-prompts/00.5-plugin-modules.md deleted file mode 100644 index aae5238383..0000000000 --- a/studio-bridge/plans/execution/agent-prompts/00.5-plugin-modules.md +++ /dev/null @@ -1,415 +0,0 @@ -# Phase 0.5: Plugin Modules (Lune-Testable Luau) -- Agent Prompts - -**Phase reference**: [studio-bridge/plans/execution/phases/00.5-plugin-modules.md](../phases/00.5-plugin-modules.md) -**Validation**: [studio-bridge/plans/execution/validation/00.5-plugin-modules.md](../validation/00.5-plugin-modules.md) - -Base path for all file references: `/workspaces/NevermoreEngine/tools/studio-bridge/` - -## How to Use These Prompts - -1. Copy the full prompt for a single task into a Claude Code sub-agent session. -2. The agent should read the "Read First" files, then implement the "Requirements" section. -3. The agent should run the acceptance criteria checks before reporting completion. -4. Do not give an agent a task whose dependencies have not been completed yet. - -Key conventions that apply to every prompt: - -- **Luau** source files with `.luau` extension -- **No Roblox API dependencies** -- these modules must run under Lune without `game`, `HttpService`, `RunService`, etc. -- **Dependency injection** -- external I/O (HTTP, WebSocket) is injected via callbacks, never called directly -- **Lune** for test runner: `lune run test/` -- **Pure logic only** -- no side effects in module scope - ---- - -## Test Harness (Created by Task 0.5.1) - -Task 0.5.1 must create the shared test harness **before** implementing the Protocol module. This harness is a prerequisite for all Phase 0.5 tasks -- without it, no Lune tests can run. - -All test harness files go in `templates/studio-bridge-plugin/test/`. - -### `test/roblox-mocks.luau` - -Minimal stubs for Roblox services so that pure logic modules can be required without errors. These do not need full implementations -- just enough that modules load and pure logic is exercisable. - -Required stubs: - -1. **HttpService** -- `JSONEncode(self, value)` and `JSONDecode(self, json)`. Implementation can delegate to Lune's `net.jsonEncode` / `net.jsonDecode` since these are equivalent for pure data. - -2. **RunService** -- Stub methods returning constants: - - `IsStudio(self)` -> `true` - - `IsRunning(self)` -> `false` - - `IsClient(self)` -> `false` - - `IsServer(self)` -> `false` - - `Heartbeat` -> a mock Signal (see below) - -3. **LogService** -- `MessageOut` -> a mock Signal. - -4. **Signal mock** -- A simple table with: - - `Connect(self, callback)` -> returns a connection object with `Disconnect(self)` method - - `Fire(self, ...)` -> calls all connected callbacks with the given arguments - - Tracks connected callbacks in a list; `Disconnect` removes the callback from the list - -The mocks module should return a table that can be used to inject these services or to set up a mock `game` global for test purposes. The Phase 0.5 modules themselves do NOT use Roblox APIs (they use dependency injection), but the mocks may be useful for the integration glue tests later. - -### `test/test-runner.luau` - -A simple Lune test runner that: - -1. Takes a list of test file paths as command-line arguments (via `process.args`), or if no arguments are given, discovers all `*.test.luau` files in the `test/` directory (non-recursive, excluding `test/integration/`). -2. For each test file, `require`s it (or uses `dofile` / Lune equivalent) and runs the tests. -3. Prints pass/fail status per individual test with the test name. -4. Prints a summary at the end: `X passed, Y failed, Z total`. -5. Exits with code 0 if all tests passed, code 1 if any failed. - -Test files should export their tests in a format the runner understands. The simplest approach: each test file returns a table of `{ name: string, fn: () -> () }` entries. The runner calls each `fn` inside a `pcall`. If the `pcall` succeeds, the test passes. If it errors, the test fails and the error message is printed. - -Agents run: `lune run test/test-runner.luau` (all unit tests) or `lune run test/protocol.test.luau` (single file, if test files also self-execute). - ---- - -## Task 0.5.1: Protocol Module - -**Prerequisites**: None (first task in Phase 0.5; also creates the shared test harness). - -**Context**: The studio-bridge plugin needs to encode and decode JSON messages conforming to the wire protocol. This module handles pure message serialization/deserialization with no Roblox dependencies, making it testable under Lune. **This task also creates the shared test harness that all subsequent Phase 0.5 tasks depend on.** - -**Objective**: Create the Lune test harness (roblox-mocks + test-runner) AND a Luau module that encodes and decodes all studio-bridge protocol message types as JSON strings. - -**Read First**: -- `/workspaces/NevermoreEngine/studio-bridge/plans/tech-specs/01-protocol.md` (wire protocol message formats) -- `/workspaces/NevermoreEngine/studio-bridge/plans/tech-specs/00-overview.md` (architecture overview) - -**Files to Create**: -- `templates/studio-bridge-plugin/test/roblox-mocks.luau` -- Roblox service stubs (see Test Harness section above) -- `templates/studio-bridge-plugin/test/test-runner.luau` -- Lune test runner (see Test Harness section above) -- `templates/studio-bridge-plugin/src/Shared/Protocol.luau` -- encode/decode module -- `templates/studio-bridge-plugin/test/protocol.test.luau` -- Lune test: round-trip encode/decode for each message type - -**Requirements**: - -1. The module exports a table with two primary functions: - -```luau -local Protocol = {} - --- Encode a message table to a JSON string -function Protocol.encode(message: { - type: string, - sessionId: string, - payload: { [string]: any }, - requestId: string?, - protocolVersion: number?, -}): string - --- Decode a JSON string to a message table, or return nil + error string -function Protocol.decode(raw: string): ({ [string]: any }?, string?) -``` - -2. Handle ALL message types from the protocol spec: - - **Plugin-to-Server**: `register`, `hello`, `scriptComplete`, `output`, `stateResult`, `screenshotResult`, `dataModelResult`, `logsResult`, `stateChange`, `heartbeat`, `subscribeResult`, `unsubscribeResult`, `error` - - **Server-to-Plugin**: `welcome`, `execute`, `shutdown`, `queryState`, `captureScreenshot`, `queryDataModel`, `queryLogs`, `subscribe`, `unsubscribe`, `error` - -3. `encode` must: - - Accept a message table with `type`, `sessionId`, `payload`, and optional `requestId`/`protocolVersion` - - Return a valid JSON string - - Omit `requestId` and `protocolVersion` from the output when they are `nil` - -4. `decode` must: - - Parse JSON string into a table - - Validate that `type`, `sessionId`, and `payload` are present - - Return `nil, "error description"` for malformed input (invalid JSON, missing required fields) - - Pass through `requestId` and `protocolVersion` when present - -5. Use a simple JSON library suitable for Lune (e.g., `net.jsonEncode`/`net.jsonDecode` from Lune's standard library, or a pure-Luau JSON implementation). Do NOT use `HttpService:JSONEncode` (that requires Roblox). - -6. Add message type validation: `decode` should verify that `type` is one of the known message types and return `nil, "unknown message type: "` for unrecognized types. - -**Acceptance Criteria**: -- `Protocol.encode(msg)` produces valid JSON for every message type. -- `Protocol.decode(Protocol.encode(msg))` round-trips correctly for every message type. -- `Protocol.decode("invalid json")` returns `nil` with an error string. -- `Protocol.decode('{"type":"unknown","sessionId":"x","payload":{}}')` returns `nil` with an error about unknown type. -- `Protocol.decode('{"type":"hello"}')` returns `nil` with an error about missing `sessionId`. -- `lune run test/protocol.test.luau` passes all tests. - -**Do NOT**: -- Use any Roblox APIs (`HttpService`, `game`, etc.). -- Import any Nevermore modules (this is standalone). -- Add side effects at module scope. - ---- - -## Task 0.5.2: Discovery State Machine - -**Prerequisites**: Task 0.5.1 (Protocol module and test harness) must be completed first. - -**Context**: The persistent plugin needs to discover a running bridge host by polling HTTP health endpoints, then connect via WebSocket. The state machine logic must be testable without Roblox APIs, so all I/O is injected via callbacks. - -**Objective**: Create a pure state machine module for plugin discovery and connection lifecycle. - -**Read First**: -- `/workspaces/NevermoreEngine/studio-bridge/plans/tech-specs/03-persistent-plugin.md` (sections 3-4: discovery and reconnection) -- `/workspaces/NevermoreEngine/studio-bridge/plans/tech-specs/01-protocol.md` (handshake flow) - -**Files to Create**: -- `templates/studio-bridge-plugin/src/Shared/DiscoveryStateMachine.luau` -- state machine module -- `test/discovery.test.luau` -- Lune test: all state transitions with mock callbacks - -**Requirements**: - -1. The module exports a constructor and state machine interface: - -```luau -local DiscoveryStateMachine = {} -DiscoveryStateMachine.__index = DiscoveryStateMachine - -export type State = "idle" | "searching" | "connecting" | "connected" | "reconnecting" - -export type Config = { - portRange: { min: number, max: number }, -- default 38740-38759 - pollIntervalMs: number, -- default 2000 - maxReconnectAttempts: number, -- default 10 - initialBackoffMs: number, -- default 1000 - maxBackoffMs: number, -- default 30000 - heartbeatIntervalMs: number, -- default 5000 -} - -export type Callbacks = { - httpGet: (url: string) -> (boolean, string?), -- success, responseBody - connectWebSocket: (url: string) -> (boolean, any?), -- success, connection - onStateChange: (oldState: State, newState: State) -> (), - onConnected: (connection: any) -> (), - onDisconnected: (reason: string?) -> (), -} - -function DiscoveryStateMachine.new(config: Config?, callbacks: Callbacks) -``` - -2. State transitions: - - `idle` -> `searching`: called via `stateMachine:start()` - - `searching` -> `connecting`: when `httpGet` succeeds on a port's `/health` endpoint - - `searching` -> `searching`: when `httpGet` fails, try next port (wraps around) - - `connecting` -> `connected`: when `connectWebSocket` succeeds - - `connecting` -> `searching`: when `connectWebSocket` fails - - `connected` -> `reconnecting`: when connection is lost (call `stateMachine:onDisconnect()`) - - `reconnecting` -> `connecting`: after backoff delay - - `reconnecting` -> `idle`: after `maxReconnectAttempts` exhausted - - Any state -> `idle`: via `stateMachine:stop()` - -3. Methods: - - `start()` -- transition from idle to searching - - `stop()` -- transition to idle from any state, cancel timers - - `onDisconnect(reason: string?)` -- signal connection lost - - `getState(): State` -- return current state - - `tick(deltaMs: number)` -- advance timers by deltaMs (for testability without real timers) - -4. The `tick` method is crucial for testability: instead of using real timers (`task.delay`, `os.clock`), the state machine tracks elapsed time internally and `tick` advances it. Tests call `tick(2000)` to simulate 2 seconds passing. - -5. Exponential backoff for reconnection: `min(initialBackoffMs * 2^attempt, maxBackoffMs)`. - -6. Port scanning: iterate ports from `portRange.min` to `portRange.max`, calling `httpGet("http://localhost:{port}/health")` for each. On success, parse the JSON response to extract the WebSocket URL. - -**Acceptance Criteria**: -- State starts as `idle`. -- `start()` transitions to `searching`. -- Mock `httpGet` returning success triggers transition to `connecting`. -- Mock `connectWebSocket` returning success triggers transition to `connected` and `onConnected` callback. -- `onDisconnect()` while `connected` transitions to `reconnecting`. -- After enough `tick()` calls with failing `connectWebSocket`, reconnect attempts exhaust and state returns to `idle`. -- `stop()` from any state transitions to `idle`. -- Backoff doubles each attempt, capped at `maxBackoffMs`. -- `lune run test/discovery.test.luau` passes all tests. - -**Do NOT**: -- Use any Roblox APIs (`HttpService`, `RunService`, `task`, `game`, etc.). -- Use real timers -- use the `tick(deltaMs)` pattern for deterministic testing. -- Import any Nevermore modules. - ---- - -## Task 0.5.3: Action Router and Message Buffer - -**Prerequisites**: Task 0.5.1 (Protocol module and test harness) must be completed first. - -**Context**: The plugin needs to route incoming action requests (execute, queryState, etc.) to the correct handler and return response messages. It also needs a ring buffer for log messages so that `queryLogs` can retrieve recent history. - -**Objective**: Create an ActionRouter for dispatching incoming messages to registered handlers, and a MessageBuffer (ring buffer) for log storage. - -**Read First**: -- `/workspaces/NevermoreEngine/studio-bridge/plans/tech-specs/04-action-specs.md` (action types and expected behavior) -- `/workspaces/NevermoreEngine/studio-bridge/plans/tech-specs/01-protocol.md` (message structure) - -**Files to Create**: -- `templates/studio-bridge-plugin/src/Shared/ActionRouter.luau` -- action dispatch module -- `templates/studio-bridge-plugin/src/Shared/MessageBuffer.luau` -- ring buffer module -- `test/actions.test.luau` -- Lune tests for both modules - -**Requirements**: - -### ActionRouter - -1. The module exports a constructor and dispatch interface: - -```luau -local ActionRouter = {} -ActionRouter.__index = ActionRouter - --- Handler receives (payload, requestId, sessionId) and returns a response payload table or nil -export type Handler = (payload: { [string]: any }, requestId: string, sessionId: string) -> { [string]: any }? - -function ActionRouter.new() - --- Register a handler for a message type -function ActionRouter:register(messageType: string, handler: Handler) - --- Dispatch an incoming message. Returns a response message table or nil. -function ActionRouter:dispatch(message: { - type: string, - sessionId: string, - payload: { [string]: any }, - requestId: string?, -}): { [string]: any }? -``` - -2. `dispatch` behavior: - - Look up a registered handler for `message.type`. - - If found, call the handler with `(message.payload, message.requestId, message.sessionId)`. - - If the handler returns a payload table, construct a response message with the appropriate response type (e.g., `queryState` -> `stateResult`), the same `sessionId` and `requestId`, and the returned payload. - - If no handler is registered, return an error response message with code `UNKNOWN_REQUEST`. - - If the handler errors (pcall fails), return an error response message with code `INTERNAL_ERROR`. - -3. Maintain a mapping of request types to response types: - -```luau -local RESPONSE_TYPES = { - execute = "scriptComplete", - queryState = "stateResult", - captureScreenshot = "screenshotResult", - queryDataModel = "dataModelResult", - queryLogs = "logsResult", - subscribe = "subscribeResult", - unsubscribe = "unsubscribeResult", -} -``` - -### MessageBuffer - -4. The module exports a ring buffer: - -```luau -local MessageBuffer = {} -MessageBuffer.__index = MessageBuffer - -function MessageBuffer.new(capacity: number) -- default 1000 - --- Add an entry to the buffer (overwrites oldest if at capacity) -function MessageBuffer:push(entry: { - level: string, -- "Print" | "Info" | "Warning" | "Error" - body: string, - timestamp: number, -}) - --- Get entries. direction: "head" (oldest first) or "tail" (newest first). count: max entries to return. -function MessageBuffer:get(direction: string?, count: number?): { - entries: { { level: string, body: string, timestamp: number } }, - total: number, - bufferCapacity: number, -} - --- Clear all entries -function MessageBuffer:clear() - --- Current number of entries -function MessageBuffer:size(): number -``` - -5. Ring buffer implementation: use a fixed-size array with a write index that wraps around. When the buffer is full, new entries overwrite the oldest. - -**Acceptance Criteria**: -- Registering a handler and dispatching a matching message calls the handler and returns a response. -- Dispatching an unknown message type returns an error response with `UNKNOWN_REQUEST`. -- Dispatching when the handler errors returns an error response with `INTERNAL_ERROR`. -- Response messages have the correct response type, sessionId, and requestId. -- MessageBuffer stores entries up to capacity. -- Pushing beyond capacity overwrites the oldest entry. -- `get("tail", 5)` returns the 5 most recent entries. -- `get("head", 5)` returns the 5 oldest entries. -- `clear()` empties the buffer. -- `lune run test/actions.test.luau` passes all tests. - -**Do NOT**: -- Use any Roblox APIs. -- Import any Nevermore modules. -- Use real timers. - ---- - -## Task 0.5.4: Lune Integration Tests - -**Prerequisites**: Tasks 0.5.1, 0.5.2, 0.5.3 (all Phase 0.5 modules) and Task 1.3a (bridge host WebSocket server) must be completed first. - -**Context**: With the Protocol, DiscoveryStateMachine, ActionRouter, and MessageBuffer modules complete, we need an end-to-end integration test that validates the full round-trip over a real WebSocket connection. - -**Objective**: Create a Lune integration test that starts a real TypeScript WebSocket server (the bridge host from Task 1.3a), connects using the Protocol module over WebSocket, and performs a full register -> welcome -> execute -> result round-trip. - -**Dependencies**: Tasks 0.5.1, 0.5.2, 0.5.3, and Task 1.3a (bridge host WebSocket server must be runnable). - -**Read First**: -- `templates/studio-bridge-plugin/src/Shared/Protocol.luau` (from Task 0.5.1) -- `templates/studio-bridge-plugin/src/Shared/DiscoveryStateMachine.luau` (from Task 0.5.2) -- `templates/studio-bridge-plugin/src/Shared/ActionRouter.luau` (from Task 0.5.3) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/server/studio-bridge-server.ts` (the bridge host server) -- `/workspaces/NevermoreEngine/studio-bridge/plans/tech-specs/01-protocol.md` (handshake flow) - -**Files to Create**: -- `test/integration.test.luau` -- Lune integration test - -**Requirements**: - -1. The test must: - - Start the TypeScript bridge host server as a subprocess (using Lune's `process.spawn` or equivalent) - - Wait for the server's health endpoint to respond - - Connect to the server via WebSocket using Lune's `net.socket` - - Send a `register` message using `Protocol.encode` - - Receive and decode the `welcome` response using `Protocol.decode` - - Validate that the welcome contains `protocolVersion` and negotiated capabilities - - Send an `execute` request (simple `print("hello")` script) - - Receive and decode the `output` and `scriptComplete` messages - - Validate the output and completion status - - Clean up: close WebSocket, stop the server subprocess - -2. Test the full message flow: - ``` - Client (Lune) Server (Node.js) - ────────────── ──────────────── - register ──────> - <────── welcome - [heartbeat] ──────> (optional, verify accepted) - execute request <────── - output ──────> - scriptComplete ──────> - ``` - -3. Use proper timeouts: fail the test if any step takes longer than 10 seconds. - -4. Clean up resources in all cases (success, failure, timeout) to avoid leaked processes. - -**Acceptance Criteria**: -- The integration test completes a full register -> welcome -> execute -> result round-trip. -- The test starts and stops the server subprocess cleanly. -- The test fails with a clear message if the server is not available or the handshake fails. -- `lune run test/integration.test.luau` passes. - -**Do NOT**: -- Skip cleanup on failure (always stop the server subprocess). -- Hard-code port numbers (discover from the server's health endpoint or use a dynamic port). -- Use any Roblox APIs. - ---- - -## Cross-References - -- Protocol spec: `studio-bridge/plans/tech-specs/01-protocol.md` -- Persistent plugin spec: `studio-bridge/plans/tech-specs/03-persistent-plugin.md` -- Action specs: `studio-bridge/plans/tech-specs/04-action-specs.md` diff --git a/studio-bridge/plans/execution/agent-prompts/01-bridge-network.md b/studio-bridge/plans/execution/agent-prompts/01-bridge-network.md deleted file mode 100644 index cd9e3f87fe..0000000000 --- a/studio-bridge/plans/execution/agent-prompts/01-bridge-network.md +++ /dev/null @@ -1,1143 +0,0 @@ -# Phase 1: Foundation (Bridge Network) -- Agent Prompts - -**Phase reference**: [studio-bridge/plans/execution/phases/01-bridge-network.md](../phases/01-bridge-network.md) -**Validation**: [studio-bridge/plans/execution/validation/01-bridge-network.md](../validation/01-bridge-network.md) - -Base path for all file references: `/workspaces/NevermoreEngine/tools/studio-bridge/` - -## How to Use These Prompts - -1. Copy the full prompt for a single task into a Claude Code sub-agent session. -2. The agent should read the "Read First" files, then implement the "Requirements" section. -3. The agent should run the acceptance criteria checks before reporting completion. -4. Do not give an agent a task whose dependencies have not been completed yet (see the dependency graph in [studio-bridge/plans/execution/phases/01-bridge-network.md](../phases/01-bridge-network.md)). - -Key conventions that apply to every prompt: - -- **TypeScript ESM** with `.js` extensions on all local imports (e.g., `import { Foo } from './foo.js';`) -- **`Async` suffix** on all async functions (e.g., `listSessionsAsync`, `resolveRequestAsync`) -- **Private `_` prefix** on all private fields and methods -- **vitest** for tests: `describe`/`it`/`expect`, test files named `*.test.ts` alongside source -- **No default exports** -- always use named exports -- **yargs `CommandModule` pattern** for CLI commands (class with `command`, `describe`, `builder`, `handler`) -- **`@quenty/` scoped packages** for workspace imports (e.g., `@quenty/cli-output-helpers`) -- **`OutputHelper`** from `@quenty/cli-output-helpers` for all user-facing output - ---- - -## Task 1.1: Protocol v2 Type Definitions - -**Prerequisites**: None (first task, no prior tasks required). - -**Context**: Studio-bridge is a WebSocket-based tool that runs Luau scripts in Roblox Studio. It uses a JSON protocol with typed messages between a Node.js server and a Roblox Studio plugin. This task extends the protocol from 6 message types to 23 message types, adding support for state queries, screenshots, DataModel inspection, log retrieval, subscriptions, heartbeats, and error reporting. - -**Objective**: Add all v2 message types, shared types, and codec functions to the existing protocol module without changing any existing type signatures or breaking existing tests. - -**Read First**: -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/server/web-socket-protocol.ts` (the file you will modify) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/server/web-socket-protocol.test.ts` (existing tests that must continue to pass) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/index.ts` (existing exports you must not break) - -**Files to Modify**: -- `src/server/web-socket-protocol.ts` -- add all new types, extend `encodeMessage`, extend `decodePluginMessage`, add `decodeServerMessage` - -**Files to Create**: -- None (but you will add new test cases to the existing test file or create a new `web-socket-protocol-v2.test.ts`) - -**Requirements**: - -1. Add these shared types as named exports: - -```typescript -export type StudioState = 'Edit' | 'Play' | 'Paused' | 'Run' | 'Server' | 'Client'; -export type SubscribableEvent = 'stateChange' | 'logPush'; - -export type Capability = - | 'execute' - | 'queryState' - | 'captureScreenshot' - | 'queryDataModel' - | 'queryLogs' - | 'subscribe' - | 'heartbeat'; - -export type ErrorCode = - | 'UNKNOWN_REQUEST' - | 'INVALID_PAYLOAD' - | 'TIMEOUT' - | 'CAPABILITY_NOT_SUPPORTED' - | 'INSTANCE_NOT_FOUND' - | 'PROPERTY_NOT_FOUND' - | 'SCREENSHOT_FAILED' - | 'SCRIPT_LOAD_ERROR' - | 'SCRIPT_RUNTIME_ERROR' - | 'BUSY' - | 'SESSION_MISMATCH' - | 'INTERNAL_ERROR'; - -export type SerializedValue = - | string - | number - | boolean - | null - | { type: 'Vector3'; value: [number, number, number] } - | { type: 'Vector2'; value: [number, number] } - | { type: 'CFrame'; value: [number, number, number, number, number, number, number, number, number, number, number, number] } - | { type: 'Color3'; value: [number, number, number] } - | { type: 'UDim2'; value: [number, number, number, number] } - | { type: 'UDim'; value: [number, number] } - | { type: 'BrickColor'; name: string; value: number } - | { type: 'EnumItem'; enum: string; name: string; value: number } - | { type: 'Instance'; className: string; path: string } - | { type: 'Unsupported'; typeName: string; toString: string }; - -export interface DataModelInstance { - name: string; - className: string; - path: string; - properties: Record; - attributes: Record; - childCount: number; - children?: DataModelInstance[]; -} -``` - -2. Add a message hierarchy using three base interfaces (not exported -- internal use for extends): - -```typescript -// All messages have type and sessionId -interface BaseMessage { - type: string; - sessionId: string; -} - -// Request/response messages require a requestId for correlation -interface RequestMessage extends BaseMessage { - requestId: string; -} - -// Push messages have no requestId (unsolicited) -interface PushMessage extends BaseMessage { - // no requestId -} -``` - -`protocolVersion` is a wire envelope field (present only on `hello`, `welcome`, `register` during handshake). It does NOT belong in the base message types. Messages that carry it declare it directly on their own interface (e.g., `RegisterMessage` has `protocolVersion: number`). - -3. Update existing message interfaces to extend the appropriate base type: - - `HelloMessage extends PushMessage` -- fire-and-forget handshake initiation - - `OutputMessage extends PushMessage` -- unsolicited log output - - `ScriptCompleteMessage extends BaseMessage` -- uses `BaseMessage` (not `RequestMessage`) because `requestId` is optional (present in v2 when the triggering `execute` had one, absent in v1) - - `WelcomeMessage extends PushMessage` -- handshake response (no requestId) - - `ExecuteMessage extends BaseMessage` -- uses `BaseMessage` (not `RequestMessage`) because `requestId` is optional (absent in v1, present in v2) - - `ShutdownMessage extends PushMessage` -- no requestId - - For v2 request/response messages, use `RequestMessage` when `requestId` is always required (e.g., `QueryStateMessage`, `StateResultMessage`, `SubscribeMessage`, etc.). Use `PushMessage` for unsolicited messages (e.g., `HeartbeatMessage`, `StateChangeMessage`). Use `BaseMessage` with `requestId?: string` for messages that bridge v1/v2 (`ScriptCompleteMessage`, `ExecuteMessage`, `PluginErrorMessage`, `ServerErrorMessage`). - -4. Add v2 Plugin-to-Server message interfaces (all exported): - - `RegisterMessage` (type: `'register'`, protocolVersion: number, payload: `{ pluginVersion: string; instanceId: string; placeName: string; placeFile?: string; state: StudioState; pid?: number; capabilities: Capability[] }`) - - `StateResultMessage` (type: `'stateResult'`, requestId: string, payload: `{ state: StudioState; placeId: number; placeName: string; gameId: number }`) - - `ScreenshotResultMessage` (type: `'screenshotResult'`, requestId: string, payload: `{ data: string; format: 'png'; width: number; height: number }`) - - `DataModelResultMessage` (type: `'dataModelResult'`, requestId: string, payload: `{ instance: DataModelInstance }`) - - `LogsResultMessage` (type: `'logsResult'`, requestId: string, payload: `{ entries: Array<{ level: OutputLevel; body: string; timestamp: number }>; total: number; bufferCapacity: number }`) - - `StateChangeMessage` (type: `'stateChange'`, payload: `{ previousState: StudioState; newState: StudioState; timestamp: number }`) - - `HeartbeatMessage` (type: `'heartbeat'`, payload: `{ uptimeMs: number; state: StudioState; pendingRequests: number }`) - - `SubscribeResultMessage` (type: `'subscribeResult'`, requestId: string, payload: `{ events: SubscribableEvent[] }`) - - `UnsubscribeResultMessage` (type: `'unsubscribeResult'`, requestId: string, payload: `{ events: SubscribableEvent[] }`) - - `PluginErrorMessage` (type: `'error'`, payload: `{ code: ErrorCode; message: string; details?: unknown }`) - -5. Update the `PluginMessage` union to include all new plugin-to-server types. - -6. Add v2 Server-to-Plugin message interfaces (all exported): - - `QueryStateMessage` (type: `'queryState'`, requestId: string, payload: `{}`) - - `CaptureScreenshotMessage` (type: `'captureScreenshot'`, requestId: string, payload: `{ format?: 'png' }`) - - `QueryDataModelMessage` (type: `'queryDataModel'`, requestId: string, payload: `{ path: string; depth?: number; properties?: string[]; includeAttributes?: boolean; find?: { name: string; recursive?: boolean }; listServices?: boolean }`) - - `QueryLogsMessage` (type: `'queryLogs'`, requestId: string, payload: `{ count?: number; direction?: 'head' | 'tail'; levels?: OutputLevel[]; includeInternal?: boolean }`) - - `SubscribeMessage` (type: `'subscribe'`, requestId: string, payload: `{ events: SubscribableEvent[] }`) - - `UnsubscribeMessage` (type: `'unsubscribe'`, requestId: string, payload: `{ events: SubscribableEvent[] }`) - - `ServerErrorMessage` (type: `'error'`, payload: `{ code: ErrorCode; message: string; details?: unknown }`) - -7. Update the `ServerMessage` union to include all new server-to-plugin types. - -8. Update `encodeMessage` to handle v2 `ServerMessage` types. Since `encodeMessage` is just `JSON.stringify(msg)`, the implementation does not change, but the type signature must accept the widened union. - -9. Extend `decodePluginMessage` with new `case` branches for every v2 plugin message type. Each branch must validate required fields and return `null` for malformed messages. Extract `requestId` and `protocolVersion` from the top-level object when present, pass them through on the returned object. For the `hello` case, also extract optional `capabilities` and `pluginVersion` from the payload. - -10. Add a new `decodeServerMessage(raw: string): ServerMessage | null` function. It mirrors `decodePluginMessage` but handles server message types. It validates `type`, `sessionId`, and `payload`, then switches on `type` with cases for all v1 and v2 server messages. - -**Code Patterns**: -- Follow the exact pattern of the existing `decodePluginMessage`: parse JSON, validate top-level shape, switch on `type`, validate payload fields, return typed object or `null`. -- Keep the `OutputLevel` type exactly as-is: `'Print' | 'Info' | 'Warning' | 'Error'`. -- The `encodeMessage` function is currently `JSON.stringify`. Keep it that way -- the type widening is what matters. - -**Acceptance Criteria**: -- All existing exports (`HelloMessage`, `OutputMessage`, `ScriptCompleteMessage`, `WelcomeMessage`, `ExecuteMessage`, `ShutdownMessage`, `PluginMessage`, `ServerMessage`, `OutputLevel`, `encodeMessage`, `decodePluginMessage`) exist with compatible signatures. -- All new types listed above are exported. -- `decodePluginMessage` correctly decodes all v2 plugin messages and returns `null` for unknown/malformed ones. -- `decodeServerMessage` correctly decodes all v1 and v2 server messages and returns `null` for unknown/malformed ones. -- Run `npx vitest run src/server/web-socket-protocol.test.ts` from `tools/studio-bridge/` -- all existing tests pass. -- Write new tests covering encode/decode round-trips for every v2 message type (at least one test per type). - -**Do NOT**: -- Remove or rename any existing type or function. -- Change the shape of any existing message type in a breaking way (adding optional fields is fine). -- Use default exports. -- Forget `.js` extension on local imports. - ---- - -## Task 1.2: Request/Response Correlation Layer - -**Prerequisites**: None (independent of other tasks). - -**Context**: Studio-bridge is being extended to support concurrent request/response operations over WebSocket. The server needs to track in-flight requests by a unique `requestId`, enforce per-request timeouts, and resolve/reject promises when responses arrive. This utility is standalone -- it has no dependency on WebSocket or the server. - -**Objective**: Implement a `PendingRequestMap` class that tracks pending requests by ID with timeout enforcement. - -**Read First**: -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/server/web-socket-protocol.ts` (for context on how requestIds are used, but you do not import from it) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/server/studio-bridge-server.ts` (for context on the server patterns, naming conventions, private `_` prefix) - -**Files to Create**: -- `src/server/pending-request-map.ts` -- the `PendingRequestMap` class -- `src/server/pending-request-map.test.ts` -- vitest tests - -**Requirements**: - -1. Implement `PendingRequestMap` with a generic type parameter `T` for the response type: - -```typescript -export class PendingRequestMap { - /** - * Register a new pending request. Returns a promise that resolves when - * resolveRequest is called with the same ID, or rejects on timeout. - */ - addRequestAsync(requestId: string, timeoutMs: number): Promise; - - /** - * Resolve a pending request with a result. No-op if the ID is not found - * (e.g., already timed out or resolved). - */ - resolveRequest(requestId: string, result: T): void; - - /** - * Reject a pending request with an error. No-op if the ID is not found. - */ - rejectRequest(requestId: string, error: Error): void; - - /** - * Reject all pending requests (used during shutdown). - */ - cancelAll(reason?: string): void; - - /** - * Number of currently pending requests. - */ - get pendingCount(): number; - - /** - * Whether a request with the given ID is currently pending. - */ - hasPendingRequest(requestId: string): boolean; -} -``` - -2. Internally, store a `Map`. When `addRequestAsync` is called, create a promise and store the resolve/reject callbacks along with a `setTimeout` handle. - -3. On timeout, reject the promise with an `Error` whose message includes the requestId and timeout duration, and remove the entry from the map. - -4. `cancelAll` iterates all entries, rejects each with a cancellation error, clears all timers, and empties the map. - -5. If `addRequestAsync` is called with a requestId that is already pending, reject the new promise immediately with a duplicate ID error. Do not disturb the existing pending request. - -6. `resolveRequest` and `rejectRequest` for unknown IDs are silent no-ops (do not throw). - -**Code Patterns**: -- Use the `Async` suffix on the async method: `addRequestAsync`. -- Use `_` prefix for private fields: `private _pending: Map<...>`. -- Use `clearTimeout` when resolving/rejecting to prevent timer leaks. - -**Acceptance Criteria**: -- `addRequestAsync` returns a promise that resolves when `resolveRequest` is called with matching ID. -- `addRequestAsync` returns a promise that rejects when `rejectRequest` is called with matching ID. -- Promise rejects with timeout error after `timeoutMs` if neither resolve nor reject is called. -- `cancelAll` rejects all pending promises and clears the map. -- Calling `resolveRequest` for an unknown ID does not throw. -- Calling `rejectRequest` for an unknown ID does not throw. -- Duplicate `addRequestAsync` with same ID rejects the new one immediately. -- `pendingCount` returns the correct count. -- After resolve/reject/timeout, `hasPendingRequest` returns false. -- Run `npx vitest run src/server/pending-request-map.test.ts` from `tools/studio-bridge/` -- all tests pass. - -**Do NOT**: -- Import from any other source file in this project (this is standalone). -- Use default exports. -- Forget to clear timers on resolve/reject/cancel to avoid Node.js timer leaks in tests. - ---- - -## Task 1.3d1: BridgeConnection.connectAsync() and Role Detection - -**Prerequisites**: Tasks 1.3a (transport + host), 1.3b (session tracker), and 1.3c (bridge client) must be completed first. - -**Context**: Studio-bridge uses a bridge network layer where the first CLI process to start becomes the "host" (binds a port, accepts WebSocket connections from plugins and other CLI processes) and subsequent processes become "clients" (connect to the host via WebSocket). This task builds the core `BridgeConnection` class and the environment detection module that determines which role to take. - -**Objective**: Implement `BridgeConnection` with `connectAsync(options?)`, `disconnectAsync()`, role detection, and the environment detection module. This is the foundational class that all other 1.3d subtasks build on. - -**Read First**: -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/bridge/internal/transport-server.ts` (host transport from Task 1.3a) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/bridge/internal/bridge-host.ts` (bridge host from Task 1.3a) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/bridge/internal/bridge-client.ts` (bridge client from Task 1.3c) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/bridge/internal/session-tracker.ts` (session tracker from Task 1.3b) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/bridge/internal/health-endpoint.ts` (health endpoint from Task 1.3a) -- `/workspaces/NevermoreEngine/studio-bridge/plans/tech-specs/07-bridge-network.md` sections 2.1-2.2 (public API spec) - -**Files to Create**: -- `src/bridge/bridge-connection.ts` -- `BridgeConnection` class -- `src/bridge/internal/environment-detection.ts` -- role detection utility -- `src/bridge/bridge-connection.test.ts` -- `src/bridge/internal/environment-detection.test.ts` - -**Requirements**: - -1. Implement `BridgeConnection` class: - -```typescript -export interface BridgeConnectionOptions { - port?: number; // Default: 38741 - timeoutMs?: number; // Default: 30_000 - keepAlive?: boolean; // Default: false - remoteHost?: string; // Skip local bind, connect directly -} - -export class BridgeConnection { - // Private constructor -- use connectAsync() - private constructor(...); - - static async connectAsync(options?: BridgeConnectionOptions): Promise; - async disconnectAsync(): Promise; - - get role(): 'host' | 'client'; - get isConnected(): boolean; -} -``` - -2. Implement `environment-detection.ts`: - -```typescript -export type DetectedRole = 'host' | 'client'; - -/** - * Detect whether this process should be the bridge host or a client. - * Algorithm: - * 1. If remoteHost is specified -> client - * 2. Try to bind port -> host - * 3. EADDRINUSE -> check health endpoint - * a. Health check succeeds -> client (host is alive) - * b. Health check fails -> wait, retry bind (stale host in TIME_WAIT) - */ -export async function detectRoleAsync(options: { - port: number; - remoteHost?: string; -}): Promise<{ role: DetectedRole; /* ... */ }>; -``` - -3. In `connectAsync`: - - Call `detectRoleAsync` to determine role. - - If host: create `TransportServer` and `BridgeHost`, start listening. - - If client: create `TransportClient` and `BridgeClient`, connect to host. - - Store role and internal components on private fields. - - Set up idle exit behavior: if `keepAlive` is false, start a 5-second grace timer when no clients and no pending commands. - -4. In `disconnectAsync`: - - If host: trigger hand-off protocol (or clean shutdown if no clients). - - If client: close WebSocket connection. - -**Code Patterns**: -- Private `_` prefix on all private fields. -- `Async` suffix on async methods. -- `.js` extension on all local imports. -- No default exports. - -**Acceptance Criteria**: -- `connectAsync()` on unused port: `role === 'host'`, `isConnected === true`. -- Two concurrent `connectAsync()` on same port: first is host, second is client. -- `disconnectAsync()` sets `isConnected === false`. -- Environment detection: `EADDRINUSE` -> client. Bind success -> host. Stale host -> retry bind. -- `remoteHost` option -> always client. -- Unit tests use configurable port (pass `port: 0` or ephemeral port) to avoid conflicts. -- Run `npx vitest run src/bridge/bridge-connection.test.ts` -- all tests pass. -- Run `npx vitest run src/bridge/internal/environment-detection.test.ts` -- all tests pass. - -**Do NOT**: -- Add session query methods yet (those are Task 1.3d2). -- Add `resolveSession` yet (Task 1.3d3). -- Add `waitForSession` or events yet (Task 1.3d4). -- Create the barrel export `index.ts` yet (Task 1.3d5). -- Use default exports. -- Forget `.js` extensions on local imports. - ---- - -## Task 1.3d2: BridgeConnection.listSessions() and listInstances() - -**Prerequisites**: Task 1.3d1 must be completed first. - -**Context**: `BridgeConnection` (from Task 1.3d1) needs session query methods so that CLI commands and other consumers can discover which Studio sessions are connected. As host, these methods query the local `SessionTracker` directly. As client, they send a `listSessions` envelope through the bridge host. - -**Objective**: Add `listSessions()` and `listInstances()` methods to the existing `BridgeConnection` class. - -**Read First**: -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/bridge/bridge-connection.ts` (from Task 1.3d1 -- the file you will modify) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/bridge/internal/session-tracker.ts` (session tracker from Task 1.3b) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/bridge/internal/bridge-client.ts` (for client-side forwarding) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/bridge/types.ts` (SessionInfo, InstanceInfo types from Task 1.3b) -- `/workspaces/NevermoreEngine/studio-bridge/plans/tech-specs/07-bridge-network.md` section 2.1 (API spec) - -**Files to Modify**: -- `src/bridge/bridge-connection.ts` -- add `listSessions()` and `listInstances()` methods -- `src/bridge/bridge-connection.test.ts` -- add tests - -**Requirements**: - -1. Add `listSessions()` to `BridgeConnection`: - -```typescript -/** List all currently connected Studio sessions (across all instances and contexts). */ -listSessions(): SessionInfo[] { - if (this._role === 'host') { - return this._sessionTracker.listSessions(); - } - // Client path: delegate to bridge client which sends listSessions envelope - return this._bridgeClient.listSessions(); -} -``` - -2. Add `listInstances()` to `BridgeConnection`: - -```typescript -/** - * List unique Studio instances. Each instance groups 1-3 context sessions - * (edit, client, server) that share the same instanceId. - */ -listInstances(): InstanceInfo[] { - if (this._role === 'host') { - return this._sessionTracker.listInstances(); - } - return this._bridgeClient.listInstances(); -} -``` - -3. Add `getSession(sessionId)` to return a `BridgeSession` or `undefined`. - -**Acceptance Criteria**: -- Host mode: `listSessions()` returns sessions from the local session tracker. -- Host mode: `listInstances()` groups sessions by `instanceId`. -- Client mode: `listSessions()` and `listInstances()` forward through the bridge client and return correct results. -- `getSession(id)` returns `BridgeSession` or `undefined`. -- Run `npx vitest run src/bridge/bridge-connection.test.ts` -- all tests pass. - -**Do NOT**: -- Add `resolveSession` (Task 1.3d3). -- Add `waitForSession` or events (Task 1.3d4). -- Create the barrel export (Task 1.3d5). -- Use default exports. -- Forget `.js` extensions on local imports. - ---- - -## Task 1.3d3: BridgeConnection.resolveSession() - -**Prerequisites**: Task 1.3d2 must be completed first (provides `listSessions()` and `listInstances()`). - -**Context**: CLI commands need to resolve which session to target. The resolution algorithm is instance-aware: it groups sessions by `instanceId`, auto-selects when unambiguous, and throws descriptive errors when disambiguation is needed. - -**Objective**: Add `resolveSession()` to the existing `BridgeConnection` class. - -**Read First**: -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/bridge/bridge-connection.ts` (from Tasks 1.3d1-1.3d2 -- the file you will modify) -- `/workspaces/NevermoreEngine/studio-bridge/plans/tech-specs/07-bridge-network.md` section 2.1 (resolution algorithm specification) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/bridge/types.ts` (SessionContext type) - -**Files to Modify**: -- `src/bridge/bridge-connection.ts` -- add `resolveSession()` method -- `src/bridge/bridge-connection.test.ts` -- add tests - -**Requirements**: - -1. Implement `resolveSession()`: - -```typescript -/** - * Resolve a session for command execution. Instance-aware. - * - * Algorithm (full pseudocode -- implement exactly): - * - * 1. If sessionId provided: - * a. Look up session by sessionId in the session tracker. - * b. If found, return it. - * c. If not found, throw SessionNotFoundError("Session '' not found"). - * - * 2. If instanceId provided: - * a. Filter sessions to those with matching instanceId. - * b. If 0 matches, throw SessionNotFoundError("No sessions for instance ''"). - * c. If context also provided, filter to matching context. - * - If match found, return it. - * - If not, throw ContextNotFoundError("Context '' not connected on instance ''"). - * d. If no context provided: - * - If 1 session, return it. - * - If N sessions, return Edit context (default for Play mode). - * - * 3. Collect unique instances (group sessions by instanceId). - * - * 4. If 0 instances: - * throw SessionNotFoundError("No sessions connected"). - * - * 5. If 1 instance: - * a. If context provided, return matching context session. - * Throw ContextNotFoundError if not found. - * b. If 1 context session on the instance, return it. - * c. If N context sessions (Play mode: edit + client + server), - * return Edit context by default. - * Rationale: in Play mode, the Edit context is the most broadly - * useful target (it can see the full DataModel including - * ReplicatedStorage, ServerStorage, etc.). - * - * 6. If N instances: - * throw SessionNotFoundError( - * "Multiple instances connected: []. Use --session or --instance to select one." - * ). - * - * Error types used: - * - SessionNotFoundError: no session matches the criteria - * - ContextNotFoundError: instance found but requested context is not connected - * - ActionTimeoutError: (not used here, but defined for completeness) - */ -async resolveSession( - sessionId?: string, - context?: SessionContext, - instanceId?: string -): Promise; -``` - -2. Error messages must be descriptive: - - 0 sessions: "No sessions connected" - - N instances without disambiguation: "Multiple instances connected: [list]. Use --session or --instance to select one." - - Unknown sessionId: "Session 'abc' not found" - - Context not found on instance: "Context 'server' not connected on instance 'inst-1'" - -**Acceptance Criteria**: -- 0 sessions -> throws with "No sessions connected". -- 1 session -> returns it automatically. -- N sessions from different instances -> throws with instance list. -- Explicit `sessionId` -> returns that session. Unknown -> throws. -- 1 instance with 3 contexts, no context arg -> returns Edit. -- 1 instance with 3 contexts, `context: 'server'` -> returns server. -- `instanceId` + `context` -> returns matching session. -- Run `npx vitest run src/bridge/bridge-connection.test.ts` -- all tests pass. - -**Do NOT**: -- Add `waitForSession` or events (Task 1.3d4). -- Create the barrel export (Task 1.3d5). -- Use default exports. -- Forget `.js` extensions on local imports. - ---- - -## Task 1.3d4: BridgeConnection.waitForSession() and Events - -**Prerequisites**: Task 1.3d3 must be completed first. - -**Context**: Commands like `exec` and `run` need to wait for a Studio plugin to connect before executing. The `waitForSession` method provides an async wait with timeout. Session lifecycle events allow consumers to react to sessions connecting and disconnecting. - -**Objective**: Add `waitForSession()` and session lifecycle events to the existing `BridgeConnection` class. - -**Read First**: -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/bridge/bridge-connection.ts` (from Tasks 1.3d1-1.3d3 -- the file you will modify) -- `/workspaces/NevermoreEngine/studio-bridge/plans/tech-specs/07-bridge-network.md` section 2.1 (event interface specification) - -**Files to Modify**: -- `src/bridge/bridge-connection.ts` -- add `waitForSession()`, wire events -- `src/bridge/bridge-connection.test.ts` -- add tests - -**Requirements**: - -1. Add `waitForSession()`: - -```typescript -/** - * Wait for at least one session to connect. - * Resolves with the first session that connects (or the first session - * if one is already connected). Rejects after timeout. - */ -async waitForSession(timeout?: number): Promise; -``` - -2. Wire session lifecycle events on `BridgeConnection` (extends `EventEmitter` or uses a typed event pattern): - -```typescript -on(event: 'session-connected', listener: (session: BridgeSession) => void): this; -on(event: 'session-disconnected', listener: (sessionId: string) => void): this; -on(event: 'instance-connected', listener: (instance: InstanceInfo) => void): this; -on(event: 'instance-disconnected', listener: (instanceId: string) => void): this; -on(event: 'error', listener: (error: Error) => void): this; -``` - -3. Implementation of `waitForSession`: - - Check if any sessions are already connected. If so, resolve immediately. - - Otherwise, listen for the `session-connected` event and resolve when it fires. - - Set a timeout that rejects with a descriptive error if no session connects in time. - - Clean up event listeners on resolve or reject. - -**Acceptance Criteria**: -- `waitForSession()` called before plugin connects -> resolves when plugin connects. -- `waitForSession()` called when sessions exist -> resolves immediately. -- `waitForSession(500)` with no plugin -> rejects after ~500ms with timeout error. -- `session-connected` event fires when a plugin registers. -- `session-disconnected` event fires when a plugin disconnects. -- `instance-connected` event fires when the first context of a new instance connects. -- `instance-disconnected` event fires when the last context of an instance disconnects. -- Run `npx vitest run src/bridge/bridge-connection.test.ts` -- all tests pass. - -**Do NOT**: -- Create the barrel export (Task 1.3d5). -- Use default exports. -- Forget `.js` extensions on local imports. - ---- - -## Task 1.3: In-Memory Session Tracking via BridgeConnection - -> **SUPERSEDED**: This task has been decomposed into Tasks 1.3a (transport + host), 1.3b (session tracker), 1.3c (bridge client), and 1.3d1-1.3d5 (BridgeConnection integration). Do NOT implement this task directly -- implement the subtasks instead. The subtask prompts above (1.3d1, 1.3d2, 1.3d3, 1.3d4) contain the authoritative requirements. This section is retained only for historical context. - ---- - -## Task 1.4: Integrate Session Tracking into StudioBridgeServer - -**Prerequisites**: Task 1.3d5 (barrel export and API surface review) must be completed first. - -**Context**: Studio-bridge's `StudioBridgeServer` class manages the WebSocket server lifecycle. This task adds in-memory session tracking so that connected plugins are discoverable by CLI processes via the bridge host. - -**Objective**: Modify `StudioBridgeServer` to track sessions in-memory when plugins connect and untrack them when plugins disconnect. - -**Read First**: -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/server/studio-bridge-server.ts` (the file you will modify) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/server/bridge-connection.ts` (the BridgeConnection with in-memory session tracking -- must be completed first) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/registry/types.ts` (SessionInfo types) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/index.ts` (exports to update) - -**Files to Modify**: -- `src/server/studio-bridge-server.ts` -- add session tracking via BridgeConnection when plugins connect/disconnect -- `src/index.ts` -- add exports for BridgeConnection and registry types - -**Requirements**: - -1. Import `BridgeConnection` from `./bridge-connection.js` and `SessionInfo` from `../registry/index.js`. - -2. When a plugin connects and sends a `register` message, create a `SessionInfo` entry in the bridge host's in-memory session map: - -```typescript -const sessionInfo: SessionInfo = { - sessionId: this._sessionId, - placeName: registerPayload.placeName, - placeFile: registerPayload.placeFile, - state: 'starting', - pluginVersion: registerPayload.pluginVersion, - capabilities: registerPayload.capabilities, - connectedAt: new Date().toISOString(), - origin: this._origin ?? 'user', -}; -this._bridgeConnection.addSession(sessionInfo); -``` - -3. Update the session state at key lifecycle points: - - After handshake completes: update session state to `'ready'` - - When executing: update session state to `'executing'` - - After execution: update session state to `'ready'` - -4. When the plugin's WebSocket closes, remove the session from the in-memory map. Sessions exist only while plugins are connected; no stale detection needed. - -5. In `src/index.ts`, add: - -```typescript -export { BridgeConnection } from './server/bridge-connection.js'; -export type { SessionInfo, SessionEvent, SessionOrigin, Disposable } from './registry/index.js'; -``` - -**Acceptance Criteria**: -- After a plugin connects and registers, `listSessionsAsync()` includes the session. -- After a plugin disconnects, `listSessionsAsync()` no longer includes the session. -- Session state is updated at lifecycle transitions. -- `SessionInfo` includes the `origin` field (`'user' | 'managed'`). -- Existing tests in `studio-bridge-server.test.ts` (if any) pass without modification. -- The `index.ts` exports `BridgeConnection` and registry types. - -**Do NOT**: -- Use any file-based session tracking (no session files, no lock files, no PID files). -- Change any existing method signatures on `StudioBridgeServer`. -- Change the constructor signature (no new required options). -- Use default exports. -- Forget `.js` extensions on local imports. - ---- - -## Task 1.5: v2 Handshake Support in StudioBridgeServer - -**Prerequisites**: Task 1.1 (protocol v2 type definitions) must be completed first. - -**Context**: Studio-bridge's WebSocket server currently handles only v1 `hello`/`welcome` handshakes. The persistent plugin will use v2 handshakes with `protocolVersion`, `capabilities`, and optionally `register` messages. The server must detect the protocol version and negotiate capabilities while keeping v1 plugins working unchanged. - -**Objective**: Update the server's handshake handler to support v2 plugins via `hello` with `protocolVersion`/`capabilities` and `register` messages, while preserving v1 behavior. - -**Read First**: -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/server/studio-bridge-server.ts` (the file you will modify -- focus on `_waitForHandshakeAsync`) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/server/web-socket-protocol.ts` (must already contain v2 types from Task 1.1) - -**Files to Modify**: -- `src/server/studio-bridge-server.ts` -- update `_waitForHandshakeAsync` method, add private fields for connection metadata - -**Requirements**: - -1. Add private fields to `StudioBridgeServer`: - -```typescript -private _negotiatedProtocolVersion: number = 1; -private _negotiatedCapabilities: Capability[] = ['execute']; -private _lastHeartbeatTimestamp: number | undefined; -``` - -2. Import the new types: `Capability`, `RegisterMessage`, `HeartbeatMessage` (and others needed) from `./web-socket-protocol.js`. - -3. In `_waitForHandshakeAsync`, update the `onMessage` handler to accept both `hello` and `register` messages: - - If `msg.type === 'hello'`: - - Check for `msg.protocolVersion`. If present and >= 2, this is a v2 hello. - - Extract `capabilities` from `msg.payload.capabilities` (default to `['execute']` if absent). - - Negotiate: `_negotiatedProtocolVersion = Math.min(msg.protocolVersion ?? 1, 2)`. - - Negotiate capabilities: `_negotiatedCapabilities` = intersection of plugin's capabilities and server's supported set (`['execute', 'queryState', 'captureScreenshot', 'queryDataModel', 'queryLogs', 'subscribe']`). - - Send welcome: if v2, include `protocolVersion` and `capabilities` in the welcome. If v1, send the existing v1 welcome (no protocolVersion, no capabilities). - - If `msg.type === 'register'`: - - This is always v2. Extract all fields from the register payload. - - Negotiate protocol version and capabilities same as above. - - Send a v2 welcome with protocolVersion and capabilities. - - Store the extra metadata (pluginVersion, instanceId, placeName, etc.) on private fields if useful for logging. - -4. After handshake, set up a listener for `heartbeat` messages on the connected WebSocket: - - When a `heartbeat` message is received, update `_lastHeartbeatTimestamp = Date.now()`. - - Do not send a response to heartbeats (the server is silent). - - Log heartbeat receipt at verbose level. - -5. Add public getters: - -```typescript -get protocolVersion(): number { return this._negotiatedProtocolVersion; } -get capabilities(): readonly Capability[] { return this._negotiatedCapabilities; } -``` - -**Acceptance Criteria**: -- A v1 plugin sending `hello` without `protocolVersion` receives a v1-style `welcome` (no `protocolVersion`, no `capabilities` in payload). -- A v2 plugin sending `hello` with `protocolVersion: 2` and `capabilities: [...]` receives a v2-style `welcome` with `protocolVersion: 2` and the negotiated capabilities. -- A v2 plugin sending `register` with full metadata receives a v2-style `welcome`. -- `protocolVersion` getter returns the negotiated version after handshake. -- `capabilities` getter returns the negotiated capabilities after handshake. -- Heartbeat messages are accepted silently (no error, no response). -- Existing v1 handshake behavior is unchanged. - -**Do NOT**: -- Change the `startAsync`, `executeAsync`, or `stopAsync` method signatures. -- Remove or rename any existing public API. -- Use default exports. -- Forget `.js` extensions on local imports. - ---- - -## Task 1.6: Action Dispatch on the Server - -**Prerequisites**: Tasks 1.1 (protocol v2 types), 1.2 (PendingRequestMap), and 1.5 (v2 handshake support) must be completed first. - -**Context**: Studio-bridge's server needs to send typed request messages to the plugin and wait for correlated responses. The `PendingRequestMap` (Task 1.2) handles timeout/correlation mechanics. This task builds the dispatch layer that connects the WebSocket message flow to the pending request map. - -**Objective**: Add an `ActionDispatcher` class and wire it into `StudioBridgeServer` so the server can perform v2 actions (queryState, captureScreenshot, etc.) and receive typed responses. - -**Read First**: -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/server/studio-bridge-server.ts` (the server you will modify) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/server/pending-request-map.ts` (the correlation utility from Task 1.2) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/server/web-socket-protocol.ts` (v2 types from Task 1.1) - -**Files to Create**: -- `src/server/action-dispatcher.ts` -- `ActionDispatcher` class -- `src/server/action-dispatcher.test.ts` -- tests - -**Files to Modify**: -- `src/server/studio-bridge-server.ts` -- add `performActionAsync` method, wire incoming messages to the dispatcher - -**Requirements**: - -1. Implement `ActionDispatcher` in `src/server/action-dispatcher.ts`: - -```typescript -import { randomUUID } from 'crypto'; -import { PendingRequestMap } from './pending-request-map.js'; -import type { ServerMessage, PluginMessage } from './web-socket-protocol.js'; - -/** Default timeouts per action type (milliseconds) */ -const ACTION_TIMEOUTS: Record = { - queryState: 5_000, - captureScreenshot: 15_000, - queryDataModel: 10_000, - queryLogs: 10_000, - execute: 120_000, - subscribe: 5_000, - unsubscribe: 5_000, -}; - -export class ActionDispatcher { - private _pendingRequests = new PendingRequestMap(); - - /** - * Generate a requestId and register the pending request. - * Returns { requestId, promise } where promise resolves with the response message. - */ - createRequestAsync( - actionType: string, - timeoutMs?: number - ): { requestId: string; responsePromise: Promise }; - - /** - * Route an incoming plugin message to the correct pending request. - * Returns true if the message was consumed (matched a pending requestId). - */ - handleResponse(message: PluginMessage): boolean; - - /** Cancel all pending requests (called on shutdown/disconnect) */ - cancelAll(reason?: string): void; - - /** Number of in-flight requests */ - get pendingCount(): number; -} -``` - -2. `createRequestAsync` implementation: - - Generate a `requestId` using `randomUUID()`. - - Look up timeout from `ACTION_TIMEOUTS[actionType]`, override with `timeoutMs` if provided. - - Call `this._pendingRequests.addRequestAsync(requestId, timeout)` to get the response promise. - - Return `{ requestId, responsePromise }`. - -3. `handleResponse` implementation: - - Check if `message.requestId` exists and is a string. - - If so, check if `_pendingRequests.hasPendingRequest(message.requestId)`. - - If message type is `'error'`, call `rejectRequest` with an error constructed from the error payload. - - Otherwise, call `resolveRequest` with the message. - - Return `true` if consumed, `false` if no matching pending request. - -4. In `StudioBridgeServer`, add: - - A private `_actionDispatcher = new ActionDispatcher()` field. - - A public `performActionAsync(message: Omit, timeoutMs?: number): Promise` method: - - Throws if `_negotiatedProtocolVersion < 2` with message "Plugin does not support v2 actions". - - Throws if the action type requires a capability not in `_negotiatedCapabilities` with message "Plugin does not support capability: X". - - Calls `_actionDispatcher.createRequestAsync(message.type, timeoutMs)`. - - Sends the message with the generated `requestId` via `encodeMessage` and `ws.send`. - - Returns the response promise cast to `T`. - - In the connected WebSocket's message handler (after handshake), route received messages through `_actionDispatcher.handleResponse(msg)` before any other handling. - - In `_cleanupResourcesAsync`, call `_actionDispatcher.cancelAll()`. - -5. The existing `executeAsync` method continues to work unchanged via the v1 path. It does not use the action dispatcher. - -**Acceptance Criteria**: -- `performActionAsync` sends a v2 message with a `requestId` and resolves when the plugin responds. -- `performActionAsync` rejects on timeout. -- `performActionAsync` rejects with structured error if plugin sends an `error` message with matching `requestId`. -- `performActionAsync` throws immediately if `protocolVersion < 2`. -- `performActionAsync` throws immediately if the required capability is not negotiated. -- `cancelAll` rejects all pending requests. -- Existing `executeAsync` works unchanged. -- Unit tests for `ActionDispatcher` cover: happy path, timeout, error response, cancel, unknown message. - -**Do NOT**: -- Modify the existing `executeAsync` method to use the action dispatcher (keep the v1 path). -- Change any existing public API signatures. -- Use default exports. -- Forget `.js` extensions on local imports. - ---- - -## Task 1.7a: Shared CLI Utilities - -**Prerequisites**: Tasks 1.3d5 (BridgeConnection barrel export) and Phase 0 Task 0.4 (output mode selector) must be completed first. - -**Context**: Every CLI command in studio-bridge needs to resolve which session to target, format output for different modes (text, JSON, CI), and follow a consistent handler pattern. This task creates the shared utilities that all commands will import. - -**Objective**: Create three small utility modules that establish the shared patterns for CLI commands: session resolution, output formatting, and the command handler type. - -**Read First**: -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/server/bridge-connection.ts` (the `BridgeConnection` API with session resolution) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/cli/commands/exec-command.ts` (existing CLI command pattern) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/cli/args/global-args.ts` (global args interface) - -**Files to Create**: -- `src/cli/resolve-session.ts` -- instance-aware session resolution -- `src/cli/format-output.ts` -- output mode selection -- `src/cli/types.ts` -- minimal handler type - -**Requirements**: - -1. Implement `src/cli/resolve-session.ts`: - -```typescript -import type { BridgeConnection } from '../server/bridge-connection.js'; -import type { SessionInfo } from '../registry/types.js'; - -export interface ResolveSessionOptions { - sessionId?: string; - instanceId?: string; - context?: string; -} - -/** - * Resolve which session to target based on CLI args. - * - If sessionId is provided, look it up directly. - * - If instanceId is provided, find sessions for that instance, optionally filtered by context. - * - If nothing is provided, use the sole session or throw if ambiguous. - */ -export async function resolveSessionAsync( - connection: BridgeConnection, - options: ResolveSessionOptions -): Promise { - // Implementation: - // 1. If options.sessionId, call connection.getSession(sessionId). Throw if not found. - // 2. Otherwise, call connection.listSessionsAsync(). - // 3. If options.instanceId, filter by instanceId. If options.context, further filter. - // 4. If exactly one result, return it. - // 5. If zero results, throw with "No matching sessions found". - // 6. If multiple results, throw with "Multiple sessions found, use --session or --instance to disambiguate". -} -``` - -2. Implement `src/cli/format-output.ts`: - -```typescript -import { resolveOutputMode, formatTable, formatJson } from '@quenty/cli-output-helpers/output-modes'; - -export interface FormatOptions { - json?: boolean; -} - -/** - * Format data for output based on the resolved output mode. - * If --json is set, outputs JSON. Otherwise outputs a formatted table. - */ -export function formatOutput(data: unknown, options: FormatOptions): string { - const mode = resolveOutputMode(options); - if (mode === 'json') { - return formatJson(data); - } - return formatTable(data); -} -``` - -Note: If `@quenty/cli-output-helpers/output-modes` does not exist yet (it is a Phase 0 deliverable), create a minimal placeholder that: -- `resolveOutputMode` returns `'json'` if `options.json` is true, `'text'` otherwise -- `formatJson` returns `JSON.stringify(data, null, 2)` -- `formatTable` returns a simple columnar string representation - -3. Implement `src/cli/types.ts`: - -```typescript -import type { BridgeConnection } from '../server/bridge-connection.js'; - -export interface CommandResult { - data: unknown; - summary: string; -} - -export type CommandHandler = ( - connection: BridgeConnection, - options: Record -) => Promise; -``` - -**Acceptance Criteria**: -- `resolveSessionAsync` resolves a session by ID when provided. -- `resolveSessionAsync` returns the sole session when no filters are provided and exactly one session exists. -- `resolveSessionAsync` throws a descriptive error when no sessions match. -- `resolveSessionAsync` throws a descriptive error when multiple sessions match without disambiguation. -- `formatOutput` returns JSON when `json: true` is set. -- `formatOutput` returns a text table when `json` is not set. -- The `CommandHandler` type compiles correctly. -- Total across all three files is approximately 80 LOC. - -**Do NOT**: -- Add any npm dependencies beyond workspace packages. -- Use default exports. -- Forget `.js` extensions on local imports. - ---- - -## Task 1.7b: Reference `sessions` Command + Barrel Export Pattern - -**Prerequisites**: Task 1.7a (shared CLI utilities) must be completed first. - -**Context**: The `sessions` command is the simplest command in studio-bridge and serves as THE reference pattern that all future commands will copy. Getting this pattern right is critical because Tasks 3.1-3.5 all replicate it. - -This task also establishes the **barrel export pattern** for command registration. Seven tasks (1.7b, 2.4, 2.6, 3.1, 3.2, 3.3, 3.4) all need to register commands. If each task modifies `cli.ts` directly, parallel worktrees will produce merge conflicts at the same lines. Instead, `cli.ts` imports `allCommands` from `src/commands/index.ts` and registers them in a loop. Each subsequent task only adds an export line to the barrel file (append-only, auto-mergeable). `cli.ts` never changes again for command registration. - -**Objective**: Implement the `sessions` command as a handler + CLI wiring pair using the shared utilities from Task 1.7a, create the `src/commands/index.ts` barrel file with the `allCommands` array, and update `cli.ts` to register commands via a loop over `allCommands`. - -**Dependencies**: Task 1.3 (BridgeConnection with session tracking), Task 1.7a (shared CLI utilities). - -**Read First**: -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/cli/resolve-session.ts` (from Task 1.7a) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/cli/format-output.ts` (from Task 1.7a) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/cli/types.ts` (from Task 1.7a) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/server/bridge-connection.ts` (session listing API) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/registry/types.ts` (SessionInfo type) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/cli/commands/exec-command.ts` (yargs pattern reference) -- `/workspaces/NevermoreEngine/studio-bridge/plans/tech-specs/02-command-system.md` sections 3.1-3.4 (barrel export pattern design) - -**Files to Create**: -- `src/commands/sessions.ts` -- the command handler (pure logic, no CLI framework) -- `src/commands/index.ts` -- barrel file exporting all commands and the `allCommands` array -- `src/cli/commands/sessions-command.ts` -- yargs CLI wiring - -**Files to Modify**: -- `src/cli/cli.ts` -- replace per-command `.command()` registration with a loop over `allCommands`. This is the LAST time `cli.ts` is modified for command registration. - -**Requirements**: - -1. Implement `src/commands/sessions.ts` (the handler): - -```typescript -import type { BridgeConnection } from '../server/bridge-connection.js'; -import type { SessionInfo } from '../registry/types.js'; -import type { CommandResult } from '../cli/types.js'; - -export interface SessionsOptions { - json?: boolean; - watch?: boolean; -} - -export async function listSessionsAsync( - connection: BridgeConnection, - options: SessionsOptions = {} -): Promise { - const sessions = await connection.listSessionsAsync(); - - if (sessions.length === 0) { - return { - data: [], - summary: 'No active sessions. Is Studio running with the studio-bridge plugin?', - }; - } - - return { - data: sessions, - summary: `${sessions.length} session(s) connected.`, - }; -} -``` - -2. Create `src/commands/index.ts` (the barrel file and command registry): - -```typescript -// src/commands/index.ts -- THE command registry -// Every command is imported and re-exported here. -// This is the single source of truth for all available commands. -// -// Adding a command = adding one export line here + one file in this directory. -// cli.ts, terminal-mode.ts, and mcp-server.ts all loop over allCommands. -// They NEVER import individual command files. They NEVER change when commands -// are added. - -export { sessionsCommand } from './sessions.js'; - -// Future commands will be added here as they are implemented: -// export { stateCommand } from './state.js'; -// export { screenshotCommand } from './screenshot.js'; -// export { logsCommand } from './logs.js'; -// export { queryCommand } from './query.js'; -// export { execCommand } from './exec.js'; -// export { runCommand } from './run.js'; -// etc. - -import { sessionsCommand } from './sessions.js'; - -export const allCommands: CommandDefinition[] = [ - sessionsCommand, -]; -``` - -3. Implement `src/cli/commands/sessions-command.ts` (the CLI wiring): - -```typescript -import type { CommandModule } from 'yargs'; -import type { StudioBridgeGlobalArgs } from '../args/global-args.js'; -import { listSessionsAsync } from '../../commands/sessions.js'; -import { formatOutput } from '../format-output.js'; - -export interface SessionsArgs extends StudioBridgeGlobalArgs { - json?: boolean; - watch?: boolean; -} - -export class SessionsCommand implements CommandModule { - command = 'sessions'; - describe = 'List active studio-bridge sessions'; - - builder(yargs) { - return yargs - .option('json', { type: 'boolean', default: false, describe: 'Output as JSON' }) - .option('watch', { alias: 'w', type: 'boolean', default: false, describe: 'Watch for session changes' }); - } - - async handler(args: SessionsArgs) { - // 1. Get or create a BridgeConnection - // 2. Call listSessionsAsync(connection, { json: args.json, watch: args.watch }) - // 3. Print formatOutput(result.data, { json: args.json }) - // 4. If result.summary, print it - // 5. If --watch, subscribe to session events and re-render on changes - } -} -``` - -4. Update `cli.ts` to use the barrel pattern: - -```typescript -import { allCommands } from '../commands/index.js'; -import { createCliCommand } from './adapters/cli-adapter.js'; - -// Register ALL commands from the barrel file in a single loop. -// New commands are registered by adding them to src/commands/index.ts. -// This file does NOT change when commands are added. -for (const command of allCommands) { - cli.command(createCliCommand(command)); -} - -// Legacy commands kept as-is during migration -cli.command(new TerminalCommand() as any); -``` - -5. The handler/wiring split is the key pattern: `src/commands/sessions.ts` contains the pure logic (testable without yargs), and `src/cli/commands/sessions-command.ts` is the thin CLI adapter. The barrel file in `src/commands/index.ts` is the single registration point. - -**Acceptance Criteria**: -- `studio-bridge sessions` lists sessions with formatted columns. -- `--json` outputs a JSON array. -- `--watch` continuously updates (or prints "watch not yet supported" if subscription is not available). -- When no sessions exist, shows a helpful message. -- `src/commands/index.ts` exists with `sessionsCommand` exported and included in `allCommands`. -- `src/cli/cli.ts` registers commands via `for (const cmd of allCommands)` loop -- it does NOT import individual command modules. -- Total across handler and CLI wiring files is approximately 60 LOC (barrel file is additional). -- The pattern is clean enough that adding a new command requires only: (a) create `src/commands/.ts`, (b) add one export + one array entry in `src/commands/index.ts`. No other files change. - -**Do NOT**: -- Add any npm dependencies for table formatting (use simple string padding or `formatOutput`). -- Add per-command `.command()` calls to `cli.ts` -- use the `allCommands` loop. -- Use default exports. -- Forget `.js` extensions on local imports. - ---- - -## Cross-References - -- Phase plan: [studio-bridge/plans/execution/phases/01-bridge-network.md](../phases/01-bridge-network.md) -- Validation: [studio-bridge/plans/execution/validation/01-bridge-network.md](../validation/01-bridge-network.md) -- Failover tasks (1.8-1.10): [01b-failover.md](01b-failover.md) -- Tech specs: `studio-bridge/plans/tech-specs/01-protocol.md`, `studio-bridge/plans/tech-specs/02-command-system.md` diff --git a/studio-bridge/plans/execution/agent-prompts/01b-failover.md b/studio-bridge/plans/execution/agent-prompts/01b-failover.md deleted file mode 100644 index 87a9e858d0..0000000000 --- a/studio-bridge/plans/execution/agent-prompts/01b-failover.md +++ /dev/null @@ -1,886 +0,0 @@ -# Phase 1b: Failover & Bridge Networking -- Agent Prompts - -**Phase reference**: [studio-bridge/plans/execution/phases/01-bridge-network.md](../phases/01-bridge-network.md) -**Validation**: [studio-bridge/plans/execution/validation/01-bridge-network.md](../validation/01-bridge-network.md) - -Base path for all file references: `/workspaces/NevermoreEngine/tools/studio-bridge/` - -## How to Use These Prompts - -These tasks were split out from Phase 1 because they involve cross-process coordination and failover logic that benefits from independent scheduling. Tasks 1.8 and 1.9 require a skilled agent with review agent verification for testing correctness. Task 1.10 is an integration test suite that depends on both. - -1. Copy the full prompt for a single task into a Claude Code sub-agent session. -2. The agent should read the "Read First" files, then implement the "Requirements" section. -3. The agent should run the acceptance criteria checks before reporting completion. -4. Do not give an agent a task whose dependencies have not been completed yet (see the dependency graph in [studio-bridge/plans/execution/phases/01-bridge-network.md](../phases/01-bridge-network.md)). - -Key conventions that apply to every prompt: - -- **TypeScript ESM** with `.js` extensions on all local imports (e.g., `import { Foo } from './foo.js';`) -- **`Async` suffix** on all async functions (e.g., `listSessionsAsync`, `resolveRequestAsync`) -- **Private `_` prefix** on all private fields and methods -- **vitest** for tests: `describe`/`it`/`expect`, test files named `*.test.ts` alongside source -- **No default exports** -- always use named exports -- **`@quenty/` scoped packages** for workspace imports (e.g., `@quenty/cli-output-helpers`) - ---- - -## Task 1.8: Failover Detection and Host Takeover - -**Prerequisites**: Task 1.3a (transport layer and bridge host) must be completed first. - -**Context**: Studio-bridge uses a single bridge host process (on port 38741) that all plugins and CLI clients connect to. When that process dies -- gracefully via SIGTERM/SIGINT, or violently via kill -9 or crash -- every participant is affected simultaneously. This task implements the failover detection and host takeover protocol: the mechanism by which a surviving CLI client detects the host's death, races to bind the port, and promotes itself to become the new host. This is the most critical resilience mechanism in the system. Without it, every host death requires manual intervention. - -The takeover protocol has two paths: **graceful** (host sends `HostTransferNotice` before dying, clients skip jitter and takeover immediately) and **crash** (no notification, clients detect WebSocket disconnect, apply random jitter to avoid thundering herd, then race to bind). Both paths converge on the same promotion logic. The OS guarantees that `bind()` is atomic -- exactly one client wins the port, and the rest fall back to connecting as clients to the new host. - -**Objective**: Implement the failover state machine in `hand-off.ts` and integrate it into `bridge-host.ts` (graceful shutdown) and `bridge-client.ts` (disconnect detection and takeover). Write unit tests for the state machine transitions and jitter behavior. - -**Read First**: -- `studio-bridge/plans/tech-specs/08-host-failover.md` (the authoritative spec -- read the whole thing) -- `studio-bridge/plans/tech-specs/07-bridge-network.md` sections 5.4-5.6 (host-protocol.ts, bridge-host.ts, bridge-client.ts, hand-off.ts) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/bridge/internal/transport-server.ts` (existing transport, needed for port binding) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/bridge/internal/bridge-host.ts` (existing host, you will modify) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/bridge/internal/bridge-client.ts` (existing client, you will modify) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/bridge/internal/host-protocol.ts` (existing protocol types including `HostTransferNotice`) - -**Files to Create**: -- `src/bridge/internal/hand-off.ts` -- takeover logic (jitter, bind, promote), graceful shutdown coordination -- `src/bridge/internal/hand-off.test.ts` -- unit tests for state machine and jitter - -**Files to Modify**: -- `src/bridge/internal/bridge-host.ts` -- register SIGTERM/SIGINT handlers, implement `shutdownAsync()` with `HostTransferNotice` broadcast, 2-second shutdown timeout, idempotent shutdown guard -- `src/bridge/internal/bridge-client.ts` -- disconnect detection, classify graceful vs crash, invoke hand-off takeover, role transition from client to host -- `src/bridge/internal/transport-server.ts` -- ensure `SO_REUSEADDR` is set (Node.js does this by default, add a defensive comment), add `forceClose()` method for crash simulation in tests - -**TypeScript Interfaces**: - -The `HostTransferNotice` message is already defined in `host-protocol.ts`: - -```typescript -interface HostTransferNotice { - type: 'host-transfer'; - // Sent by the host to all clients when it is shutting down gracefully -} - -interface HostReadyNotice { - type: 'host-ready'; - // Sent by the new host to remaining clients after takeover -} -``` - -The takeover state machine in `hand-off.ts` uses these states: - -```typescript -type TakeoverState = - | 'connected' // Normal operation, connected to host - | 'detecting-failure' // WebSocket closed/errored, determining failure type - | 'taking-over' // Jitter complete, attempting to bind port - | 'promoted' // Successfully bound port, now acting as host - | 'fell-back-to-client' // Bind failed (EADDRINUSE), reconnected as client to new host - ; -``` - -Export a `HandOffManager` class (or equivalent) with this shape: - -```typescript -export class HandOffManager { - constructor(options: { port: number }); - - /** Current takeover state. */ - get state(): TakeoverState; - - /** - * Called when the host sends HostTransferNotice (graceful path). - * Sets _takeoverPending = true. Does NOT initiate takeover yet -- - * that happens when the WebSocket actually closes. - */ - onHostTransferNotice(): void; - - /** - * Called when the WebSocket to the host closes or errors. - * Determines graceful vs crash based on whether onHostTransferNotice() - * was called first, then initiates the appropriate takeover path. - * Returns the outcome: 'promoted' or 'fell-back-to-client'. - */ - onHostDisconnectedAsync(): Promise<'promoted' | 'fell-back-to-client'>; -} -``` - -**State Machine -- Guard Conditions**: - -| Transition | Guard | Notes | -|---|---|---| -| `connected` -> `detecting-failure` | WebSocket `close` or `error` event fires | Entry into failover | -| `detecting-failure` -> `taking-over` | Jitter delay complete | 0ms for graceful (HostTransferNotice received), uniformly random [0, 500ms] for crash | -| `taking-over` -> `promoted` | `server.listen(port)` succeeds (bind succeeds) | This process is now the host | -| `taking-over` -> `fell-back-to-client` | Bind fails with EADDRINUSE AND subsequent WebSocket connect to `ws://localhost:port/client` succeeds | Another client won the race | -| `taking-over` -> `taking-over` | Bind fails with EADDRINUSE AND client connect also fails | Retry after 1 second (port in TIME_WAIT or held by foreign process). Up to 10 retries. | -| `taking-over` -> ERROR | 10 retries exhausted | Throw `HostUnreachableError` | - -**Error transitions**: If `bind()` fails with an error other than EADDRINUSE, throw immediately (do not retry -- this is a system-level error like EACCES). If bind succeeds but the subsequent host startup fails (e.g., `BridgeHost` constructor throws), call `server.close()` to release the port and fall back to the retry loop. - -**Implementation Steps**: - -1. Create `hand-off.ts` with the `HandOffManager` class and a pure `computeTakeoverJitterMs(options: { graceful: boolean }): number` function. -2. `computeTakeoverJitterMs`: returns `0` if `graceful` is true; otherwise returns `Math.random() * 500` (uniformly distributed [0, 500ms]). This is the ONLY source of randomness in the failover path. Export it so tests can validate the range. -3. Implement `onHostTransferNotice()`: set `_takeoverPending = true`, set state to `detecting-failure`. -4. Implement `onHostDisconnectedAsync()`: - - a. If `_takeoverPending` is true (graceful path): jitter = 0. Set state to `taking-over`. - - b. If `_takeoverPending` is false (crash path): compute jitter via `computeTakeoverJitterMs({ graceful: false })`. Wait jitter ms. Set state to `taking-over`. - - c. Enter retry loop (max 10 attempts): - - Try `server.listen(port)` to bind port - - If bind succeeds: set state to `promoted`, return `'promoted'` - - If EADDRINUSE: try connecting as client to `ws://localhost:port/client` - - If client connect succeeds: set state to `fell-back-to-client`, return `'fell-back-to-client'` - - If client connect fails: wait 1 second, continue loop - - d. If loop exhausts: throw `HostUnreachableError` -5. Modify `bridge-host.ts` to register SIGTERM/SIGINT handlers in `startAsync()`, BEFORE binding the port. Implement `shutdownAsync()`: - - Guard: `if (this._shuttingDown) return;` then `this._shuttingDown = true;` - - Send `{ type: 'host-transfer' }` to all connected clients - - Send WebSocket close frame (code 1001, "Going Away") to all connected plugins - - Send WebSocket close frame (code 1001) to all connected clients - - Call `this._transportServer.closeAsync()` to free the port - - Wrap the above in a 2-second timeout: if shutdown takes longer, call `forceClose()` and exit -6. Modify `bridge-client.ts`: - - On receiving `{ type: 'host-transfer' }` message: call `this._handOff.onHostTransferNotice()` - - On WebSocket close/error: call `const outcome = await this._handOff.onHostDisconnectedAsync()` - - If outcome is `'promoted'`: create a new `BridgeHost`, start it, update `this._role` to `'host'`, reject all pending requests from the old connection with `SessionDisconnectedError` - - If outcome is `'fell-back-to-client'`: the `HandOffManager` has already established the client connection; update internal state to use the new connection -7. Add `forceClose()` to `transport-server.ts` that closes all sockets immediately without sending close frames (for crash simulation in tests). -8. Ensure `SO_REUSEADDR` is documented in `transport-server.ts` with a comment explaining that Node.js `http.Server` sets it by default and that this MUST NOT be removed in future refactors. - -**Race Condition Handling**: - -The critical race is: "two clients bind simultaneously after host death." This is resolved by the OS kernel. `bind()` is atomic at the kernel level. If client A and client B both call `bind()` on port 38741 at the same time: -- Exactly one succeeds (gets the port) -- The other gets EADDRINUSE -- The loser then tries connecting as a client to the winner -- No lock files, no distributed coordination, no PIDs -- the port IS the lock - -The jitter (0-500ms random delay for crash path only) reduces contention by spreading bind attempts over time, but it is not required for correctness. Even without jitter, the bind-or-fallback loop is correct. The jitter is an optimization to reduce unnecessary EADDRINUSE errors. - -**Test Scenarios**: - -All timing tests MUST use `vi.useFakeTimers()`. Do NOT use wall-clock assertions or `setTimeout` with real delays. - -```typescript -describe('HandOffManager', () => { - describe('state machine transitions', () => { - it('starts in connected state', () => { - const handOff = new HandOffManager({ port: TEST_PORT }); - expect(handOff.state).toBe('connected'); - }); - - it('transitions to detecting-failure on HostTransferNotice', () => { - const handOff = new HandOffManager({ port: TEST_PORT }); - handOff.onHostTransferNotice(); - expect(handOff.state).toBe('detecting-failure'); - }); - - it('graceful path: skips jitter, transitions directly to taking-over', async () => { - // Setup: host running, client connected, mock bind to succeed - const handOff = createHandOffWithMockBind({ bindResult: 'success' }); - handOff.onHostTransferNotice(); - const outcome = await handOff.onHostDisconnectedAsync(); - expect(outcome).toBe('promoted'); - expect(handOff.state).toBe('promoted'); - }); - - it('crash path: applies jitter before takeover attempt', async () => { - vi.useFakeTimers(); - const handOff = createHandOffWithMockBind({ bindResult: 'success' }); - // Do NOT call onHostTransferNotice -- simulate crash - const promise = handOff.onHostDisconnectedAsync(); - // Jitter is [0, 500ms], advance past it - await vi.advanceTimersByTimeAsync(500); - const outcome = await promise; - expect(outcome).toBe('promoted'); - vi.useRealTimers(); - }); - - it('falls back to client when bind fails and another host exists', async () => { - const handOff = createHandOffWithMockBind({ - bindResult: 'eaddrinuse', - clientConnectResult: 'success', - }); - handOff.onHostTransferNotice(); - const outcome = await handOff.onHostDisconnectedAsync(); - expect(outcome).toBe('fell-back-to-client'); - }); - - it('retries when bind fails and no host is reachable', async () => { - vi.useFakeTimers(); - let attempt = 0; - const handOff = createHandOffWithMockBind({ - bindResult: () => { - attempt++; - return attempt >= 3 ? 'success' : 'eaddrinuse'; - }, - clientConnectResult: 'fail', - }); - handOff.onHostTransferNotice(); - const promise = handOff.onHostDisconnectedAsync(); - // Advance past retry delays (1s per retry) - await vi.advanceTimersByTimeAsync(3000); - const outcome = await promise; - expect(outcome).toBe('promoted'); - expect(attempt).toBe(3); - vi.useRealTimers(); - }); - - it('throws HostUnreachableError after 10 failed retries', async () => { - vi.useFakeTimers(); - const handOff = createHandOffWithMockBind({ - bindResult: 'eaddrinuse', - clientConnectResult: 'fail', - }); - handOff.onHostTransferNotice(); - const promise = handOff.onHostDisconnectedAsync(); - await vi.advanceTimersByTimeAsync(15000); // 10 retries * 1s each - await expect(promise).rejects.toThrow(HostUnreachableError); - vi.useRealTimers(); - }); - }); - - describe('computeTakeoverJitterMs', () => { - it('returns 0 for graceful shutdown', () => { - expect(computeTakeoverJitterMs({ graceful: true })).toBe(0); - }); - - it('returns values in [0, 500] for crash', () => { - const values = Array.from({ length: 1000 }, () => - computeTakeoverJitterMs({ graceful: false }) - ); - expect(Math.min(...values)).toBeGreaterThanOrEqual(0); - expect(Math.max(...values)).toBeLessThanOrEqual(500); - }); - }); - - describe('thundering herd', () => { - it('two clients: host crashes, one takes over, one falls back', async () => { - // Simulate two HandOffManagers with coordinated mock bind: - // whichever calls bind first succeeds, the second gets EADDRINUSE - // ... - }); - - it('graceful shutdown with HostTransferNotice: no jitter', async () => { - // Both clients receive HostTransferNotice, both try immediately, - // one wins, one falls back - // ... - }); - - it('three clients: crash, jitter spreads attempts', async () => { - vi.useFakeTimers(); - // Track timestamps of bind attempts to verify they are spread - // over the [0, 500ms] jitter window - // ... - vi.useRealTimers(); - }); - }); -}); - -describe('bridge-host shutdown', () => { - it('sends HostTransferNotice to all clients before closing', async () => { - // Start host, connect two mock clients, call shutdownAsync() - // Verify both clients received { type: 'host-transfer' } - }); - - it('shutdown is idempotent', async () => { - // Call shutdownAsync() twice, verify no error and no duplicate messages - }); - - it('force-closes after 2-second timeout', async () => { - vi.useFakeTimers(); - // Connect a mock client that never acknowledges close - // Verify host force-closes after 2 seconds - vi.useRealTimers(); - }); -}); -``` - -**Acceptance Criteria**: - -1. `HandOffManager` correctly transitions through all states: `connected` -> `detecting-failure` -> `taking-over` -> `promoted` (or `fell-back-to-client`). -2. Graceful path (HostTransferNotice received): jitter is 0, takeover begins immediately after WebSocket close. -3. Crash path (no HostTransferNotice): jitter is uniformly distributed in [0, 500ms]. -4. When bind succeeds: client promotes to host, creates new `BridgeHost`, starts accepting connections. -5. When bind fails with EADDRINUSE and another host exists: client falls back to client role and connects to the new host. -6. When bind fails with EADDRINUSE and no host exists: client retries every 1 second, up to 10 times. -7. After 10 retries: throws `HostUnreachableError`. -8. `shutdownAsync()` on bridge-host sends `HostTransferNotice` to all clients, then closes all connections, then frees the port -- all within a 2-second timeout. -9. Shutdown is idempotent (second call is a no-op). -10. SIGTERM and SIGINT handlers are registered before the port is bound. -11. All pending requests in the client's `PendingRequestMap` are rejected with `SessionDisconnectedError` during promotion. -12. `SO_REUSEADDR` is documented in transport-server.ts. -13. All unit tests pass: `npx vitest run src/bridge/internal/hand-off.test.ts` from `tools/studio-bridge/`. - -**Do NOT**: -- Use lock files or PID files for coordination -- the port binding IS the coordination mechanism. -- Add `process.exit()` calls outside of the shutdown timeout handler -- let the normal control flow handle exit. -- Use `setTimeout` with real delays in tests -- use `vi.useFakeTimers()` for all timing. -- Import from `@quenty/` packages in `hand-off.ts` -- this module should be self-contained within `src/bridge/internal/`. -- Add retry logic for non-EADDRINUSE bind errors (EACCES, etc.) -- those are fatal. -- Forget `.js` extensions on local imports. - ---- - -## Task 1.9: Inflight Request Handling During Failover - -**Prerequisites**: Tasks 1.3d5 (BridgeConnection barrel export) and 1.8 (failover detection and host takeover) must be completed first. - -**Context**: When the bridge host dies mid-operation, there may be in-flight requests that were sent to the host but never received a response. These requests are sitting in the client's `PendingRequestMap` as unresolved promises. The consumers that initiated those requests (CLI commands, MCP tools, library calls) are waiting on those promises. This task ensures that inflight requests are surfaced to callers quickly and correctly -- with the right error type, within the right time bounds, and with the right retry semantics. - -The key distinction: consumers should receive `SessionDisconnectedError` (the host died), NOT `ActionTimeoutError` (the request timed out). The difference matters because `ActionTimeoutError` implies "we waited the full timeout and nothing happened" while `SessionDisconnectedError` implies "the host died and the request outcome is unknown." Consumer code makes retry decisions based on this distinction. - -**Objective**: Implement inflight request rejection during host death, define retry policy per action type, and write tests verifying that requests surface the correct error within 2 seconds of host death. - -**Read First**: -- `studio-bridge/plans/tech-specs/08-host-failover.md` sections 3.1, 4.4, 5.4 (state loss, drain behavior, host dies mid-action) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/bridge/internal/bridge-client.ts` (client-side pending request handling) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/server/pending-request-map.ts` (the PendingRequestMap class from Task 1.2) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/bridge/bridge-session.ts` (BridgeSession action methods) -- `studio-bridge/plans/tech-specs/07-bridge-network.md` section 2.7 (error types) - -**Files to Modify**: -- `src/bridge/internal/bridge-client.ts` -- on host disconnect, call `_pendingRequests.rejectAll()` with `SessionDisconnectedError` before entering the takeover flow -- `src/bridge/bridge-session.ts` -- when the underlying transport handle is disconnected, action methods must reject immediately with `SessionDisconnectedError` (not queue or hang) -- `src/bridge/errors.ts` -- ensure `SessionDisconnectedError` is defined with a clear message - -**Files to Create**: -- `src/bridge/internal/__tests__/failover-inflight.test.ts` -- tests for inflight request behavior during failover - -**PendingRequestMap Behavior During Host Death**: - -When the client detects host disconnect (WebSocket close/error event), it MUST call `_pendingRequests.cancelAll('Host disconnected')` BEFORE initiating the takeover flow. The `cancelAll` method (from Task 1.2) rejects every pending promise with the provided reason, clears all timeout timers, and empties the map. The `bridge-client.ts` should wrap the cancellation reason in a `SessionDisconnectedError`: - -```typescript -private _onHostDisconnected(): void { - // Step 1: Reject all inflight requests immediately - this._pendingRequests.rejectAll( - new SessionDisconnectedError('Bridge host disconnected during request') - ); - - // Step 2: Begin takeover flow (may take seconds) - this._handOff.onHostDisconnectedAsync().then(outcome => { - // ... handle promotion or fallback ... - }); -} -``` - -The ordering is critical: reject first, THEN takeover. If takeover happens first, the pending requests sit unresolved for the entire takeover duration (jitter + bind + retry = potentially seconds). Consumers should learn about the failure immediately. - -**How Inflight Requests Are Surfaced to Callers**: - -The error propagation chain: - -1. Host dies -> WebSocket close event fires on the client -2. Client calls `_pendingRequests.rejectAll(new SessionDisconnectedError(...))` -3. Each pending promise rejects with `SessionDisconnectedError` -4. The consumer's `await session.execAsync(...)` (or other action method) throws `SessionDisconnectedError` -5. Consumer can catch and decide whether to retry - -**Retry Policy Per Action Type**: - -NOT all actions should be retried automatically. The retry decision depends on whether the action is idempotent: - -| Action | Auto-retry after failover? | Reason | -|--------|---------------------------|--------| -| `execute` (exec) | **NO** | Arbitrary Luau code may have side effects. The script may have partially executed. Retrying could cause double execution. | -| `run` | **NO** | Same as execute -- arbitrary code with potential side effects. | -| `queryState` | **YES** | Read-only, idempotent. Safe to retry on the new host once a session is available. | -| `captureScreenshot` | **YES** | Read-only, idempotent. | -| `queryDataModel` | **YES** | Read-only, idempotent. | -| `queryLogs` | **YES** | Read-only, idempotent. The plugin's log buffer survives host death. | -| `subscribe` | **YES** | Idempotent (subscribing to an already-subscribed event is a no-op on the plugin). | - -Auto-retry for idempotent actions is NOT implemented in this task -- it would be a higher-level concern in `BridgeSession` or consumer code. This task only ensures the correct error type is thrown so that consumers CAN make the retry decision. Document the retry policy in code comments on `SessionDisconnectedError`. - -**BridgeSession Behavior After Disconnect**: - -Once a `BridgeSession`'s transport handle is disconnected, ALL subsequent action calls MUST reject immediately with `SessionDisconnectedError`. The session must not queue, buffer, or silently drop requests. Implement this with a `_disconnected` flag on the session: - -```typescript -async execAsync(code: string, timeout?: number): Promise { - if (this._disconnected) { - throw new SessionDisconnectedError( - `Session ${this.info.sessionId} is disconnected (host died). ` + - `Re-resolve session via BridgeConnection.waitForSession().` - ); - } - // ... normal implementation ... -} -``` - -When the client transitions roles (during takeover), it should mark ALL existing `BridgeSession` instances as disconnected and emit `'session-disconnected'` events. New sessions from the new host will be fresh `BridgeSession` instances with new session IDs. - -**Test Scenarios**: - -```typescript -describe('inflight request handling during failover', () => { - it('rejects pending execute with SessionDisconnectedError on host death', async () => { - // Setup: host + client + mock plugin - const { host, client, plugin } = await setupTestBridge(); - - // Send execute, but mock plugin does NOT respond (simulates in-flight) - const execPromise = client.session.execAsync('print("hello")'); - - // Kill the host (force close, no HostTransferNotice) - host.forceClose(); - - // The exec promise should reject with SessionDisconnectedError - await expect(execPromise).rejects.toThrow(SessionDisconnectedError); - // NOT ActionTimeoutError -- the error should arrive quickly - }); - - it('rejects pending execute within 2 seconds of host death', async () => { - const { host, client, plugin } = await setupTestBridge(); - const execPromise = client.session.execAsync('print("hello")'); - const startTime = Date.now(); - - host.forceClose(); - - try { - await execPromise; - } catch (err) { - const elapsed = Date.now() - startTime; - expect(elapsed).toBeLessThan(2000); - expect(err).toBeInstanceOf(SessionDisconnectedError); - } - }); - - it('rejects ALL pending requests, not just the first', async () => { - const { host, client, plugin } = await setupTestBridge(); - - // Send 5 concurrent requests, none answered - const promises = Array.from({ length: 5 }, (_, i) => - client.session.execAsync(`print(${i})`) - ); - - host.forceClose(); - - const results = await Promise.allSettled(promises); - for (const result of results) { - expect(result.status).toBe('rejected'); - expect((result as PromiseRejectedResult).reason).toBeInstanceOf(SessionDisconnectedError); - } - }); - - it('queryState retries successfully after new host takes over', async () => { - const { host, client, plugin } = await setupTestBridge(); - - // Send queryState, host dies mid-request - const queryPromise = client.session.queryStateAsync(); - host.forceClose(); - - // First attempt fails - await expect(queryPromise).rejects.toThrow(SessionDisconnectedError); - - // Wait for client to become new host and plugin to reconnect - await waitForCondition(() => client.role === 'host', 5000); - await plugin.waitForReconnection(5000); - - // Get the new session and retry (consumer-side retry for idempotent action) - const newSession = await client.waitForSession(5000); - const state = await newSession.queryStateAsync(); - expect(state.state).toBeDefined(); - }); - - it('session methods reject immediately after disconnect', async () => { - const { host, client } = await setupTestBridge(); - const session = client.session; - - host.forceClose(); - // Wait for disconnect to be detected - await waitForCondition(() => !session.isConnected, 2000); - - // All subsequent calls should reject immediately - await expect(session.execAsync('print(1)')).rejects.toThrow(SessionDisconnectedError); - await expect(session.queryStateAsync()).rejects.toThrow(SessionDisconnectedError); - await expect(session.captureScreenshotAsync()).rejects.toThrow(SessionDisconnectedError); - }); - - it('graceful shutdown: pending requests reject with SessionDisconnectedError', async () => { - const { host, client, plugin } = await setupTestBridge(); - const execPromise = client.session.execAsync('print("hello")'); - - // Graceful shutdown (sends HostTransferNotice first) - await host.shutdownAsync(); - - await expect(execPromise).rejects.toThrow(SessionDisconnectedError); - }); -}); -``` - -**Acceptance Criteria**: - -1. When the host dies (graceful or crash), ALL pending requests in the client's `PendingRequestMap` are rejected with `SessionDisconnectedError` within 2 seconds. -2. The error type is `SessionDisconnectedError`, NOT `ActionTimeoutError`. -3. Pending requests are rejected BEFORE the takeover flow begins (ordering guarantee). -4. After disconnect, all action methods on the old `BridgeSession` throw `SessionDisconnectedError` immediately. -5. After failover, consumers can get a new `BridgeSession` via `bridge.waitForSession()` and retry idempotent actions. -6. `SessionDisconnectedError` message includes the session ID and guidance to re-resolve via `waitForSession()`. -7. All tests pass: `npx vitest run src/bridge/internal/__tests__/failover-inflight.test.ts` from `tools/studio-bridge/`. - -**Do NOT**: -- Implement automatic retry logic in this task -- that is a consumer-level concern. This task only ensures the right error is thrown. -- Let pending requests hang until their timeout expires -- they must be rejected eagerly on disconnect. -- Use `ActionTimeoutError` for host death scenarios -- that error is reserved for "the plugin did not respond within the timeout while the host was alive." -- Forget to clear timeout timers when rejecting pending requests (timer leaks will cause test warnings). -- Forget `.js` extensions on local imports. - ---- - -## Task 1.10: Plugin Reconnection During Failover - -**Prerequisites**: Tasks 1.3d5 (BridgeConnection barrel export) and 1.8 (failover detection and host takeover) must be completed first. - -**Context**: When the bridge host dies, Studio plugins lose their WebSocket connection. The persistent plugin has a built-in state machine (implemented in Luau) that handles reconnection: it detects the disconnect, enters a backoff period, then polls the health endpoint to discover the new host. This task implements the **server-side handling** of plugin reconnection after failover, and writes integration tests that exercise the full reconnection flow using mock plugins. It also covers the mock plugin's reconnection behavior for use in all failover tests. - -The critical insight: the new host starts with an **empty session map**. It has zero knowledge of what sessions existed on the old host. Sessions are rebuilt entirely from plugin re-registrations. This means there is a recovery window (1-5 seconds) where `listSessions()` returns fewer sessions than actually exist. The tests must verify this progressive recovery behavior. - -**Objective**: Extend the mock plugin helper with reconnection support, implement server-side reconnection handling in `bridge-host.ts`, and write integration tests covering plugin reconnection scenarios during both graceful and crash failover. - -**Read First**: -- `studio-bridge/plans/tech-specs/08-host-failover.md` sections 2.1-2.4, 3.3, 3.4 (recovery protocol, state recovery, instance ID continuity) -- `studio-bridge/plans/tech-specs/03-persistent-plugin.md` sections 4.1-4.2, 6.1-6.4 (plugin state machine, reconnection strategy) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/bridge/internal/bridge-host.ts` (host -- handles plugin connections) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/bridge/internal/session-tracker.ts` (session management) -- Any existing mock plugin helpers from prior tasks - -**Files to Create**: -- `src/bridge/internal/__tests__/failover-plugin-reconnect.test.ts` -- integration tests for plugin reconnection -- `src/bridge/internal/__tests__/helpers/mock-plugin.ts` -- extended mock plugin with reconnection support (or modify existing if one exists) - -**Files to Modify**: -- `src/bridge/internal/bridge-host.ts` -- ensure `register` messages from reconnecting plugins are handled correctly (fresh session created, old session was already removed when WebSocket closed) - -**Plugin State Machine During Host Death**: - -The real Luau plugin transitions through these states during failover (you do not implement the Luau side -- the mock plugin simulates it): - -``` -connected -- Normal operation, WebSocket active - | - | WebSocket close/error (no shutdown message preceded it) - v -reconnecting -- Backoff period before retrying - | - | backoff timer expires (1s initially, doubles: 2s, 4s, 8s, 16s, 30s max) - v -searching -- Polling /health every 2 seconds - | - | /health returns 200 - v -connecting -- Opening WebSocket, sending register - | - | welcome received - v -connected -- Re-established on the new host -``` - -For **graceful** shutdown (clean WebSocket close with code 1001): the plugin skips `reconnecting` and goes directly to `searching` with no backoff. - -For **crash** (unexpected close/error): the plugin enters `reconnecting` with exponential backoff starting at 1 second: 1s, 2s, 4s, 8s, 16s, 30s (capped). - -**Mock Plugin Reconnection Support**: - -Extend the mock plugin helper (from Task 1.3 or create new) with auto-reconnection: - -```typescript -export interface MockPluginOptions { - port: number; - instanceId?: string; // Default: random UUID - context?: SessionContext; // Default: 'edit' - placeName?: string; // Default: 'TestPlace' - placeId?: number; // Default: 0 - gameId?: number; // Default: 0 - capabilities?: Capability[]; - autoReconnect?: boolean; // Default: true - pollIntervalMs?: number; // Default: 200 (fast for tests, real plugin uses 2000) - backoffMs?: number; // Default: 100 (fast for tests, real plugin uses 1000) -} - -export interface MockPlugin { - readonly state: 'disconnected' | 'connecting' | 'connected' | 'reconnecting' | 'searching'; - readonly sessionId: string | null; - readonly instanceId: string; - readonly context: SessionContext; - - /** Connect to the host for the first time. */ - connectAsync(): Promise; - - /** Wait for the welcome message. */ - waitForWelcome(timeoutMs?: number): Promise; - - /** Wait for reconnection to complete after a disconnection. */ - waitForReconnection(timeoutMs?: number): Promise; - - /** Register a handler for a specific action type. */ - onAction(type: string, handler: (msg: any) => any): void; - - /** Disconnect and stop the mock plugin. */ - dispose(): void; -} -``` - -The mock plugin's reconnection behavior: -1. On WebSocket close/error: transition to `reconnecting` -2. After `backoffMs` delay: transition to `searching` -3. Poll `http://localhost:{port}/health` every `pollIntervalMs` -4. On 200 OK: transition to `connecting`, open new WebSocket to `/plugin` -5. Generate a **fresh UUID** as the proposed session ID (per spec: "each plugin generates a fresh UUID as its proposed session ID when re-registering") -6. Send `register` with the same `instanceId` and `context` (these do not change across reconnections) -7. On `welcome`: transition to `connected`, store new session ID from welcome response -8. Reset all subscription state (the new host has no memory of previous subscriptions) - -**Session Identity After Reconnection**: - -This is a critical design decision that the tests must validate: - -- `instanceId` is **persistent** -- the same before and after failover. It identifies the Studio installation. -- `sessionId` is **ephemeral** -- a fresh UUID is generated on each connection. After failover, the session ID changes. -- `context` is **persistent** -- `'edit'`, `'client'`, or `'server'` does not change. -- The tuple `(instanceId, context)` provides continuity across failovers. The `sessionId` does not. - -On the server side (bridge-host.ts), when a plugin reconnects: -- The old session was already removed when the old host died (or when the WebSocket closed) -- The new `register` message creates a brand new `TrackedSession` in the `SessionTracker` -- The `SessionTracker` groups sessions by `instanceId` -- the reconnecting plugin slots back into the correct instance group -- The new host emits `SessionEvent { event: 'connected' }` to all connected clients - -**Test Scenarios**: - -```typescript -describe('plugin reconnection during failover', () => { - it('plugin reconnects to new host after crash', async () => { - // Start host, connect mock plugin, connect client - const host = await createTestHost({ port: 0 }); - const port = host.port; - const plugin = createMockPlugin({ port, instanceId: 'inst-1', context: 'edit' }); - await plugin.connectAsync(); - await plugin.waitForWelcome(); - const oldSessionId = plugin.sessionId; - - const client = await BridgeConnection.connectAsync({ port }); - expect(client.listSessions()).toHaveLength(1); - - // Kill the host - host.forceClose(); - - // Wait for client to take over - await waitForCondition(() => client.role === 'host', 5000); - - // Wait for plugin to reconnect to the new host - await plugin.waitForReconnection(5000); - - // Session ID should be different (fresh UUID on reconnect) - expect(plugin.sessionId).not.toBe(oldSessionId); - - // Instance ID should be the same - expect(plugin.instanceId).toBe('inst-1'); - - // The new host should have the session - const sessions = client.listSessions(); - expect(sessions).toHaveLength(1); - expect(sessions[0].instanceId).toBe('inst-1'); - expect(sessions[0].context).toBe('edit'); - }); - - it('plugin reconnects after graceful shutdown (no backoff)', async () => { - const host = await createTestHost({ port: 0 }); - const port = host.port; - const plugin = createMockPlugin({ port, instanceId: 'inst-1', context: 'edit' }); - await plugin.connectAsync(); - await plugin.waitForWelcome(); - - const client = await BridgeConnection.connectAsync({ port }); - - // Graceful shutdown - await host.shutdownAsync(); - - // Client takes over - await waitForCondition(() => client.role === 'host', 5000); - - // Plugin should reconnect quickly (no backoff for graceful) - await plugin.waitForReconnection(3000); - - expect(client.listSessions()).toHaveLength(1); - }); - - it('multi-context: all 3 sessions reconnect after failover', async () => { - const host = await createTestHost({ port: 0 }); - const port = host.port; - - // Simulate a Studio in Play mode: 3 plugin instances, same instanceId - const editPlugin = createMockPlugin({ port, instanceId: 'inst-1', context: 'edit' }); - const serverPlugin = createMockPlugin({ port, instanceId: 'inst-1', context: 'server' }); - const clientPlugin = createMockPlugin({ port, instanceId: 'inst-1', context: 'client' }); - - await Promise.all([ - editPlugin.connectAsync().then(() => editPlugin.waitForWelcome()), - serverPlugin.connectAsync().then(() => serverPlugin.waitForWelcome()), - clientPlugin.connectAsync().then(() => clientPlugin.waitForWelcome()), - ]); - - const client = await BridgeConnection.connectAsync({ port }); - expect(client.listSessions()).toHaveLength(3); - - // Kill the host - host.forceClose(); - - // Client takes over - await waitForCondition(() => client.role === 'host', 5000); - - // All 3 plugins reconnect independently - await Promise.all([ - editPlugin.waitForReconnection(5000), - serverPlugin.waitForReconnection(5000), - clientPlugin.waitForReconnection(5000), - ]); - - // All 3 sessions restored, grouped by instanceId - const sessions = client.listSessions(); - expect(sessions).toHaveLength(3); - const contexts = sessions.map(s => s.context).sort(); - expect(contexts).toEqual(['client', 'edit', 'server']); - // All share the same instanceId - expect(new Set(sessions.map(s => s.instanceId)).size).toBe(1); - }); - - it('plugin resets subscription state after reconnection', async () => { - const host = await createTestHost({ port: 0 }); - const port = host.port; - const plugin = createMockPlugin({ port, instanceId: 'inst-1', context: 'edit' }); - await plugin.connectAsync(); - await plugin.waitForWelcome(); - - const client = await BridgeConnection.connectAsync({ port }); - const session = await client.waitForSession(); - - // Subscribe to stateChange - await session.subscribeAsync(['stateChange']); - - // Kill the host - host.forceClose(); - - // Wait for recovery - await waitForCondition(() => client.role === 'host', 5000); - await plugin.waitForReconnection(5000); - - // After reconnection, the new host has no subscription state - // Consumer must re-subscribe - const newSession = await client.waitForSession(); - // Verify: the new host does not push stateChange events without re-subscribe - // (implementation-specific assertion) - }); - - it('actions work through the new host after plugin reconnection', async () => { - const host = await createTestHost({ port: 0 }); - const port = host.port; - const plugin = createMockPlugin({ port, instanceId: 'inst-1', context: 'edit' }); - await plugin.connectAsync(); - await plugin.waitForWelcome(); - - // Register a queryState handler on the mock plugin - plugin.onAction('queryState', () => ({ - type: 'stateResult', - payload: { state: 'Edit', placeId: 0, placeName: 'Test', gameId: 0 }, - })); - - const client = await BridgeConnection.connectAsync({ port }); - - // Kill host, wait for recovery - host.forceClose(); - await waitForCondition(() => client.role === 'host', 5000); - await plugin.waitForReconnection(5000); - - // Execute action through the new host - const newSession = await client.waitForSession(5000); - const state = await newSession.queryStateAsync(); - expect(state.state).toBe('Edit'); - }); - - it('partial multi-context recovery: available sessions are usable while others reconnect', async () => { - const host = await createTestHost({ port: 0 }); - const port = host.port; - - const editPlugin = createMockPlugin({ port, instanceId: 'inst-1', context: 'edit', backoffMs: 50 }); - const serverPlugin = createMockPlugin({ port, instanceId: 'inst-1', context: 'server', backoffMs: 5000 }); // Slow reconnect - - await Promise.all([ - editPlugin.connectAsync().then(() => editPlugin.waitForWelcome()), - serverPlugin.connectAsync().then(() => serverPlugin.waitForWelcome()), - ]); - - const client = await BridgeConnection.connectAsync({ port }); - - host.forceClose(); - await waitForCondition(() => client.role === 'host', 5000); - - // Edit plugin reconnects quickly - await editPlugin.waitForReconnection(2000); - - // At this point, 1 of 2 sessions is available - const sessions = client.listSessions(); - expect(sessions).toHaveLength(1); - expect(sessions[0].context).toBe('edit'); - - // Server plugin eventually reconnects - await serverPlugin.waitForReconnection(10000); - expect(client.listSessions()).toHaveLength(2); - }); - - it('no clients: plugin polls until new CLI starts', async () => { - const host = await createTestHost({ port: 0 }); - const port = host.port; - const plugin = createMockPlugin({ port, instanceId: 'inst-1', context: 'edit' }); - await plugin.connectAsync(); - await plugin.waitForWelcome(); - - // Kill host with no clients connected - host.forceClose(); - - // Plugin enters searching state - await waitForCondition(() => plugin.state === 'searching', 5000); - - // Start a new host on the same port - const newHost = await createTestHost({ port }); - - // Plugin discovers and reconnects - await plugin.waitForReconnection(5000); - expect(newHost.listSessions()).toHaveLength(1); - }); -}); -``` - -**Acceptance Criteria**: - -1. Mock plugin helper supports auto-reconnection with configurable poll interval and backoff. -2. After host crash, plugin enters `reconnecting` -> `searching` -> `connecting` -> `connected`. -3. After graceful shutdown (clean WebSocket close 1001), plugin skips `reconnecting` and goes directly to `searching` (no backoff). -4. Plugin generates a **fresh UUID** as session ID on reconnect (not the old session ID). -5. Plugin sends the **same `instanceId` and `context`** on reconnect. -6. New host creates a fresh `TrackedSession` from the `register` message, grouped by `instanceId`. -7. Multi-context reconnection: all 3 contexts (edit, client, server) reconnect independently and are grouped correctly. -8. Partial recovery: sessions that reconnect first are immediately usable while others are still reconnecting. -9. Subscription state is NOT carried over -- consumers must re-subscribe after failover. -10. Actions work through the new host after plugin reconnection. -11. All tests use ephemeral ports to avoid conflicts. -12. All tests clean up connections in `afterEach`. -13. All tests pass: `npx vitest run src/bridge/internal/__tests__/failover-plugin-reconnect.test.ts` from `tools/studio-bridge/`. - -**Do NOT**: -- Implement the Luau-side reconnection logic -- that is a separate task (Phase 0.5). This task implements the mock and the server-side handling. -- Attempt to transfer or restore session state from the old host -- the new host starts empty and rebuilds from registrations. -- Reuse old session IDs after reconnection -- session IDs are ephemeral and scoped to a single host lifetime. -- Use wall-clock time assertions in tests -- use event-driven waits (`waitForCondition`, `waitForReconnection`) with generous timeouts. -- Use the same port across test cases -- always use ephemeral ports (`port: 0`) to prevent test interference. -- Forget `.js` extensions on local imports. - ---- - -## Cross-References - -- Phase plan: [studio-bridge/plans/execution/phases/01-bridge-network.md](../phases/01-bridge-network.md) -- Validation: [studio-bridge/plans/execution/validation/01-bridge-network.md](../validation/01-bridge-network.md) -- Tech specs: `studio-bridge/plans/tech-specs/07-bridge-network.md`, `studio-bridge/plans/tech-specs/08-host-failover.md` diff --git a/studio-bridge/plans/execution/agent-prompts/02-plugin.md b/studio-bridge/plans/execution/agent-prompts/02-plugin.md deleted file mode 100644 index 2d80a20bf0..0000000000 --- a/studio-bridge/plans/execution/agent-prompts/02-plugin.md +++ /dev/null @@ -1,466 +0,0 @@ -# Phase 2: Persistent Plugin -- Agent Prompts - -**Phase reference**: [studio-bridge/plans/execution/phases/02-plugin.md](../phases/02-plugin.md) -**Validation**: [studio-bridge/plans/execution/validation/02-plugin.md](../validation/02-plugin.md) - -Base path for all file references: `/workspaces/NevermoreEngine/tools/studio-bridge/` - -## How to Use These Prompts - -1. Copy the full prompt for a single task into a Claude Code sub-agent session. -2. The agent should read the "Read First" files, then implement the "Requirements" section. -3. The agent should run the acceptance criteria checks before reporting completion. -4. Do not give an agent a task whose dependencies have not been completed yet (see the dependency graph in [studio-bridge/plans/execution/phases/02-plugin.md](../phases/02-plugin.md)). - -Key conventions that apply to every prompt: - -- **TypeScript ESM** with `.js` extensions on all local imports (e.g., `import { Foo } from './foo.js';`) -- **`Async` suffix** on all async functions (e.g., `listSessionsAsync`, `resolveRequestAsync`) -- **Private `_` prefix** on all private fields and methods -- **vitest** for tests: `describe`/`it`/`expect`, test files named `*.test.ts` alongside source -- **No default exports** -- always use named exports -- **yargs `CommandModule` pattern** for CLI commands (class with `command`, `describe`, `builder`, `handler`) -- **`@quenty/` scoped packages** for workspace imports (e.g., `@quenty/cli-output-helpers`) -- **`OutputHelper`** from `@quenty/cli-output-helpers` for all user-facing output - ---- - -## Task 2.3: Health Endpoint on WebSocket Server - -**Prerequisites**: Task 1.3d5 (BridgeConnection barrel export) must be completed first. - -**Context**: The persistent Roblox Studio plugin needs to discover running studio-bridge servers. Each server exposes a `GET /health` HTTP endpoint alongside its WebSocket endpoint. The plugin polls `localhost:{port}/health` to find active servers. - -**Objective**: Add an HTTP server to `StudioBridgeServer` that responds to `GET /health` with session info JSON, while continuing to handle WebSocket upgrades. - -**Read First**: -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/server/studio-bridge-server.ts` (the file you will modify) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/registry/types.ts` (SessionInfo shape) - -**Files to Modify**: -- `src/server/studio-bridge-server.ts` -- replace bare `WebSocketServer` with `http.createServer` + `WebSocketServer({ noServer: true })`, add `/health` handler - -**Requirements**: - -1. Import `http` from Node.js: `import * as http from 'http';` - -2. Replace the current `new WebSocketServer({ port: 0, path: ... })` with: - - Create an `http.Server` that handles HTTP requests. - - Create a `WebSocketServer` with `{ noServer: true }`. - - On the HTTP server's `'upgrade'` event, check if the URL matches `/${sessionId}`, then call `wss.handleUpgrade`. - - On the HTTP server's `'request'` event, handle `GET /health` and return 404 for everything else. - -3. The `/health` endpoint returns: - -```json -{ - "status": "ok", - "sessionId": "", - "port": , - "protocolVersion": 2, - "serverVersion": "" -} -``` - -Use `200 OK` with `Content-Type: application/json`. - -4. Non-matching HTTP requests return `404 Not Found` with a plain text body. - -5. WebSocket upgrade requests to wrong paths return 404 and destroy the socket. - -6. Update `startWsServerAsync` (or the startup code) to listen the `http.Server` instead of the `WebSocketServer`. - -7. Update `_cleanupResourcesAsync` to close both the HTTP server and the WebSocket server. - -**Acceptance Criteria**: -- `GET http://localhost:{port}/health` returns 200 with the JSON body described above. -- WebSocket upgrades to `/{sessionId}` continue to work (existing handshake tests pass). -- Non-matching HTTP requests return 404. -- WebSocket upgrades to wrong paths are rejected. -- The health endpoint is available immediately after `startAsync` resolves. -- The HTTP server is closed during `_cleanupResourcesAsync`. - -**Do NOT**: -- Add any npm dependencies (use Node.js built-in `http` module). -- Change the public API of `StudioBridgeServer`. -- Break the existing WebSocket handshake flow. -- Use default exports. -- Forget `.js` extensions on local imports. - ---- - -## Task 2.4: Plugin Installer Command - -**Prerequisites**: Task 2.1 (persistent plugin core) and Task 1.7b (barrel export pattern for commands) must be completed first. - -**Context**: The persistent Studio plugin needs to be installed into Roblox Studio's plugins folder. This task implements `studio-bridge install-plugin` and `studio-bridge uninstall-plugin` CLI commands that build and manage the plugin file. - -**Objective**: Implement CLI commands to install and uninstall the persistent plugin, plus a detection utility. - -**Read First**: -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/cli/cli.ts` (to see how commands are registered) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/cli/commands/exec-command.ts` (pattern for yargs CommandModule) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/cli/args/global-args.ts` (global args interface) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/plugin/plugin-injector.ts` (existing plugin build pattern with rojo, template helpers, findPluginsFolder) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/process/studio-process-manager.ts` (for `findPluginsFolder`) - -**Files to Create**: -- `src/cli/commands/install-plugin-command.ts` -- `InstallPluginCommand` class -- `src/cli/commands/uninstall-plugin-command.ts` -- `UninstallPluginCommand` class -- `src/plugin/persistent-plugin-installer.ts` -- shared install/uninstall logic -- `src/plugin/plugin-discovery.ts` -- `isPersistentPluginInstalled(): boolean` - -**Files to Modify**: -- `src/cli/cli.ts` -- register both commands - -**Requirements**: - -1. Implement `src/plugin/plugin-discovery.ts`: - -```typescript -import * as fs from 'fs'; -import * as path from 'path'; -import { findPluginsFolder } from '../process/studio-process-manager.js'; - -const PERSISTENT_PLUGIN_FILENAME = 'StudioBridgePersistentPlugin.rbxm'; - -export function getPersistentPluginPath(): string { - return path.join(findPluginsFolder(), PERSISTENT_PLUGIN_FILENAME); -} - -export function isPersistentPluginInstalled(): boolean { - return fs.existsSync(getPersistentPluginPath()); -} -``` - -2. Implement `src/plugin/persistent-plugin-installer.ts`: - - `async installPersistentPluginAsync(): Promise` -- builds the persistent plugin template via rojo, copies the output `.rbxm` to the plugins folder as `StudioBridgePersistentPlugin.rbxm`. Returns the installed path. - - `async uninstallPersistentPluginAsync(): Promise` -- removes the plugin file. Throws if not installed. - - Use `BuildContext`, `TemplateHelper`, and `resolveTemplatePath` from `@quenty/nevermore-template-helpers` (same pattern as `plugin-injector.ts`). - - The template directory is `templates/studio-bridge-plugin/`. Note: this directory may not exist yet (Task 2.1 creates it). The installer code should be correct for when the template exists. If the template does not exist, the build will fail with a clear rojo error. - -3. Implement `InstallPluginCommand` following the yargs CommandModule pattern: - - Command: `install-plugin` - - Description: `Install the persistent Studio Bridge plugin` - - No additional arguments beyond global args. - - Handler calls `installPersistentPluginAsync()`, prints the installed path on success. - - On error, prints the error message and exits with code 1. - -4. Implement `UninstallPluginCommand`: - - Command: `uninstall-plugin` - - Description: `Remove the persistent Studio Bridge plugin` - - Handler calls `uninstallPersistentPluginAsync()`, prints confirmation on success. - - If not installed, prints a message and exits cleanly. - -5. Register both commands in `src/commands/index.ts` (NOT `cli.ts`): - -```typescript -// In src/commands/index.ts, add: -export { installPluginCommand } from './install-plugin.js'; -export { uninstallPluginCommand } from './uninstall-plugin.js'; - -// And add both to the allCommands array. -``` - -`cli.ts` already registers all commands via a loop over `allCommands` (established in Task 1.7b). Do NOT add per-command `.command()` calls to `cli.ts`. - -**Acceptance Criteria**: -- `studio-bridge install-plugin` builds and writes `StudioBridgePersistentPlugin.rbxm` to the Studio plugins folder. -- Running it again overwrites the existing file. -- `studio-bridge uninstall-plugin` removes the file. -- `isPersistentPluginInstalled()` returns `true` when the file exists, `false` otherwise. -- `src/commands/index.ts` exports both commands and includes them in `allCommands`. -- Both commands print clear success/failure messages with the file path. -- Commands follow the same error handling pattern as `ExecCommand`. -- **PluginManager generality test**: The following concrete test must pass: - -```typescript -describe('PluginManager generality', () => { - it('registers and builds a second template without code changes', async () => { - const manager = new PluginManager(); - manager.registerTemplate(studioBridgeTemplate); - manager.registerTemplate({ - name: 'test-plugin', - templateDir: path.join(__dirname, 'fixtures/test-plugin-template'), - buildConstants: { TEST_VALUE: 'hello' }, - outputFilename: 'test-plugin.rbxm', - version: '1.0.0', - }); - const built = await manager.buildAsync('test-plugin'); - expect(built.filePath).toContain('test-plugin.rbxm'); - const installed = await manager.installAsync('test-plugin'); - expect(installed.name).toBe('test-plugin'); - const list = await manager.listInstalledAsync(); - expect(list).toHaveLength(2); - }); -}); -``` - - The test fixture `fixtures/test-plugin-template/` must be created with a minimal `default.project.json` and a single `.lua` file sufficient for Rojo to produce a valid `.rbxm`. Example minimal structure: - - ``` - fixtures/test-plugin-template/ - default.project.json # { "name": "TestPlugin", "tree": { "$path": "src" } } - src/ - init.lua # return {} - ``` - -**Do NOT**: -- Modify `cli.ts` to register commands -- add them to `src/commands/index.ts` instead. -- Create the persistent plugin template directory (that is Task 2.1). -- Use default exports. -- Forget `.js` extensions on local imports. - ---- - -## Handoff Notes for Tasks Requiring Orchestrator Coordination or Review - -The following Phase 2 tasks benefit from orchestrator coordination, a review agent, or Studio validation. They can be implemented by a skilled agent but require additional verification. Brief handoff notes are provided instead of full prompts. - -### Task 2.1: Persistent Plugin Core (Luau) - -**Prerequisites**: Phase 0.5 (all plugin modules: 0.5.1-0.5.3) and Task 1.1 (protocol v2 types) must be completed first. - -**Why requires review**: This is a complex Luau plugin with Studio-specific APIs (`HttpService:CreateWebStreamClient`, `RunService`, `LogService`). Code quality and structure can be reviewed by a review agent; however, runtime behavior (WebSocket connectivity, state machine transitions, reconnection) requires Studio validation (deferred to Phase 6 E2E). The Luau codebase uses a custom module loader, but Lune tests cover the Layer 1 modules. - -**Handoff**: The plugin implements a state machine (idle -> searching -> connecting -> connected -> reconnecting) with HTTP discovery polling, WebSocket connection management, v2 handshake with capability advertisement, heartbeat sending, and exponential backoff reconnection. Reference the full spec in `studio-bridge/plans/tech-specs/03-persistent-plugin.md`. The plugin template goes in `templates/studio-bridge-plugin/`. - -**Wiring sequence** (step-by-step guide for connecting Phase 0.5 Layer 1 modules to Roblox services): -1. Import `Protocol` module from `src/Shared/Protocol.luau` (Phase 0.5) -2. Import `DiscoveryStateMachine` from `src/Shared/DiscoveryStateMachine.luau` (Phase 0.5) -3. Import `ActionRouter` from `src/Shared/ActionRouter.luau` (Phase 0.5) -4. Import `MessageBuffer` from `src/Shared/MessageBuffer.luau` (Phase 0.5) -5. Read build constants (`{{PORT}}`, `{{SESSION_ID}}`, `{{IS_EPHEMERAL}}`). Detect ephemeral vs persistent mode using the following explicit check: - -```lua --- Build constants are Handlebars templates before substitution -local IS_EPHEMERAL = (PORT ~= "{{PORT}}") -if IS_EPHEMERAL then - -- Connect directly using substituted build constants -else - -- Enter discovery state machine (persistent mode) -end -``` - -If `IS_EPHEMERAL` is true (build constants were substituted by Handlebars), the plugin connects directly to the known port. If false (build constants are still literal template strings), the plugin enters the discovery state machine. - -6. In plugin init, create `DiscoveryStateMachine` with injected callbacks: - - `onHttpPoll = function(url) return HttpService:GetAsync(url) end` - - `onWebSocketConnect = function(url) return HttpService:CreateWebStreamClient(url) end` - - `onStateChange = function(old, new) -- log transition end` -7. On discovery success (or immediate connect in ephemeral mode), create WebSocket connection. -8. Wire `WebSocket.OnMessage` -> `Protocol.decode()` -> `ActionRouter:dispatch()` for incoming messages. -9. Wire `ActionRouter` responses through `Protocol.encode()` -> `WebSocket:Send()` for outgoing messages. -10. Start heartbeat coroutine: `task.spawn(function() while stateMachine:isConnected() do ... task.wait(15) end end)` using the pattern from `studio-bridge/plans/tech-specs/03-persistent-plugin.md` section 6.3. Do NOT use `task.cancel`. -11. Wire `LogService.MessageOut:Connect()` -> `MessageBuffer:push()` for log buffering. -12. Wire `RunService` state detection: check `RunService:IsRunMode()`, `RunService:IsStudio()`, `RunService:IsRunning()` to determine context (`edit`, `client`, `server`). -13. Send `register` message with all capabilities and session identity fields. - -After every change to files in `templates/studio-bridge-plugin/`, run `rojo build templates/studio-bridge-plugin/default.project.json -o dist/studio-bridge-plugin.rbxm` to verify the Rojo build still succeeds. This is your primary validation signal for Luau code structure. The output file `dist/studio-bridge-plugin.rbxm` must exist and be > 1KB. - -**Lune test expectations**: Rojo build succeeds. Module structure matches `default.project.json` tree. All Luau modules required by the entry point are resolvable within the Rojo project. - ---- - -## Task 2.2: Execute Action Handler in Plugin (Luau) - -**Prerequisites**: Task 2.1 (persistent plugin core) must be completed first. - -**Context**: The persistent plugin receives `execute` messages from the server containing Luau script code. This task implements the action handler that receives these messages, executes the code via `loadstring`, captures output, and sends back `scriptComplete` with the result. This is the Luau-side counterpart to the server's existing `executeAsync` method. - -**Objective**: Create an execute action handler module that registers with the `ActionRouter` (from Phase 0.5), handles `requestId` correlation for v2 protocol, queues concurrent execute requests, and processes them sequentially. - -**Read First**: -- `/workspaces/NevermoreEngine/tools/studio-bridge/templates/studio-bridge-plugin/src/Shared/ActionRouter.luau` (from Phase 0.5 -- the router this handler registers with) -- `/workspaces/NevermoreEngine/tools/studio-bridge/templates/studio-bridge-plugin/src/Shared/Protocol.luau` (message encoding/decoding) -- `/workspaces/NevermoreEngine/tools/studio-bridge/templates/studio-bridge-plugin/src/StudioBridgePlugin.server.lua` (existing plugin entry point -- see how `execute` is handled in the temporary plugin for reference) -- `/workspaces/NevermoreEngine/studio-bridge/plans/tech-specs/04-action-specs.md` (execute action specification) - -**Files to Create**: -- `templates/studio-bridge-plugin/src/Actions/ExecuteAction.luau` -- the execute action handler module -- `templates/studio-bridge-plugin/test/execute-handler.test.luau` -- Lune tests - -**Requirements**: - -1. Create the execute action handler module: - -```luau -local ExecuteAction = {} - --- Register this handler with the ActionRouter -function ExecuteAction.register(router, sendMessage) - router:register("execute", function(payload, requestId, sessionId) - return ExecuteAction._handleExecute(payload, requestId, sessionId, sendMessage) - end) -end -``` - -2. **requestId handling** (critical for v2 protocol): - - The incoming `execute` message MAY have a `requestId` (v2) or may NOT (v1). - - If `requestId` is present, it MUST be echoed in the `scriptComplete` response message AND in all `output` messages generated during execution. - - If `requestId` is absent (v1 fallback), send `scriptComplete` and `output` without a `requestId` field. - - The `requestId` is how the server correlates the response back to the original request in the `PendingRequestMap`. - -3. **Error handling** -- handle these distinct failure modes: - - **`loadstring` failure** (syntax error in the script): `loadstring(code)` returns `nil, errorMessage`. Send `scriptComplete` with `success: false`, `error: errorMessage`, error code `SCRIPT_LOAD_ERROR`. Do NOT call the function. - - **Runtime error** (script executes but throws): Wrap the function call in `pcall`. If `pcall` returns `false`, send `scriptComplete` with `success: false`, `error: errorString`, error code `SCRIPT_RUNTIME_ERROR`. - - **Timeout**: If the script runs longer than the timeout specified in the payload (default 120 seconds), terminate execution and send `scriptComplete` with `success: false`, `error: "Script execution timed out after Ns"`, error code `TIMEOUT`. - - **Success**: `pcall` returns `true`. Send `scriptComplete` with `success: true`. - -4. **Output capture**: During script execution, capture `print()` / `warn()` / `error()` output by hooking `LogService.MessageOut`. Each captured line is sent as an `output` message with the matching `requestId` (if present). Output messages are sent as they are captured (streaming), not batched. - -5. **Sequential execution**: Queue concurrent execute requests and process them one at a time. Use a simple FIFO queue. While one script is executing, incoming execute requests are queued. When execution completes (success or error), dequeue and execute the next request. - -6. **Response message format**: - -```luau --- Success: -{ - type = "scriptComplete", - sessionId = sessionId, - requestId = requestId, -- only if present in the original request - payload = { success = true } -} - --- Failure: -{ - type = "scriptComplete", - sessionId = sessionId, - requestId = requestId, -- only if present in the original request - payload = { - success = false, - error = errorMessage, - code = "SCRIPT_LOAD_ERROR" | "SCRIPT_RUNTIME_ERROR" | "TIMEOUT" - } -} -``` - -**Acceptance Criteria**: -- Script execution returns success result with `success: true` in the payload. -- `loadstring` failure returns `scriptComplete` with `success: false` and error code `SCRIPT_LOAD_ERROR`. -- Runtime error (pcall failure) returns `scriptComplete` with `success: false` and error code `SCRIPT_RUNTIME_ERROR`. -- Timeout returns `scriptComplete` with `success: false` and error code `TIMEOUT`. -- `requestId` is echoed in the `scriptComplete` response when present in the original `execute` message. -- `requestId` is omitted from the response when absent in the original `execute` message (v1 compatibility). -- Output messages are sent with the matching `requestId` during execution. -- Concurrent execute requests are queued and processed sequentially. -- After every change, run `rojo build templates/studio-bridge-plugin/default.project.json -o dist/studio-bridge-plugin.rbxm` to verify the build. -- `lune run test/execute-handler.test.luau` passes all tests. - -**Lune Test Cases** (file: `test/execute-handler.test.luau`): -- Script execution returns success/error result. -- `requestId` is echoed in response when present. -- `requestId` is omitted when absent (v1 mode). -- `loadstring` failure returns `SCRIPT_LOAD_ERROR` code. -- Runtime error returns `SCRIPT_RUNTIME_ERROR` code. -- Timeout behavior returns `TIMEOUT` error code. -- Sequential queueing: second request waits for first to complete. - -**Do NOT**: -- Use any Roblox APIs directly in the module (inject via callbacks for testability where possible). -- Use default exports. -- Forget to echo `requestId` in both `output` and `scriptComplete` messages. - ---- - -## Task 2.5: Persistent Plugin Detection and Fallback - -**Prerequisites**: Tasks 2.3 (health endpoint) and 2.4 (plugin installer + plugin-discovery.ts) must be completed first. - -**Context**: When the studio-bridge server starts, it needs to decide whether to inject the temporary plugin (the v1 behavior) or wait for the persistent plugin to connect on its own. If the persistent plugin is installed, the server should skip injection and wait for the plugin to discover the server via the health endpoint. If the persistent plugin is NOT installed, the server falls back to temporary injection. There is a grace period to handle the case where the persistent plugin is installed but has not yet discovered the server. - -**Objective**: Modify `StudioBridgeServer.startAsync()` to check `isPersistentPluginInstalled()` and either wait for the persistent plugin or fall back to temporary injection. Add a `preferPersistentPlugin` option. - -**Read First**: -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/server/studio-bridge-server.ts` (the file you will modify) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/plugin/plugin-discovery.ts` (from Task 2.4 -- `isPersistentPluginInstalled()`) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/plugin/plugin-injector.ts` (existing temporary injection logic) - -**Files to Modify**: -- `src/server/studio-bridge-server.ts` -- modify `startAsync` to check for persistent plugin - -**Requirements**: - -1. Add `preferPersistentPlugin?: boolean` to `StudioBridgeServerOptions`: - -```typescript -export interface StudioBridgeServerOptions { - // ... existing options ... - preferPersistentPlugin?: boolean; // Default: true -} -``` - -2. Modify `startAsync` to add persistent plugin detection: - -```typescript -async startAsync(): Promise { - // ... existing setup (WebSocket server, health endpoint, etc.) ... - - const preferPersistent = this._options.preferPersistentPlugin ?? true; - - if (preferPersistent && isPersistentPluginInstalled()) { - // Persistent plugin is installed. Skip injection and wait for the - // plugin to discover us via the health endpoint. - // Start a grace period timer: if the plugin does not connect within - // the grace period, fall back to temporary injection. - const graceMs = 3_000; // 3 seconds - const connected = await this._waitForPluginConnectionAsync(graceMs); - if (!connected) { - // Grace period expired. Plugin may not be running in Studio. - // Fall back to temporary injection. - await this._injectPluginAsync(); - } - } else { - // No persistent plugin or preference disabled (CI mode). - // Use temporary injection (existing v1 behavior). - await this._injectPluginAsync(); - } -} -``` - -3. Implement `_waitForPluginConnectionAsync(graceMs: number): Promise`: - - Start listening for plugin connections on the WebSocket server. - - If a plugin connects (sends `hello` or `register`) within `graceMs`, return `true`. - - If the grace period expires without a connection, return `false`. - - This is a non-blocking wait with a timeout, not a blocking sleep. - -4. The default grace period is **3 seconds**. This is long enough for a running Studio instance with the persistent plugin to discover the server (plugin polls every 2 seconds), but short enough that users do not perceive a significant delay when the plugin is not running. - -5. When `preferPersistentPlugin` is set to `false`, the server always uses temporary injection, regardless of whether the persistent plugin is installed. This is the behavior for CI environments. - -**Acceptance Criteria**: -- When persistent plugin is installed and running: server waits, plugin connects within 3 seconds, no temporary injection occurs. -- When persistent plugin is installed but NOT running: server waits 3 seconds, then falls back to temporary injection. -- When persistent plugin is NOT installed: server immediately uses temporary injection. -- When `preferPersistentPlugin: false`: server immediately uses temporary injection regardless of plugin installation. -- Grace period is exactly 3 seconds (not configurable externally, but clear in the code). -- Existing `startAsync` behavior is unchanged when `preferPersistentPlugin` is not set and the persistent plugin is not installed. -- `StudioBridgeServerOptions` type includes the new field. - -**Test Cases**: -- Grace period expiry: mock `isPersistentPluginInstalled` to return `true`, do NOT connect a plugin, verify that temporary injection is called after 3 seconds. -- Plugin connects within grace period: mock `isPersistentPluginInstalled` to return `true`, connect a mock plugin after 1 second, verify no temporary injection. -- `preferPersistentPlugin: false`: mock `isPersistentPluginInstalled` to return `true`, verify temporary injection is called immediately. -- Plugin not installed: `isPersistentPluginInstalled` returns `false`, verify temporary injection is called immediately. - -**Do NOT**: -- Change any existing public method signatures on `StudioBridgeServer`. -- Make the grace period configurable via the public API (keep it as an internal constant). -- Use default exports. -- Forget `.js` extensions on local imports. - -### Task 2.6: Session Selection for Existing Commands - -**Prerequisites**: Tasks 1.3d5 (BridgeConnection), 1.4 (StudioBridge wrapper), 1.7a (shared CLI utilities), and 1.7b (barrel export pattern) must be completed first. - -**Why requires review**: Session resolution UX and handler pattern consistency benefit from review agent verification to ensure patterns match the reference command. The full CLI flow can be tested programmatically. - -**Handoff**: Add `--session` / `-s` global option to `cli.ts` (global options only, not per-command registration). Use `resolveSessionAsync()` from `src/cli/resolve-session.ts` for session disambiguation (created in Task 1.7a). Follow the `sessions` command pattern established in `src/commands/sessions.ts` and `src/cli/commands/sessions-command.ts` (Task 1.7b) for the handler/wiring split. Create `src/commands/exec.ts`, `src/commands/run.ts`, and `src/commands/launch.ts` command handlers. Add all three to `src/commands/index.ts` barrel file and `allCommands` array. Do NOT add per-command `.command()` calls to `cli.ts` -- it already loops over `allCommands`. Update `terminal` commands to use `resolveSessionAsync()` and `formatOutput()` from `src/cli/format-output.ts`. Reference the session resolution table in `studio-bridge/plans/tech-specs/02-command-system.md` section 4.1. - ---- - -## Cross-References - -- Phase plan: [studio-bridge/plans/execution/phases/02-plugin.md](../phases/02-plugin.md) -- Validation: [studio-bridge/plans/execution/validation/02-plugin.md](../validation/02-plugin.md) -- Tech specs: `studio-bridge/plans/tech-specs/03-persistent-plugin.md`, `studio-bridge/plans/tech-specs/04-action-specs.md` diff --git a/studio-bridge/plans/execution/agent-prompts/03-commands.md b/studio-bridge/plans/execution/agent-prompts/03-commands.md deleted file mode 100644 index b8e855f265..0000000000 --- a/studio-bridge/plans/execution/agent-prompts/03-commands.md +++ /dev/null @@ -1,423 +0,0 @@ -# Phase 3: New Actions (Commands) -- Agent Prompts - -**Phase reference**: [studio-bridge/plans/execution/phases/03-commands.md](../phases/03-commands.md) -**Validation**: [studio-bridge/plans/execution/validation/03-commands.md](../validation/03-commands.md) - -Base path for all file references: `/workspaces/NevermoreEngine/tools/studio-bridge/` - -## How to Use These Prompts - -1. Copy the full prompt for a single task into a Claude Code sub-agent session. -2. The agent should read the "Read First" files, then implement the "Requirements" section. -3. The agent should run the acceptance criteria checks before reporting completion. -4. Do not give an agent a task whose dependencies have not been completed yet (see the dependency graph in [studio-bridge/plans/execution/phases/03-commands.md](../phases/03-commands.md)). - -Key conventions that apply to every prompt: - -- **TypeScript ESM** with `.js` extensions on all local imports (e.g., `import { Foo } from './foo.js';`) -- **`Async` suffix** on all async functions (e.g., `listSessionsAsync`, `resolveRequestAsync`) -- **Private `_` prefix** on all private fields and methods -- **vitest** for tests: `describe`/`it`/`expect`, test files named `*.test.ts` alongside source -- **No default exports** -- always use named exports -- **yargs `CommandModule` pattern** for CLI commands (class with `command`, `describe`, `builder`, `handler`) -- **`@quenty/` scoped packages** for workspace imports (e.g., `@quenty/cli-output-helpers`) -- **`OutputHelper`** from `@quenty/cli-output-helpers` for all user-facing output -- **Copy the `sessions` command pattern**: Every command follows the handler/wiring split established by `src/commands/sessions.ts` and `src/cli/commands/sessions-command.ts` (Task 1.7b). Use `resolveSession()` from `src/cli/resolve-session.ts` and `formatOutput()` from `src/cli/format-output.ts`. -- **Barrel export pattern for command registration**: When adding a new command, add its export to `src/commands/index.ts` and add it to the `allCommands` array. Do NOT modify `cli.ts` -- it already registers all commands via a loop over `allCommands` (established in Task 1.7b). This pattern prevents merge conflicts when multiple tasks add commands in parallel. - ---- - -## Task 3.1: State Query Action - -**Prerequisites**: Tasks 1.6 (action dispatch), 1.7b (barrel export pattern for commands), and 2.1 (persistent plugin core) must be completed first. - -**Context**: Studio-bridge needs to query the current state of a connected Roblox Studio instance (edit mode vs. play mode, place info). This task implements the server-side handler and CLI command. The plugin-side handler (Luau) is a separate task. - -**Objective**: Implement the server-side state query wrapper and the `studio-bridge state` CLI command, following the same structure as the `sessions` command. - -**Read First**: -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/commands/sessions.ts` (the reference command handler -- copy this structure) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/cli/commands/sessions-command.ts` (the reference CLI wiring -- copy this structure) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/cli/resolve-session.ts` (session resolution utility) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/cli/format-output.ts` (output formatting utility) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/server/studio-bridge-server.ts` (the server with `performActionAsync` from Task 1.6) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/server/web-socket-protocol.ts` (v2 types: `QueryStateMessage`, `StateResultMessage`, `StudioState`) - -**Files to Create**: -- `src/commands/state.ts` -- command handler following the same structure as `src/commands/sessions.ts` -- `src/cli/commands/state-command.ts` -- CLI wiring following `src/cli/commands/sessions-command.ts` - -**Files to Modify**: -- `src/commands/index.ts` -- add `stateCommand` export and add it to the `allCommands` array. Do NOT modify `cli.ts`. - -**Requirements**: - -1. Create `src/commands/state.ts` following the same structure as `src/commands/sessions.ts`: - -```typescript -import type { BridgeConnection } from '../server/bridge-connection.js'; -import type { CommandResult } from '../cli/types.js'; -import type { StateResultMessage } from '../server/web-socket-protocol.js'; - -export interface StateOptions { - session?: string; - instance?: string; - context?: string; - json?: boolean; - watch?: boolean; -} - -export interface StateQueryResult { - state: string; - placeId: number; - placeName: string; - gameId: number; -} - -export async function queryStateAsync( - connection: BridgeConnection, - options: StateOptions = {} -): Promise { - // 1. Use resolveSessionAsync() from src/cli/resolve-session.ts to find the target session - // 2. Send queryState via performActionAsync on the resolved session's server - // 3. Return { data: stateResult, summary: "Mode: Edit" } -} -``` - -2. Create `src/cli/commands/state-command.ts` following `src/cli/commands/sessions-command.ts`: - - Command: `state` - - Description: `Query the current Studio state` - - Args: `--session` / `-s`, `--instance`, `--context`, `--json`, `--watch` / `-w` - - Handler: - - Use `resolveSessionAsync()` from `src/cli/resolve-session.ts` for session disambiguation - - Call `queryStateAsync(connection, options)` - - Use `formatOutput()` from `src/cli/format-output.ts` for output - - If `--watch`, subscribe to `stateChange` events via the WebSocket push subscription protocol (`subscribe { events: ['stateChange'] }`) and print updates as `stateChange` push messages arrive. On Ctrl+C, send `unsubscribe { events: ['stateChange'] }`. See `01-protocol.md` section 5.2 and `07-bridge-network.md` section 5.3. (If subscribe is not available yet, log "watch not yet supported" and exit.) - -3. Register in `src/commands/index.ts` (NOT `cli.ts`): - -```typescript -// In src/commands/index.ts, add: -export { stateCommand } from './state.js'; - -// And add to the allCommands array: -import { stateCommand } from './state.js'; -// ... stateCommand in the allCommands array -``` - -**Acceptance Criteria**: -- `queryStateAsync` returns a typed `CommandResult`. -- `src/commands/index.ts` exports `stateCommand` and includes it in `allCommands`. -- `studio-bridge state` prints state info in human-readable format. -- `--json` outputs structured JSON via `formatOutput`. -- Session resolution works via `--session`, `--instance`, `--context` flags. -- Timeout after 5 seconds produces a clear error. -- **Lune test plan**: Test file: `test/state-action.test.luau`. Required test cases: StudioState values are correct strings (e.g. `"Edit"`, `"Play"`, `"Run"`, `"Paused"`), `--watch` sends subscribe message with `stateChange` event, requestId is echoed in response. - -**Do NOT**: -- Modify `cli.ts` to register the command -- add it to `src/commands/index.ts` instead. -- Implement the plugin-side Luau handler (separate task). -- Use default exports. -- Forget `.js` extensions on local imports. - ---- - -## Task 3.3: Log Query Action - -**Prerequisites**: Tasks 1.6 (action dispatch), 1.7b (barrel export pattern for commands), and 2.1 (persistent plugin core) must be completed first. - -**Context**: Studio-bridge needs to retrieve buffered log history from the connected Studio plugin. Logs are stored in a ring buffer on the plugin side. The server sends a `queryLogs` request and receives a `logsResult` response. - -**Objective**: Implement the server-side log query wrapper and the `studio-bridge logs` CLI command, following the same structure as the `sessions` command. - -**Read First**: -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/commands/sessions.ts` (the reference command handler -- copy this structure) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/cli/commands/sessions-command.ts` (the reference CLI wiring -- copy this structure) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/cli/resolve-session.ts` (session resolution utility) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/cli/format-output.ts` (output formatting utility) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/server/studio-bridge-server.ts` (server with `performActionAsync`) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/server/web-socket-protocol.ts` (v2 types: `QueryLogsMessage`, `LogsResultMessage`, `OutputLevel`) - -**Files to Create**: -- `src/commands/logs.ts` -- command handler following the same structure as `src/commands/sessions.ts` -- `src/cli/commands/logs-command.ts` -- CLI wiring following `src/cli/commands/sessions-command.ts` - -**Files to Modify**: -- `src/commands/index.ts` -- add `logsCommand` export and add it to the `allCommands` array. Do NOT modify `cli.ts`. - -**Requirements**: - -1. Create `src/commands/logs.ts` following the same structure as `src/commands/sessions.ts`: - -```typescript -import type { BridgeConnection } from '../server/bridge-connection.js'; -import type { CommandResult } from '../cli/types.js'; -import type { LogsResultMessage, OutputLevel } from '../server/web-socket-protocol.js'; - -export interface LogsOptions { - session?: string; - instance?: string; - context?: string; - json?: boolean; - count?: number; - direction?: 'head' | 'tail'; - levels?: OutputLevel[]; - includeInternal?: boolean; - follow?: boolean; -} - -export interface LogEntry { - level: OutputLevel; - body: string; - timestamp: number; -} - -export interface LogsQueryResult { - entries: LogEntry[]; - total: number; - bufferCapacity: number; -} - -export async function queryLogsAsync( - connection: BridgeConnection, - options: LogsOptions = {} -): Promise { - // 1. Use resolveSessionAsync() from src/cli/resolve-session.ts to find the target session - // 2. Send queryLogs via performActionAsync on the resolved session's server - // 3. Return { data: logsResult, summary: "N entries (M total in buffer)" } -} -``` - -2. Create `src/cli/commands/logs-command.ts` following `src/cli/commands/sessions-command.ts`: - - Command: `logs` - - Description: `Retrieve and stream output logs from Studio` - - Args: `--session` / `-s`, `--instance`, `--context`, `--json`, `--tail` (number, default 50), `--head` (number), `--follow` / `-f`, `--level` / `-l` (string, comma-separated), `--all` - - Handler: - - Determine `direction` and `count`: if `--head` is provided use `direction: 'head'` with that count. Otherwise use `direction: 'tail'` with `--tail` value (default 50). - - Parse `--level` into an array of `OutputLevel` strings. - - Use `resolveSessionAsync()` from `src/cli/resolve-session.ts` for session disambiguation - - Call `queryLogsAsync(connection, options)` - - Use `formatOutput()` from `src/cli/format-output.ts` for output - - If `--follow`, after printing the initial batch, subscribe to `logPush` events via the WebSocket push subscription protocol (`subscribe { events: ['logPush'] }`) and print new log entries as `logPush` push messages arrive. Continue until Ctrl+C, then send `unsubscribe { events: ['logPush'] }`. Note: `logPush` is distinct from `output` (which is batched and scoped to a single `execute` request). See `01-protocol.md` section 5.2 and `07-bridge-network.md` section 5.3. (If subscribe is not available yet, print a message and exit.) - -3. Register in `src/commands/index.ts` (NOT `cli.ts`): - -```typescript -// In src/commands/index.ts, add: -export { logsCommand } from './logs.js'; - -// And add to the allCommands array: -import { logsCommand } from './logs.js'; -// ... logsCommand in the allCommands array -``` - -**Acceptance Criteria**: -- `queryLogsAsync` returns a typed `CommandResult`. -- `src/commands/index.ts` exports `logsCommand` and includes it in `allCommands`. -- `studio-bridge logs` prints the last 50 log lines by default. -- `--tail 100` prints the last 100. -- `--head 20` prints the first 20. -- `--level Error,Warning` filters correctly. -- `--all` includes internal messages. -- `--json` outputs JSON lines via `formatOutput`. -- Session resolution works via `--session`, `--instance`, `--context` flags. -- Timeout after 10 seconds with a clear error. -- **Lune test plan**: Test file: `test/log-action.test.luau`. Required test cases: returns entries array with correct shape, `--follow` sends subscribe message with `logPush` event, level filter works (filters entries by OutputLevel), ring buffer respects count limit and evicts oldest entries, requestId is echoed in response. - -**Do NOT**: -- Modify `cli.ts` to register the command -- add it to `src/commands/index.ts` instead. -- Implement the plugin-side ring buffer (separate Luau task). -- Use default exports. -- Forget `.js` extensions on local imports. - ---- - -## Handoff Notes for Tasks Requiring Orchestrator Coordination or Review - -The following Phase 3 tasks benefit from orchestrator coordination, a review agent, or Studio validation. They can be implemented by a skilled agent but require additional verification. Brief handoff notes are provided instead of full prompts. - -All handoff tasks should follow the `sessions` command pattern: -- Create `src/commands/.ts` following the same structure as `src/commands/sessions.ts` -- Create `src/cli/commands/-command.ts` following `src/cli/commands/sessions-command.ts` -- Add the command export to `src/commands/index.ts` and add it to the `allCommands` array. Do NOT modify `cli.ts`. -- Use `resolveSession()` from `src/cli/resolve-session.ts` and `formatOutput()` from `src/cli/format-output.ts` - -### Task 3.2: Screenshot Capture Action - -**Prerequisites**: Tasks 1.6 (action dispatch), 1.7b (barrel export pattern for commands), and 2.1 (persistent plugin core) must be completed first. - -**Why requires review**: `CaptureService` is confirmed working in Studio plugins. Code quality and mock tests can be verified by a review agent; runtime edge cases (minimized window, rendering errors) require Studio validation. - -**Handoff**: Create `src/commands/screenshot.ts` following the same structure as `src/commands/sessions.ts`. Create `src/cli/commands/screenshot-command.ts` following `src/cli/commands/sessions-command.ts`. Use `resolveSession()` from `src/cli/resolve-session.ts` and `formatOutput()` from `src/cli/format-output.ts`. Plugin side uses the confirmed CaptureService call chain: (1) `CaptureService:CaptureScreenshot(function(contentId) ... end)` to capture the viewport (callback receives a `contentId` string), (2) `AssetService:CreateEditableImageAsync(contentId)` to load the content into an `EditableImage`, (3) `editableImage:ReadPixels(...)` to extract raw pixel bytes, (4) base64-encode the bytes, (5) read dimensions from `editableImage.Size`. Each step is wrapped in `pcall` with error handling for runtime failures. Note: implementer should verify exact `EditableImage` method names against the Roblox API at implementation time. Server side writes base64 to temp PNG file. CLI has `--output`, `--open`, `--base64` flags. The `captureScreenshot` capability is always advertised (CaptureService is available in plugin context). - -**Lune test plan**: Test file: `test/screenshot-action.test.luau`. Required test cases: returns base64 data with dimensions, error on CaptureService failure returns protocol error message, requestId is echoed in response. - -### Task 3.4: DataModel Query Action - -**Prerequisites**: Tasks 1.6 (action dispatch), 1.7b (barrel export pattern for commands), and 2.1 (persistent plugin core) must be completed first. - -**Why requires review**: Complex Roblox type serialization (`Vector3`, `CFrame`, `Color3`, etc.). Code quality and serialization logic can be verified by a review agent using mock tests; full type coverage requires Studio validation against actual Roblox property types. - -**Handoff**: Create `src/commands/query.ts` following the same structure as `src/commands/sessions.ts`. Create `src/cli/commands/query-command.ts` following `src/cli/commands/sessions-command.ts`. Use `resolveSession()` from `src/cli/resolve-session.ts` and `formatOutput()` from `src/cli/format-output.ts`. Plugin resolves dot-separated paths from `game` by splitting on `.` and calling `FindFirstChild` at each segment. Reads properties and serializes to `SerializedValue` format: primitives (string, number, boolean) pass as bare JSON values; Roblox types use `{ type: "...", value: [...] }` with flat arrays (e.g., Vector3 as `{ "type": "Vector3", "value": [1, 2, 3] }`, CFrame as `{ "type": "CFrame", "value": [x, y, z, r00, r01, r02, r10, r11, r12, r20, r21, r22] }`, EnumItem as `{ "type": "EnumItem", "enum": "Material", "name": "Plastic", "value": 256 }`, Instance ref as `{ "type": "Instance", "className": "Part", "path": "game.Workspace.Part1" }`). See `04-action-specs.md` section 6 for the full SerializedValue format and path documentation. CLI accepts paths without `game.` prefix and prepends it. Support `--children`, `--descendants`, `--properties`, `--attributes`, `--depth`, `--json` flags. Note: instance names containing dots are an edge case -- the dot is treated as a path separator, so names with literal dots will not resolve correctly (known limitation, may be addressed with escaping in a future version). - -**Lune test plan**: Test file: `test/datamodel-action.test.luau`. Required test cases: dot-path resolution walks FindFirstChild correctly, SerializedValue format is correct for each type (Vector3 as `{ type, value: [x,y,z] }`, CFrame as flat 12-element array, Color3, UDim2, UDim, EnumItem, Instance ref, primitives as bare values), error cases return protocol error messages for invalid paths, requestId is echoed in response. - -### Task 3.5: Terminal Mode Dot-Commands for New Actions - -**Prerequisites**: Tasks 1.7b (barrel export pattern), 2.6 (exec/run refactor), 3.1, 3.2, 3.3, and 3.4 (all action commands) must be completed first. - -**Why requires review**: Interactive REPL wiring to adapter registry. Review agent verifies dispatch pattern and dot-command coverage. E2e test spec (below) provides automated validation of the terminal behavior. - -**Handoff**: Add `.state`, `.screenshot`, `.logs`, `.query`, `.sessions`, `.connect`, `.disconnect` to the terminal REPL. Wire to the shared command handlers in `src/commands/`. Each dot-command calls the same handler function as the CLI command (e.g., `.state` calls `queryStateAsync` from `src/commands/state.ts`), using `formatOutput()` from `src/cli/format-output.ts` for consistent output. Reference the terminal adapter design in `studio-bridge/plans/tech-specs/02-command-system.md` section 6. - -**Wiring sequence** (step-by-step guide for connecting the terminal adapter registry): -1. Import all command definitions from `src/commands/index.ts` (the barrel file: `sessionsCommand`, `stateCommand`, `screenshotCommand`, `logsCommand`, `queryCommand`, `execCommand`, `runCommand`). -2. Create `connectCommand` in `src/commands/connect.ts` -- handler calls `connection.resolveSession(sessionId)` and stores the result as the active session in terminal state. -3. Create `disconnectCommand` in `src/commands/disconnect.ts` -- handler clears the active session reference without killing Studio (for persistent sessions). -4. Import `connectCommand` and `disconnectCommand` into `terminal-mode.ts`. -5. Build the dot-command dispatcher: `const dotCommands = createDotCommandHandler([sessionsCommand, stateCommand, screenshotCommand, logsCommand, queryCommand, execCommand, runCommand, connectCommand, disconnectCommand])`. -6. In `terminal-editor.ts`, replace the hard-coded if/else dot-command chain (lines 342-403) with: `if (input.startsWith('.')) { const result = await dotCommands.dispatch(input, connection, activeSession); if (result) { formatOutput(result, terminalOutputStream); } }`. -7. Keep `.help`, `.exit`, `.clear` as built-in commands handled before the adapter dispatch. -8. Auto-generate `.help` output from the registered command definitions: `dotCommands.listCommands().map(cmd => \`.${cmd.name}\` + ' ' + cmd.description)`. -9. Wire the implicit REPL execution path: when input does NOT start with `.`, delegate to the `execCommand` handler with the current `activeSession`. -10. Ensure all dot-command output goes through `formatOutput()` from `src/cli/format-output.ts` for consistent formatting. - -**Concrete output specs for each dot-command**: - -``` -Input: .state -Expected output (connected, Edit mode): - Mode: Edit - Place: MyGame - PlaceId: 12345 - GameId: 67890 - -Input: .sessions -Expected output (two sessions): - ID Context Place State Connected - abc-123 edit MyGame (12345) ready 2m ago - def-456 server MyGame (12345) ready 1m ago - -Input: .screenshot -Expected output: - Screenshot saved to /tmp/studio-bridge/screenshot-2026-02-23-1430.png - -Input: .logs -Expected output (default --tail 50): - [14:30:01] [Print] Hello from server - [14:30:02] [Warning] Something suspicious - [14:30:03] [Error] Script error at line 5 - (50 entries, 342 total in buffer) - -Input: .query Workspace.SpawnLocation -Expected output: - Name: SpawnLocation - ClassName: SpawnLocation - Path: game.Workspace.SpawnLocation - Properties: - Position: { type: "Vector3", value: [0, 5, 0] } - Anchored: true - Size: { type: "Vector3", value: [4, 1.2, 4] } - Children: 0 - -Input: .connect abc-123 -Expected output: - Connected to session abc-123 (edit, MyGame) - -Input: .disconnect -Expected output: - Disconnected from session abc-123 - -Input: .help -Expected output: - .state Query the current Studio state - .sessions List active sessions - .screenshot Capture a screenshot - .logs Retrieve output logs - .query Query the DataModel - .connect Switch to a different session - .disconnect Disconnect from current session - .clear Clear the terminal - .exit Exit terminal mode -``` - -**E2e test spec**: Spawn the terminal as a subprocess, send stdin commands, assert stdout patterns. Test file: `src/test/e2e/terminal-dot-commands.test.ts`. Required test cases: - -```typescript -describe('terminal dot-commands e2e', () => { - // Setup: start a bridge host with a mock plugin connected, - // then spawn `studio-bridge terminal --session ` as a subprocess. - - it('.state prints studio state', async () => { - await sendStdin('.state\n'); - const output = await readStdoutUntil('Mode:'); - expect(output).toContain('Mode:'); - expect(output).toMatch(/Mode:\s+(Edit|Play|Run|Paused)/); - }); - - it('.sessions prints session table', async () => { - await sendStdin('.sessions\n'); - const output = await readStdoutUntil('session(s) connected'); - expect(output).toContain('ID'); - expect(output).toContain('Context'); - }); - - it('.screenshot prints saved path', async () => { - await sendStdin('.screenshot\n'); - const output = await readStdoutUntil('.png'); - expect(output).toMatch(/Screenshot saved to .+\.png/); - }); - - it('.logs prints log entries', async () => { - await sendStdin('.logs\n'); - const output = await readStdoutUntil('total in buffer'); - expect(output).toContain('total in buffer'); - }); - - it('.query prints DataModel node', async () => { - await sendStdin('.query Workspace\n'); - const output = await readStdoutUntil('ClassName:'); - expect(output).toContain('ClassName:'); - }); - - it('.connect switches session', async () => { - await sendStdin('.connect def-456\n'); - const output = await readStdoutUntil('Connected to'); - expect(output).toContain('Connected to session def-456'); - }); - - it('.disconnect disconnects', async () => { - await sendStdin('.disconnect\n'); - const output = await readStdoutUntil('Disconnected'); - expect(output).toContain('Disconnected'); - }); - - it('.help lists all commands', async () => { - await sendStdin('.help\n'); - const output = await readStdoutUntil('.exit'); - expect(output).toContain('.state'); - expect(output).toContain('.sessions'); - expect(output).toContain('.screenshot'); - expect(output).toContain('.logs'); - expect(output).toContain('.query'); - expect(output).toContain('.connect'); - expect(output).toContain('.disconnect'); - }); - - it('unknown dot-command prints error', async () => { - await sendStdin('.notacommand\n'); - const output = await readStdoutUntil('Unknown'); - expect(output).toContain('Unknown command'); - }); -}); -``` - ---- - -## Cross-References - -- Phase plan: [studio-bridge/plans/execution/phases/03-commands.md](../phases/03-commands.md) -- Validation: [studio-bridge/plans/execution/validation/03-commands.md](../validation/03-commands.md) -- Reference command pattern: `src/commands/sessions.ts` + `src/cli/commands/sessions-command.ts` (Task 1.7b) -- Shared utilities: `src/cli/resolve-session.ts`, `src/cli/format-output.ts`, `src/cli/types.ts` (Task 1.7a) -- Tech specs: `studio-bridge/plans/tech-specs/02-command-system.md`, `studio-bridge/plans/tech-specs/04-action-specs.md` diff --git a/studio-bridge/plans/execution/agent-prompts/04-split-server.md b/studio-bridge/plans/execution/agent-prompts/04-split-server.md deleted file mode 100644 index e9b3b5af1d..0000000000 --- a/studio-bridge/plans/execution/agent-prompts/04-split-server.md +++ /dev/null @@ -1,663 +0,0 @@ -# Phase 4: Split Server Mode -- Agent Prompts - -**Phase reference**: [studio-bridge/plans/execution/phases/04-split-server.md](../phases/04-split-server.md) -**Validation**: [studio-bridge/plans/execution/validation/04-split-server.md](../validation/04-split-server.md) - -Base path for all file references: `/workspaces/NevermoreEngine/tools/studio-bridge/` - -## How to Use These Prompts - -1. Copy the full prompt for a single task into a Claude Code sub-agent session. -2. The agent should read the "Read First" files, then implement the "Requirements" section. -3. The agent should run the acceptance criteria checks before reporting completion. -4. Do not give an agent a task whose dependencies have not been completed yet (see the dependency graph in [studio-bridge/plans/execution/phases/04-split-server.md](../phases/04-split-server.md)). - -Key conventions that apply to every prompt: - -- **TypeScript ESM** with `.js` extensions on all local imports (e.g., `import { Foo } from './foo.js';`) -- **`Async` suffix** on all async functions (e.g., `listSessionsAsync`, `disconnectAsync`) -- **Private `_` prefix** on all private fields and methods -- **vitest** for tests: `describe`/`it`/`expect`, test files named `*.test.ts` alongside source -- **No default exports** -- always use named exports -- **yargs `CommandModule` pattern** for CLI commands (class with `command`, `describe`, `builder`, `handler`) -- **`@quenty/` scoped packages** for workspace imports (e.g., `@quenty/cli-output-helpers`) -- **`OutputHelper`** from `@quenty/cli-output-helpers` for all user-facing output -- **Copy the `sessions` command pattern**: Every command follows the handler/wiring split established by `src/commands/sessions.ts` and `src/cli/commands/sessions-command.ts` (Task 1.7b). Use `resolveSession()` from `src/cli/resolve-session.ts` and `formatOutput()` from `src/cli/format-output.ts`. -- **Barrel export pattern for command registration**: When adding a new command, add its export to `src/commands/index.ts` and add it to the `allCommands` array. Do NOT modify `cli.ts` -- it already registers all commands via a loop over `allCommands` (established in Task 1.7b). -- **Consumer invariant**: No code outside `src/bridge/internal/` should know or care whether the bridge host is implicit (first CLI process) or explicit (`studio-bridge serve`). `BridgeConnection` works identically in both cases. Any change that leaks this distinction to consumers is a design violation. - ---- - -## Task 4.1: Serve Command (`studio-bridge serve`) - -**Prerequisites**: Tasks 1.3d5 (BridgeConnection barrel export) and 1.7a (shared CLI utilities) must be completed first. - -**Context**: Split-server mode separates the bridge host and CLI into two processes, typically on two different machines (host OS and devcontainer). The `serve` command starts a dedicated bridge host that stays alive indefinitely, accepting connections from both Studio plugins and CLI clients. This is the same bridge host that any CLI process creates implicitly when it is the first to bind port 38741 -- the only difference is that `serve` always becomes the host (never falls back to client mode) and never exits on idle. - -**Objective**: Implement `studio-bridge serve` as a `CommandDefinition` handler in `src/commands/serve.ts`. This is a thin wrapper around `BridgeConnection.connectAsync({ keepAlive: true })` with signal handling, structured logging, and port contention error handling. - -**Read First**: -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/commands/sessions.ts` (the reference command handler -- copy this structure) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/cli/commands/sessions-command.ts` (the reference CLI wiring -- copy this structure) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/bridge/bridge-connection.ts` (the `BridgeConnection` class with `connectAsync`) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/bridge/internal/bridge-host.ts` (the bridge host implementation -- `serve` uses this indirectly via `BridgeConnection`) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/commands/index.ts` (barrel file -- add the new command here) -- `studio-bridge/plans/tech-specs/05-split-server.md` sections 4 and 5 (serve command spec, file layout) - -**Files to Create**: -- `src/commands/serve.ts` -- `CommandDefinition>` handler -- `src/cli/commands/serve-command.ts` -- CLI wiring (yargs `CommandModule`) -- `src/commands/serve.test.ts` -- unit tests for the serve command handler - -**Files to Modify**: -- `src/commands/index.ts` -- add `serveCommand` to named exports and `allCommands` array. Do NOT modify `cli.ts`. - -**Requirements**: - -1. Create `src/commands/serve.ts` following the same structure as `src/commands/sessions.ts`: - -```typescript -import type { BridgeConnection } from '../bridge/bridge-connection.js'; -import type { CommandResult } from '../cli/types.js'; - -export interface ServeInput { - port?: number; - logLevel?: 'silent' | 'error' | 'warn' | 'info' | 'debug'; - json?: boolean; - timeout?: number; -} - -export interface ServeOutput { - port: number; - sessions: Array<{ id: string; context: string; instanceId: string }>; -} - -export async function serveAsync( - options: ServeInput = {} -): Promise> { - const port = options.port ?? 38741; - - // 1. Call BridgeConnection.connectAsync({ port, keepAlive: true }). - // This internally calls bridge-host.ts to start the WebSocket server. - // If keepAlive is true, the host never exits on idle (no 5-second grace period). - // - // 2. If connectAsync throws with code EADDRINUSE: - // - Do NOT fall back to client mode (unlike implicit host behavior). - // - Throw a clear error: "Port is already in use. A bridge host is - // already running. Connect as a client with any studio-bridge command, - // or use --port to start on a different port." - // - Exit code 1. - // - // 3. On success, log the startup message: - // - Human-readable (default): "Bridge host listening on port " - // - JSON mode (--json): { "event": "started", "port": , "timestamp": "" } - // - // 4. Set up event listeners for session connect/disconnect: - // - On plugin connect: log "Plugin connected: ()" - // - On plugin disconnect: log "Plugin disconnected: " - // - On client connect: log "Client connected" - // - On client disconnect: log "Client disconnected" - // - In JSON mode, these are JSON lines: { "event": "pluginConnected", "sessionId": "...", ... } - // - // 5. The function does NOT return until the process is killed or --timeout expires. - // Use a Promise that resolves on shutdown signal. -} -``` - -2. Create `src/cli/commands/serve-command.ts` following `src/cli/commands/sessions-command.ts`: - - Command: `serve` - - Description: `Start a dedicated bridge host process` - - Args: - - `--port ` (default: 38741) -- port to listen on - - `--log-level ` (choices: silent, error, warn, info, debug; default: info) -- log verbosity - - `--json` (boolean, default: false) -- print structured status to stdout as JSON lines - - `--timeout ` (number, default: none) -- auto-shutdown after idle period with no connections - - Handler: - - Call `serveAsync(options)` with the parsed flags - - The handler blocks until SIGTERM/SIGINT or timeout - - Output is handled within `serveAsync` (streaming log output, not a single result) - -3. Register in `src/commands/index.ts` (NOT `cli.ts`): - -```typescript -// In src/commands/index.ts, add: -export { serveCommand } from './serve.js'; - -// And add to the allCommands array: -import { serveCommand } from './serve.js'; -// ... serveCommand in the allCommands array -``` - -4. Implement signal handling inside the serve command handler: - - **SIGTERM / SIGINT** -- Graceful shutdown sequence: - 1. Log: "Shutting down..." (or `{ "event": "shuttingDown", "timestamp": "..." }` in JSON mode). - 2. Send `shutdown` notification to all connected plugins. This tells the plugin to cleanly disconnect rather than enter its reconnection polling loop. - 3. Close all WebSocket connections (both plugin and client connections). - 4. Unbind the port (stop the HTTP server). - 5. Log: "Bridge host stopped." (or `{ "event": "stopped", "timestamp": "..." }` in JSON mode). - 6. Exit with code 0. - - The graceful shutdown is implemented by calling `connection.disconnectAsync()` inside the signal handler. The `disconnectAsync` method on `BridgeConnection` already handles the hand-off protocol (transfer host role to a connected client if one exists, otherwise shut down). For `serve`, since we want a clean exit, we call `disconnectAsync()` and then `process.exit(0)`. - - **SIGHUP** -- Ignore. The serve process should survive terminal close (e.g., when run in a detached tmux session or via nohup). Register `process.on('SIGHUP', () => {})` to prevent the default SIGHUP behavior (which is to terminate). - - **Signal handler registration**: - -```typescript -// Inside the serveAsync function, after BridgeConnection is established: -const shutdownAsync = async () => { - log('Shutting down...'); - await connection.disconnectAsync(); - log('Bridge host stopped.'); - process.exit(0); -}; - -process.on('SIGTERM', () => void shutdownAsync()); -process.on('SIGINT', () => void shutdownAsync()); -process.on('SIGHUP', () => { /* ignore -- survive terminal close */ }); -``` - -5. Implement the `--timeout` flag: - - When `--timeout ` is provided, start a timer that resets whenever a plugin or client connects or sends a message. If the timer expires with zero active connections, trigger the same graceful shutdown sequence as SIGTERM. Log: "Idle timeout reached (ms with no connections). Shutting down." - - Implementation: use `setTimeout`/`clearTimeout` with a counter of active connections. On connection open, increment counter and clear the timer. On connection close, decrement counter and restart the timer if counter reaches zero. - -6. Exit codes: - - `0` -- Clean shutdown (SIGTERM, SIGINT, or idle timeout) - - `1` -- Startup failure (port in use and not recoverable, invalid arguments) - -7. Create `src/commands/serve.test.ts` with these unit tests: - -```typescript -describe('serve command', () => { - it('calls BridgeConnection.connectAsync with keepAlive: true', async () => { - // Mock BridgeConnection.connectAsync, verify keepAlive is set - }); - - it('passes port option to connectAsync', async () => { - // serveAsync({ port: 39000 }) -> connectAsync({ port: 39000, keepAlive: true }) - }); - - it('throws clear error on EADDRINUSE', async () => { - // Mock connectAsync to throw EADDRINUSE - // Verify error message contains "already in use" and "--port" - }); - - it('logs startup message in human-readable mode', async () => { - // Verify stdout contains "Bridge host listening on port 38741" - }); - - it('logs startup message in JSON mode', async () => { - // serveAsync({ json: true }) - // Verify stdout contains parseable JSON with event: "started" - }); -}); -``` - -**Acceptance Criteria**: -- `studio-bridge serve` binds port 38741 (or `--port N`) and stays alive until killed. -- Plugin can discover and connect via the `/health` endpoint on the bound port. -- Other CLIs can connect as bridge clients via the `/client` WebSocket path. -- `--json` outputs structured JSON lines to stdout on startup and on session events. -- `--log-level` controls verbosity (silent suppresses all output; debug shows WebSocket frame details). -- `--timeout ` enables auto-shutdown after idle period with no active connections (default: no timeout, runs forever). -- SIGTERM/SIGINT trigger graceful shutdown: notify plugins, close WebSockets, unbind port, exit 0. -- SIGHUP is ignored (process survives terminal close). -- If port 38741 is already in use, prints: "Port 38741 is already in use. A bridge host is already running. Connect as a client with any studio-bridge command, or use --port to start on a different port." and exits with code 1. -- There is NO `src/cli/commands/serve-command.ts` separate from the command pattern -- the wiring follows the same `CommandModule` pattern as all other commands. -- There is NO `src/server/daemon-server.ts` -- the serve command uses `bridge-host.ts` from `src/bridge/internal/` directly via `BridgeConnection`. -- **End-to-end test**: Start `studio-bridge serve` in a subprocess. Connect a mock plugin via WebSocket to `ws://localhost:38741/plugin`. Send SIGTERM to the subprocess. Verify: (a) the mock plugin receives a `shutdown` message or the WebSocket closes cleanly, (b) the subprocess exits with code 0, (c) the port is unbound (a new process can bind it). - -**Do NOT**: -- Create a separate daemon module or server directory. The serve command is a thin wrapper. -- Fall back to client mode on EADDRINUSE. This is an explicit host request; silent fallback would be confusing. -- Modify `cli.ts` to register the command -- add it to `src/commands/index.ts` instead. -- Use default exports. -- Forget `.js` extensions on local imports. - ---- - -## Task 4.2: Remote Bridge Client (`--remote` / `--local` flags) - -**Prerequisites**: Task 1.3d5 (BridgeConnection barrel export) must be completed first. - -**Context**: When the CLI runs inside a devcontainer, it cannot bind the bridge port locally (Studio is on a different machine). Instead, it needs to connect as a client to a remote bridge host. The `--remote` flag lets users explicitly specify the remote host address. The `--local` flag forces local mode, disabling auto-detection. These are GLOBAL flags on the yargs root (not per-command) because they affect `BridgeConnection` behavior for every command. - -**Objective**: Add `remoteHost?: string` support to `BridgeConnectionOptions` so the CLI can connect to a remote bridge host instead of trying to bind locally. Add `--remote` and `--local` as global CLI flags. No new abstractions -- the existing `bridge-client.ts` from `src/bridge/internal/` already knows how to connect as a client. - -**Read First**: -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/bridge/bridge-connection.ts` (the `BridgeConnection` class with `connectAsync` -- you will modify this) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/bridge/internal/bridge-client.ts` (the client implementation -- already exists, used when connecting as client) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/bridge/internal/bridge-host.ts` (the host implementation -- you need to understand when this is used vs. bridge-client) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/cli/args/global-args.ts` (global CLI argument definitions -- add --remote and --local here) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/cli/cli.ts` (the main CLI entry point -- understand how global args are threaded to commands) -- `studio-bridge/plans/tech-specs/05-split-server.md` section 6 (client connection spec, decision flow) - -**Files to Modify**: -- `src/bridge/bridge-connection.ts` -- add `remoteHost?: string` to `BridgeConnectionOptions`. Modify `connectAsync` to skip local bind when `remoteHost` is set. -- `src/cli/args/global-args.ts` -- add `--remote ` and `--local` to `StudioBridgeGlobalArgs`. - -**Files to Create**: -- `src/bridge/bridge-connection.test.ts` -- unit tests for the remote connection path (if not already existing; otherwise add tests to the existing file) - -**Requirements**: - -1. Add `remoteHost` to `BridgeConnectionOptions`: - -```typescript -// In src/bridge/types.ts or bridge-connection.ts (wherever BridgeConnectionOptions is defined) -export interface BridgeConnectionOptions { - port?: number; - timeoutMs?: number; - keepAlive?: boolean; - remoteHost?: string; // e.g., 'localhost:38741' or '192.168.1.5:38741' - local?: boolean; // force local mode, disable devcontainer auto-detection -} -``` - -2. Modify `BridgeConnection.connectAsync()` to handle `remoteHost`: - -```typescript -// Modified decision flow in connectAsync: -static async connectAsync(options: BridgeConnectionOptions = {}): Promise { - // 1. If remoteHost is set: - // - Parse host:port. If only host is given (no colon), append default port 38741. - // - Validate format: must be "host:port" where port is a number 1-65535. - // - Skip local port-bind attempt entirely. - // - Connect as client to ws:///client via bridge-client.ts. - // - On connection refused: throw with message: - // "Could not connect to bridge host at . - // Is `studio-bridge serve` running on the host?" - // - On timeout (5 seconds): throw with message: - // "Connection to bridge host at timed out after 5 seconds. - // Check that the host is reachable and port forwarding is configured." - // - // 2. If local is set: - // - Skip devcontainer auto-detection (Task 4.3). - // - Proceed directly to local bind attempt (standard implicit host behavior). - // - // 3. Otherwise (neither remoteHost nor local): - // - [Task 4.3 will add devcontainer auto-detection here] - // - Try binding port (become host); EADDRINUSE -> connect as client. -} -``` - -3. Parse the `--remote` flag as a global yargs option: - -```typescript -// In src/cli/args/global-args.ts, add to the global options: -remote: { - type: 'string', - description: 'Connect to a remote bridge host at host:port (e.g., localhost:38741)', - global: true, - coerce: (value: string): string => { - // If value contains no colon, append default port - if (!value.includes(':')) { - return `${value}:38741`; - } - // Validate port is a number - const [host, portStr] = value.split(':'); - const port = parseInt(portStr, 10); - if (isNaN(port) || port < 1 || port > 65535) { - throw new Error(`Invalid port in --remote: ${portStr}. Must be 1-65535.`); - } - return `${host}:${port}`; - }, -}, -local: { - type: 'boolean', - description: 'Force local mode (disable devcontainer auto-detection)', - global: true, - default: false, - conflicts: 'remote', // --remote and --local are mutually exclusive -}, -``` - -4. Thread the global flags into `BridgeConnectionOptions`: - - In the CLI handler chain (wherever `BridgeConnection.connectAsync()` is called from CLI commands), pass `remoteHost` and `local` from the parsed global args: - -```typescript -// In each command handler (or in a shared middleware): -const connection = await BridgeConnection.connectAsync({ - port: argv.port, - remoteHost: argv.remote, - local: argv.local, -}); -``` - -5. Type signature for the parsed `--remote` flag: - -```typescript -// In StudioBridgeGlobalArgs (global-args.ts) -export interface StudioBridgeGlobalArgs { - // ... existing fields ... - remote?: string; // "host:port" after coercion - local?: boolean; -} -``` - -6. Error handling -- connection refused: - - When `remoteHost` is set and the connection fails with `ECONNREFUSED`: - ``` - Error: Could not connect to bridge host at localhost:38741. - Is `studio-bridge serve` running on the host? - ``` - - When `remoteHost` is set and the connection times out (5 second default): - ``` - Error: Connection to bridge host at localhost:38741 timed out after 5 seconds. - Check that the host is reachable and port forwarding is configured. - ``` - - When `remoteHost` has an invalid format: - ``` - Error: Invalid --remote value: "foo:bar". Expected format: host:port (e.g., localhost:38741). - ``` - -7. Add tests to `src/bridge/bridge-connection.test.ts`: - -```typescript -describe('BridgeConnection.connectAsync with remoteHost', () => { - it('connects as client when remoteHost is set', async () => { - // Start a mock WebSocket server on a test port. - // Call connectAsync({ remoteHost: 'localhost:' }). - // Verify a client connection is made (not a host bind). - }); - - it('appends default port when remoteHost has no colon', async () => { - // connectAsync({ remoteHost: 'myhost' }) - // Verify connection attempt to myhost:38741 - }); - - it('throws ECONNREFUSED with clear message when host is unreachable', async () => { - // connectAsync({ remoteHost: 'localhost:19999' }) -- nothing listening - // Verify error message contains "Could not connect" and "studio-bridge serve" - }); - - it('throws timeout error after 5 seconds when host does not respond', async () => { - // Use a server that accepts TCP but never completes the WebSocket handshake - // connectAsync({ remoteHost: 'localhost:' }) - // Verify error within ~5 seconds, message contains "timed out" - }); - - it('rejects when --remote and --local are both set', async () => { - // This is handled at the yargs level via conflicts, but verify behavior - }); - - it('skips local bind attempt when remoteHost is set', async () => { - // Start a real bridge host on port 38741. - // Call connectAsync({ remoteHost: 'localhost:38741' }). - // Verify the connection is as a CLIENT (not a second host). - // The test port should remain available for binding by another process. - }); -}); -``` - -**Acceptance Criteria**: -- `studio-bridge exec --remote localhost:38741 'print("hi")'` connects as a bridge client to the remote host and executes the script. Output is printed as if running locally. -- `studio-bridge exec --remote myhost 'print("hi")'` connects to `myhost:38741` (default port appended). -- `studio-bridge exec --local 'print("hi")'` forces local mode even inside a devcontainer (ignores auto-detection from Task 4.3). -- `--remote` and `--local` are mutually exclusive. Passing both produces a yargs validation error. -- All commands work through the remote connection: `exec`, `run`, `terminal`, `state`, `screenshot`, `logs`, `query`, `sessions`. The remote connection is transparent to the command handlers. -- Connection refused (`ECONNREFUSED`) produces: "Could not connect to bridge host at ``. Is `studio-bridge serve` running on the host?" -- Connection timeout (5 seconds) produces: "Connection to bridge host at `` timed out after 5 seconds. Check that the host is reachable and port forwarding is configured." -- Invalid `--remote` format produces a clear validation error with the expected format. -- **End-to-end test**: Start `studio-bridge serve --port 38742` in a subprocess. In a separate process, run `studio-bridge exec --remote localhost:38742 'print("hello")'`. Verify the output contains "hello". Then run `studio-bridge sessions --remote localhost:38742` and verify it lists the connected session. -- **End-to-end test (unreachable)**: Run `studio-bridge exec --remote localhost:19999 'print("hi")'`. Verify the process exits with code 1 within 6 seconds and the error message contains "Could not connect". - -**Do NOT**: -- Create a separate "daemon client" abstraction. The existing `bridge-client.ts` from `src/bridge/internal/` is the client. `remoteHost` just changes which address it connects to. -- Make consumers aware of whether they are in local or remote mode. `BridgeSession` methods work identically. -- Modify `cli.ts` for command registration -- add to `src/commands/index.ts` instead. -- Use default exports. -- Forget `.js` extensions on local imports. - ---- - -## Task 4.3: Devcontainer Auto-Detection - -**Prerequisites**: Task 4.2 (remote bridge client) must be completed first. Tasks 4.2, 4.3, and 6.5 all modify `bridge-connection.ts` and must be sequenced: 4.2 then 4.3 then 6.5. - -**Context**: When the CLI runs inside a devcontainer (VS Code Dev Containers, GitHub Codespaces, Docker Compose), it should automatically try connecting to a remote bridge host before falling back to local mode. This avoids requiring users to manually pass `--remote` every time. Detection is based on well-known environment variables and file markers. The `--remote` flag takes precedence over auto-detection, and `--local` disables it entirely. - -**Objective**: Create `src/bridge/internal/environment-detection.ts` with `isDevcontainer()` and `getDefaultRemoteHost()` functions. Wire them into `BridgeConnection.connectAsync()` so that CLI processes inside devcontainers automatically attempt remote connection with a 3-second timeout before falling back to local mode. - -**Read First**: -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/bridge/bridge-connection.ts` (you will modify this -- the `connectAsync` decision flow) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/bridge/internal/bridge-host.ts` (understand the host side) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/bridge/internal/bridge-client.ts` (understand the client side) -- `studio-bridge/plans/tech-specs/05-split-server.md` section 6 (decision flow diagram, devcontainer auto-detection spec) - -**Files to Create**: -- `src/bridge/internal/environment-detection.ts` -- `isDevcontainer(): boolean`, `getDefaultRemoteHost(): string | null` -- `src/bridge/internal/environment-detection.test.ts` -- unit tests for detection logic - -**Files to Modify**: -- `src/bridge/bridge-connection.ts` -- add auto-detection step in `connectAsync` between the `remoteHost` check and the local bind attempt - -**Requirements**: - -1. Create `src/bridge/internal/environment-detection.ts`: - -```typescript -import { existsSync } from 'node:fs'; - -const DEFAULT_BRIDGE_PORT = 38741; - -/** - * Detect whether the current process is running inside a devcontainer. - * - * Checks multiple signals to minimize false positives: - * - REMOTE_CONTAINERS: set by VS Code Remote - Containers extension - * - CODESPACES: set by GitHub Codespaces - * - CONTAINER: set by some container runtimes - * - /.dockerenv: file created by Docker in every container - * - * Returns true if ANY of these signals are present. This intentionally - * casts a wide net -- the consequence of a false positive is a 3-second - * timeout delay followed by a fallback to local mode, which is acceptable. - * The consequence of a false negative is that the user must manually pass - * --remote, which has clear error messaging. - */ -export function isDevcontainer(): boolean { - return !!( - process.env.REMOTE_CONTAINERS || - process.env.CODESPACES || - process.env.CONTAINER || - existsSync('/.dockerenv') - ); -} - -/** - * Get the default remote host address for devcontainer environments. - * - * Returns "localhost:38741" when inside a devcontainer (port forwarding - * maps localhost inside the container to the host OS). Returns null when - * not in a devcontainer. - * - * Why localhost and not host.docker.internal: - * VS Code Dev Containers and Codespaces use port forwarding, which makes - * the host's port 38741 accessible at localhost:38741 inside the container. - * Docker Compose users configure port forwarding explicitly. In all cases, - * localhost is the correct address from the container's perspective. - */ -export function getDefaultRemoteHost(): string | null { - if (isDevcontainer()) { - return `localhost:${DEFAULT_BRIDGE_PORT}`; - } - return null; -} -``` - -2. Create `src/bridge/internal/environment-detection.test.ts`: - -```typescript -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { isDevcontainer, getDefaultRemoteHost } from './environment-detection.js'; - -describe('isDevcontainer', () => { - const originalEnv = { ...process.env }; - - beforeEach(() => { - // Clear all detection env vars before each test - delete process.env.REMOTE_CONTAINERS; - delete process.env.CODESPACES; - delete process.env.CONTAINER; - }); - - afterEach(() => { - // Restore original environment - process.env = { ...originalEnv }; - vi.restoreAllMocks(); - }); - - it('returns true when REMOTE_CONTAINERS is set', () => { - process.env.REMOTE_CONTAINERS = 'true'; - expect(isDevcontainer()).toBe(true); - }); - - it('returns true when CODESPACES is set', () => { - process.env.CODESPACES = 'true'; - expect(isDevcontainer()).toBe(true); - }); - - it('returns true when CONTAINER is set', () => { - process.env.CONTAINER = 'podman'; - expect(isDevcontainer()).toBe(true); - }); - - it('returns true when /.dockerenv exists', () => { - // Mock existsSync to return true for /.dockerenv - vi.mock('node:fs', () => ({ - existsSync: (path: string) => path === '/.dockerenv', - })); - // Re-import after mock - expect(isDevcontainer()).toBe(true); - }); - - it('returns false when no detection signals are present', () => { - // No env vars set, /.dockerenv does not exist (default on host OS) - expect(isDevcontainer()).toBe(false); - }); - - it('returns true when multiple signals are present', () => { - process.env.REMOTE_CONTAINERS = 'true'; - process.env.CODESPACES = 'true'; - expect(isDevcontainer()).toBe(true); - }); - - it('treats empty string env var as falsy', () => { - process.env.REMOTE_CONTAINERS = ''; - expect(isDevcontainer()).toBe(false); - }); -}); - -describe('getDefaultRemoteHost', () => { - it('returns localhost:38741 when inside devcontainer', () => { - process.env.REMOTE_CONTAINERS = 'true'; - expect(getDefaultRemoteHost()).toBe('localhost:38741'); - }); - - it('returns null when not inside devcontainer', () => { - delete process.env.REMOTE_CONTAINERS; - delete process.env.CODESPACES; - delete process.env.CONTAINER; - expect(getDefaultRemoteHost()).toBeNull(); - }); -}); -``` - -3. Modify `src/bridge/bridge-connection.ts` -- add auto-detection to the `connectAsync` decision flow: - -```typescript -import { isDevcontainer, getDefaultRemoteHost } from './internal/environment-detection.js'; - -// The complete decision flow in connectAsync: -static async connectAsync(options: BridgeConnectionOptions = {}): Promise { - // Step 1: Explicit --remote takes highest precedence - if (options.remoteHost) { - return this._connectAsClientAsync(options.remoteHost, options); - // On failure: throw with clear error message (no fallback) - } - - // Step 2: Devcontainer auto-detection (unless --local is set) - if (!options.local) { - const autoRemoteHost = getDefaultRemoteHost(); - if (autoRemoteHost) { - try { - // Use a shorter timeout (3 seconds) for auto-detection. - // If the bridge host is not running on the host OS, we want to - // fall back quickly rather than making the user wait. - return await this._connectAsClientAsync(autoRemoteHost, { - ...options, - timeoutMs: 3000, - }); - } catch (error) { - // Auto-detection failed -- fall back to local mode with a warning. - // This is NOT an error because the user did not explicitly request - // remote mode. The warning helps them understand what happened. - console.warn( - `Devcontainer detected, but could not connect to bridge host at ${autoRemoteHost}. ` + - `Falling back to local mode. Run \`studio-bridge serve\` on the host OS, ` + - `or use --remote to specify a different address.` - ); - } - } - } - - // Step 3: Local mode -- try binding port (become host); EADDRINUSE -> connect as client - return this._connectLocalAsync(options); -} -``` - - **Important sequencing note**: This modification to `bridge-connection.ts` MUST happen AFTER Task 4.2 is complete. Task 4.2 adds the `remoteHost` handling and `_connectAsClientAsync` method. Task 4.3 inserts the auto-detection step between the `remoteHost` check and the local bind attempt. Do NOT run Tasks 4.2 and 4.3 in parallel -- they both modify `connectAsync` and must be sequenced: 4.2 then 4.3. - -4. Auto-detection timeout behavior: - - - The auto-detection connection attempt uses a **3-second timeout** (not the default 5 seconds used by explicit `--remote`). This is shorter because auto-detection is speculative -- if the host is not reachable, we want to fall back quickly. - - On timeout or `ECONNREFUSED`, log a warning and fall back to local mode. Do NOT throw an error. The user did not explicitly request remote mode. - - The warning message should be actionable: tell the user to run `studio-bridge serve` on the host or use `--remote`. - -5. Override precedence (highest to lowest): - 1. `--remote ` -- connect to specified host, error on failure (no fallback) - 2. `--local` -- force local mode, skip auto-detection entirely - 3. Devcontainer auto-detection -- if detected, try remote with 3s timeout, fall back to local - 4. Default local behavior -- bind port or connect as client - -**Acceptance Criteria**: -- Inside a devcontainer (with `REMOTE_CONTAINERS=true` or `CODESPACES=true` env var), `studio-bridge exec 'print("hi")'` automatically tries connecting to `localhost:38741` as a client. -- If the remote host is reachable (bridge host running on host OS with port forwarding), the command executes successfully without `--remote`. -- If the remote host is NOT reachable (no `studio-bridge serve` running, or port not forwarded), the CLI falls back to local mode within 3 seconds and prints a warning. -- The warning message includes instructions: run `studio-bridge serve` on the host, or use `--remote`. -- Outside a devcontainer (no env vars, no `/.dockerenv`), behavior is identical to pre-Phase-4 (local host/client detection). -- `--remote` flag takes precedence over auto-detection. If `--remote` is set, auto-detection is skipped even inside a devcontainer. -- `--local` flag disables auto-detection. Inside a devcontainer with `--local`, the CLI goes directly to local bind attempt. -- Empty string env vars (e.g., `REMOTE_CONTAINERS=""`) are treated as not set (falsy). -- **Unit test**: Set `REMOTE_CONTAINERS=true`, mock a reachable bridge host on `localhost:38741`. Call `connectAsync({})`. Verify it connects as a client (not a host). -- **Unit test**: Set `REMOTE_CONTAINERS=true`, no bridge host running. Call `connectAsync({})`. Verify it falls back to local mode within 3 seconds and logs a warning. -- **Unit test**: Set `REMOTE_CONTAINERS=true`, call `connectAsync({ remoteHost: 'otherhost:39000' })`. Verify it connects to `otherhost:39000` (explicit `--remote` overrides auto-detection). -- **Unit test**: Set `REMOTE_CONTAINERS=true`, call `connectAsync({ local: true })`. Verify it does NOT attempt remote connection. -- **Unit test**: No env vars set, no `/.dockerenv`. Call `connectAsync({})`. Verify no remote connection attempt (goes straight to local bind). - -**Do NOT**: -- Use `host.docker.internal` as the default address. VS Code Dev Containers use port forwarding, so `localhost` is correct from inside the container. -- Create a separate "devcontainer client" class. The existing `bridge-client.ts` is used for all client connections. -- Make the auto-detection timeout an error. It is a warning with fallback. -- Run this task in parallel with Task 4.2. They both modify `bridge-connection.ts` and must be sequenced. -- Use default exports. -- Forget `.js` extensions on local imports. - ---- - -## Cross-References - -- Phase plan: [studio-bridge/plans/execution/phases/04-split-server.md](../phases/04-split-server.md) -- Validation: [studio-bridge/plans/execution/validation/04-split-server.md](../validation/04-split-server.md) -- Tech spec: `studio-bridge/plans/tech-specs/05-split-server.md` -- Reference command pattern: `src/commands/sessions.ts` + `src/cli/commands/sessions-command.ts` (Task 1.7b) -- Shared utilities: `src/cli/resolve-session.ts`, `src/cli/format-output.ts`, `src/cli/types.ts` (Task 1.7a) -- Sequential chain: Task 4.2 -> Task 4.3 -> Task 6.5 (all modify `bridge-connection.ts`) diff --git a/studio-bridge/plans/execution/agent-prompts/05-mcp-server.md b/studio-bridge/plans/execution/agent-prompts/05-mcp-server.md deleted file mode 100644 index 2ab04c0cc8..0000000000 --- a/studio-bridge/plans/execution/agent-prompts/05-mcp-server.md +++ /dev/null @@ -1,179 +0,0 @@ -# Phase 5: MCP Integration -- Agent Prompts - -**Phase reference**: [studio-bridge/plans/execution/phases/05-mcp-server.md](../phases/05-mcp-server.md) -**Validation**: [studio-bridge/plans/execution/validation/05-mcp-server.md](../validation/05-mcp-server.md) - -Base path for all file references: `/workspaces/NevermoreEngine/tools/studio-bridge/` - -## How to Use These Prompts - -1. Copy the full prompt for a single task into a Claude Code sub-agent session. -2. The agent should read the "Read First" files, then implement the "Requirements" section. -3. The agent should run the acceptance criteria checks before reporting completion. -4. Do not give an agent a task whose dependencies have not been completed yet (see the dependency graph in [studio-bridge/plans/execution/phases/05-mcp-server.md](../phases/05-mcp-server.md)). - -Key conventions that apply to every prompt: - -- **TypeScript ESM** with `.js` extensions on all local imports (e.g., `import { Foo } from './foo.js';`) -- **`Async` suffix** on all async functions (e.g., `listSessionsAsync`, `resolveRequestAsync`) -- **Private `_` prefix** on all private fields and methods -- **vitest** for tests: `describe`/`it`/`expect`, test files named `*.test.ts` alongside source -- **No default exports** -- always use named exports -- **yargs `CommandModule` pattern** for CLI commands (class with `command`, `describe`, `builder`, `handler`) -- **`@quenty/` scoped packages** for workspace imports (e.g., `@quenty/cli-output-helpers`) -- **`OutputHelper`** from `@quenty/cli-output-helpers` for all user-facing output - ---- - -## Handoff Notes for Tasks Requiring Orchestrator Coordination - -### Task 5.1: MCP Server Scaffold - -**Prerequisites**: Task 1.7a (shared CLI utilities) and Phase 3 (all action commands) must be completed first. - -**Why requires coordination**: Code quality and SDK integration can be verified by a review agent. Claude Code validation (verifying tools appear and function correctly) is a separate validation step that requires a running Claude Code instance. - -**Handoff**: Create `src/mcp/mcp-server.ts` with tool registration and request routing. Create `src/cli/commands/mcp-command.ts` for the `studio-bridge mcp` command. Use `@modelcontextprotocol/sdk` (the official MCP SDK) -- this is decided, not a choice. It handles JSON-RPC framing, stdio transport, tool/resource registration, and protocol negotiation. Import `Server` from `@modelcontextprotocol/sdk/server/index.js` and `StdioServerTransport` from `@modelcontextprotocol/sdk/server/stdio.js`. See `06-mcp-server.md` section 5.2 for the exact import pattern and server setup. - ---- - -## Task 5.2: MCP Tool Definitions - -**Prerequisites**: Tasks 5.1 (MCP server scaffold) and 1.7a (shared CLI utilities) must be completed first. - -**Context**: Studio-bridge exposes capabilities to AI agents via the Model Context Protocol (MCP). Each tool maps to an existing server action. The MCP server scaffold (Task 5.1) provides the registration mechanism. This task defines the individual tool implementations. - -**Objective**: Implement the six MCP tool handlers that map to studio-bridge actions. - -**Read First**: -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/mcp/mcp-server.ts` (the MCP server scaffold from Task 5.1 -- must exist before this task) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/server/actions/query-state.ts` (action handler pattern) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/server/actions/query-logs.ts` (action handler pattern) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/server/bridge-connection.ts` (for session resolution via in-memory tracking) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/server/web-socket-protocol.ts` (v2 types) - -**Files to Create**: -- `src/mcp/tools/studio-sessions-tool.ts` -- `src/mcp/tools/studio-state-tool.ts` -- `src/mcp/tools/studio-screenshot-tool.ts` -- `src/mcp/tools/studio-logs-tool.ts` -- `src/mcp/tools/studio-query-tool.ts` -- `src/mcp/tools/studio-exec-tool.ts` - -**Requirements**: - -1. Each tool file exports a tool definition object with: - - `name: string` -- the MCP tool name (e.g., `'studio_sessions'`) - - `description: string` -- human-readable description for tool discovery - - `inputSchema: object` -- JSON Schema for the tool input - - `handler: (input: Record) => Promise` -- the implementation - -2. **`studio_sessions`** tool: - - No input required (empty schema or optional `{}`). - - Calls `BridgeConnection.listSessionsAsync()` to get all currently connected sessions. - - Returns the array of sessions as JSON. - -3. **`studio_state`** tool: - - Input: `{ sessionId?: string }` - - Resolves session (auto-select if one exists, error if multiple and no ID). - - Calls `queryStateAsync`. - - Returns state JSON. - -4. **`studio_screenshot`** tool: - - Input: `{ sessionId?: string }` - - Calls `captureScreenshotAsync`. - - Returns `{ data: , format: 'png', width, height }` as MCP image content. - -5. **`studio_logs`** tool: - - Input: `{ sessionId?: string, count?: number, levels?: string[] }` - - Calls `queryLogsAsync`. - - Returns entries as JSON. - -6. **`studio_query`** tool: - - Input: `{ sessionId?: string, path: string, depth?: number, properties?: string[], includeAttributes?: boolean }` - - Calls `queryDataModelAsync`. - - Returns the DataModel instance JSON. - -7. **`studio_exec`** tool: - - Input: `{ sessionId?: string, script: string }` - - Calls `execAsync`. - - Returns `{ success: boolean, logs: string }`. - -8. Session resolution for all tools that require a session: - - If `sessionId` is provided, find by ID. - - If omitted and exactly one session exists, auto-select. - - If omitted and multiple sessions exist, return an MCP error listing available sessions. - - If omitted and zero sessions exist, return an MCP error. - -9. Error handling: - - Use structured MCP error responses, not process exits. - - Timeout errors should include a clear message. - -**Acceptance Criteria**: -- Each tool has a valid JSON Schema for input. -- Session auto-selection works correctly. -- Errors return structured MCP responses. -- `studio_screenshot` returns base64 image data. -- All tools return structured JSON, not formatted text. -- All tool files compile without errors. - -**Do NOT**: -- Implement the MCP server scaffold (that is Task 5.1). -- Import `@modelcontextprotocol/sdk` types in the adapter layer -- define local interfaces to keep the adapter decoupled from the SDK. -- Use default exports. -- Forget `.js` extensions on local imports. - ---- - -## Task 5.3: MCP Transport and Configuration - -**Prerequisites**: Tasks 5.1 (MCP server scaffold) and 5.2 (MCP tool definitions) must be completed first. - -**Context**: The MCP server needs to communicate with Claude Code and other MCP-compatible clients via the stdio transport (JSON-RPC over stdin/stdout). This task wires the transport into the MCP server. - -**Objective**: Implement stdio transport for the MCP server so it can be registered as a Claude Code MCP tool provider. - -**Read First**: -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/mcp/mcp-server.ts` (the MCP server from Task 5.1) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/cli/cli.ts` (for the `mcp` command registration) - -**Files to Create**: -- (no custom transport file needed -- use `StdioServerTransport` from `@modelcontextprotocol/sdk`) - -**Files to Modify**: -- `src/mcp/mcp-server.ts` -- wire the stdio transport -- `src/cli/commands/mcp-command.ts` -- ensure the `mcp` command starts the server with stdio transport - -**Requirements**: - -1. Use the `@modelcontextprotocol/sdk` package's `StdioServerTransport` for the stdio transport. The SDK handles JSON-RPC framing and MCP lifecycle messages (`initialize`, `tools/list`, `tools/call`) automatically. - -2. The `studio-bridge mcp` command: - - Starts the MCP server with stdio transport. - - The server stays alive as long as stdin is open. - - On stdin close, the server shuts down gracefully. - -3. The MCP server responds to: - - `initialize` -- returns server info and capabilities. - - `tools/list` -- returns the list of all tool definitions. - - `tools/call` -- dispatches to the matching tool handler from Task 5.2. - -**Acceptance Criteria**: -- `studio-bridge mcp` starts and communicates via stdio JSON-RPC. -- The MCP server correctly responds to `initialize`, `tools/list`, and `tools/call`. -- A Claude Code MCP configuration pointing to `studio-bridge mcp` discovers all six tools. -- The server shuts down cleanly when stdin closes. - -**Do NOT**: -- Implement the tool handlers (that is Task 5.2). -- Use default exports. -- Forget `.js` extensions on local imports. -- Write to stderr in a way that would interfere with MCP JSON-RPC on stdout. - ---- - -## Cross-References - -- Phase plan: [studio-bridge/plans/execution/phases/05-mcp-server.md](../phases/05-mcp-server.md) -- Validation: [studio-bridge/plans/execution/validation/05-mcp-server.md](../validation/05-mcp-server.md) -- Tech spec: `studio-bridge/plans/tech-specs/06-mcp-server.md` diff --git a/studio-bridge/plans/execution/agent-prompts/06-integration.md b/studio-bridge/plans/execution/agent-prompts/06-integration.md deleted file mode 100644 index 8541cf16c2..0000000000 --- a/studio-bridge/plans/execution/agent-prompts/06-integration.md +++ /dev/null @@ -1,368 +0,0 @@ -# Phase 6: Polish (Integration) -- Agent Prompts - -**Phase reference**: [studio-bridge/plans/execution/phases/06-integration.md](../phases/06-integration.md) -**Validation**: [studio-bridge/plans/execution/validation/06-integration.md](../validation/06-integration.md) - -Base path for all file references: `/workspaces/NevermoreEngine/tools/studio-bridge/` - -## How to Use These Prompts - -1. Copy the full prompt for a single task into a Claude Code sub-agent session. -2. The agent should read the "Read First" files, then implement the "Requirements" section. -3. The agent should run the acceptance criteria checks before reporting completion. -4. Do not give an agent a task whose dependencies have not been completed yet (see the dependency graph in [studio-bridge/plans/execution/phases/06-integration.md](../phases/06-integration.md)). - -Key conventions that apply to every prompt: - -- **TypeScript ESM** with `.js` extensions on all local imports (e.g., `import { Foo } from './foo.js';`) -- **`Async` suffix** on all async functions (e.g., `listSessionsAsync`, `resolveRequestAsync`) -- **Private `_` prefix** on all private fields and methods -- **vitest** for tests: `describe`/`it`/`expect`, test files named `*.test.ts` alongside source -- **No default exports** -- always use named exports -- **yargs `CommandModule` pattern** for CLI commands (class with `command`, `describe`, `builder`, `handler`) -- **`@quenty/` scoped packages** for workspace imports (e.g., `@quenty/cli-output-helpers`) -- **`OutputHelper`** from `@quenty/cli-output-helpers` for all user-facing output - ---- - -## Task 6.4: Update index.ts Exports - -**Prerequisites**: All phases (0-5) must be completed first. This task exports the public API surface from all prior phases. - -**Context**: The library's public API is exported from `src/index.ts`. New types, classes, and functions added across all phases need to be exported for library consumers. - -**Objective**: Update `src/index.ts` to export all new public types and classes. - -**Read First**: -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/index.ts` (the file you will modify) -- Browse all new files created in Phases 1-5 to identify public exports. - -**Files to Modify**: -- `src/index.ts` - -**Requirements**: - -1. Add exports for the registry module: - -```typescript -export { BridgeConnection } from './server/bridge-connection.js'; -export type { SessionInfo, SessionEvent, SessionOrigin, Disposable } from './registry/index.js'; -``` - -2. Add exports for new protocol types: - -```typescript -export type { - StudioState, - Capability, - ErrorCode, - SerializedValue, - DataModelInstance, - SubscribableEvent, - RegisterMessage, - StateResultMessage, - ScreenshotResultMessage, - DataModelResultMessage, - LogsResultMessage, - StateChangeMessage, - HeartbeatMessage, - SubscribeResultMessage, - UnsubscribeResultMessage, - PluginErrorMessage, - QueryStateMessage, - CaptureScreenshotMessage, - QueryDataModelMessage, - QueryLogsMessage, - SubscribeMessage, - UnsubscribeMessage, - ServerErrorMessage, -} from './server/web-socket-protocol.js'; -export { decodeServerMessage } from './server/web-socket-protocol.js'; -``` - -3. Add exports for action wrappers (if they exist): - -```typescript -export { queryStateAsync } from './server/actions/query-state.js'; -export { queryLogsAsync } from './server/actions/query-logs.js'; -// ... etc for each action wrapper that was created -``` - -4. Add exports for plugin discovery: - -```typescript -export { isPersistentPluginInstalled } from './plugin/plugin-discovery.js'; -``` - -5. Ensure all existing exports remain unchanged. - -**Acceptance Criteria**: -- All new public types and functions are exported. -- All existing exports are preserved. -- `tsc --noEmit` passes from `tools/studio-bridge/`. - -**Do NOT**: -- Remove any existing exports. -- Export internal/private types. -- Use default exports. -- Forget `.js` extensions on local imports. - ---- - -## Task 6.5: CI Integration - -**Prerequisites**: Task 4.3 (devcontainer auto-detection) must be completed first. Tasks 4.2, 4.3, and 6.5 all modify `bridge-connection.ts` and must be sequenced: 4.2 then 4.3 then 6.5. - -**Context**: In CI environments (GitHub Actions, etc.), the persistent plugin is never installed. Session tracking is in-memory via the bridge host, so no directory configuration is needed for sessions. - -**Objective**: Make plugin detection CI-aware. - -**Read First**: -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/plugin/plugin-discovery.ts` (plugin detection to modify) - -**Files to Modify**: -- `src/plugin/plugin-discovery.ts` -- return `false` for `isPersistentPluginInstalled()` in CI - -**Requirements**: - -1. In `plugin-discovery.ts`, update `isPersistentPluginInstalled`: - -```typescript -export function isPersistentPluginInstalled(): boolean { - if (process.env.CI === 'true') { - return false; - } - return fs.existsSync(getPersistentPluginPath()); -} -``` - -2. Add a test that verifies CI behavior by temporarily setting `process.env.CI`. - -Note: Session tracking is entirely in-memory via the bridge host. There are no session files, directories, or environment variables for session storage. No CI-specific session configuration is needed. - -**Acceptance Criteria**: -- In CI, `isPersistentPluginInstalled()` returns `false` regardless of file existence. -- Normal (non-CI) behavior is unchanged. - -**Do NOT**: -- Change any constructor signatures in a breaking way. -- Use default exports. -- Forget `.js` extensions on local imports. - ---- - -## Task 6.1: Update Existing Tests - -**Prerequisites**: All of Phases 1-3 must be completed first. - -**Context**: The refactoring across Phases 1-3 changed protocol types, server internals, handshake behavior, and the CLI command surface. Existing tests need to be verified and updated to cover the new behavior while ensuring no regressions. - -**Objective**: Verify all existing tests pass, fix any that break due to Phase 1-3 changes, and add integration tests that exercise the new v2 behavior in the existing test files. - -**Read First**: -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/server/studio-bridge-server.test.ts` (primary test file to update) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/server/web-socket-protocol.test.ts` (protocol test file -- should already have v2 tests from Task 1.1, verify coverage) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/server/pending-request-map.test.ts` (from Task 1.2 -- verify tests pass) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/server/action-dispatcher.test.ts` (from Task 1.6 -- verify tests pass) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/bridge/bridge-connection.test.ts` (from Tasks 1.3d1-1.3d4 -- verify tests pass) - -**Files to Modify**: -- `src/server/studio-bridge-server.test.ts` -- add new test cases -- `src/server/web-socket-protocol.test.ts` -- verify coverage, add if needed - -**Requirements**: - -1. Run all existing tests first: `cd tools/studio-bridge && npx vitest run`. Fix any failures caused by Phase 1-3 changes. - -2. Add the following integration tests to `studio-bridge-server.test.ts`: - - a. **v2 handshake test**: Connect a mock WebSocket client that sends a v2 `hello` with `protocolVersion: 2` and `capabilities: ['execute', 'queryState', 'captureScreenshot']`. Verify the server responds with a v2 `welcome` containing `protocolVersion: 2` and negotiated capabilities. - - b. **v2 register handshake test**: Connect a mock WebSocket client that sends a `register` message with all v2 fields (`protocolVersion`, `pluginVersion`, `instanceId`, `placeName`, `state`, `capabilities`). Verify the server responds with a v2 `welcome`. - - c. **v1 backward compatibility test**: Connect a mock WebSocket client that sends a v1 `hello` (no `protocolVersion`, no `capabilities`). Verify the server responds with a v1 `welcome` (no `protocolVersion` in the response). Verify `protocolVersion` getter returns 1. - - d. **Registry integration test**: Connect a mock plugin, verify it appears in `listSessionsAsync()`. Disconnect the plugin, verify it is removed from `listSessionsAsync()`. - - e. **Persistent plugin detection test**: Mock `isPersistentPluginInstalled()` to return `true`, start the server with `preferPersistentPlugin: true`, connect a mock plugin within the grace period, verify no temporary injection occurs. Then test the fallback: mock `isPersistentPluginInstalled()` to return `true`, do NOT connect a plugin, verify temporary injection is called after the grace period. - - f. **Heartbeat acceptance test**: Connect a v2 mock plugin, send a `heartbeat` message, verify no error response and the server continues operating normally. - - g. **performActionAsync test**: Connect a v2 mock plugin with `queryState` capability. Call `performActionAsync({ type: 'queryState', ... })`. Respond with a `stateResult` from the mock plugin. Verify the promise resolves with the correct data. - -3. Verify that ALL existing tests still pass after modifications: `cd tools/studio-bridge && npx vitest run`. - -**Acceptance Criteria**: -- All existing tests pass without modification (or with minimal fixes for intentional API changes). -- New v2 handshake tests cover both `hello` and `register` paths. -- v1 backward compatibility is verified. -- Registry integration (session tracking) is tested. -- Persistent plugin detection with grace period is tested. -- `cd tools/studio-bridge && npx vitest run` passes with zero failures. - -**Do NOT**: -- Delete any existing tests (update them if the API changed, but do not remove coverage). -- Use default exports. -- Forget `.js` extensions on local imports. - ---- - -## Task 6.2: End-to-End Test Suite - -**Prerequisites**: All of Phases 1-4 must be completed first. - -**Context**: The system needs end-to-end tests that exercise the full lifecycle across all components: plugin connection, handshake, command execution, session management, split-server relay, and failover recovery. - -**Objective**: Create a comprehensive E2E test suite with a shared mock plugin client helper. - -**Read First**: -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/server/studio-bridge-server.ts` (server under test) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/bridge/bridge-connection.ts` (bridge connection API) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/server/web-socket-protocol.ts` (v2 protocol types) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/bridge/internal/bridge-host.ts` (host implementation) -- `/workspaces/NevermoreEngine/studio-bridge/plans/execution/validation/shared-test-utilities.md` (MockPluginClient spec) - -**Files to Create**: -- `src/test/helpers/mock-plugin-client.ts` -- reusable mock plugin that speaks the v2 protocol -- `src/test/e2e/persistent-session.test.ts` -- persistent plugin lifecycle tests -- `src/test/e2e/split-server.test.ts` -- bridge host + remote client relay tests -- `src/test/e2e/hand-off.test.ts` -- full-stack failover scenarios - -**Requirements**: - -1. **`mock-plugin-client.ts`** -- Create a reusable mock plugin client: - -```typescript -export interface MockPluginClientOptions { - port: number; - instanceId?: string; - context?: 'edit' | 'client' | 'server'; - placeName?: string; - capabilities?: Capability[]; - protocolVersion?: number; -} - -export class MockPluginClient { - async connectAsync(): Promise; - async sendRegisterAsync(): Promise; - async waitForWelcomeAsync(timeoutMs?: number): Promise; - async disconnectAsync(): Promise; - onMessage(type: string, handler: (msg: any) => any): void; - get isConnected(): boolean; - get sessionId(): string; -} -``` - -2. **`persistent-session.test.ts`** -- Test the full persistent session lifecycle: - - Plugin connects, sends `register`, receives `welcome` with capabilities. - - Server sends `execute`, plugin sends `output` + `scriptComplete`. - - Server sends `queryState`, plugin sends `stateResult`. - - Server sends `captureScreenshot`, plugin sends `screenshotResult` with base64 data. - - Server sends `queryDataModel`, plugin sends `dataModelResult`. - - Server sends `queryLogs`, plugin sends `logsResult`. - - Plugin sends `heartbeat`, server accepts silently. - - Plugin disconnects, session is removed from list. - - Plugin reconnects with new session ID but same instance ID. - -3. **`split-server.test.ts`** -- Test bridge host + remote client: - - Start a bridge host (`BridgeConnection.connectAsync({ keepAlive: true })`). - - Connect a mock plugin to the host. - - Connect a bridge client (`BridgeConnection.connectAsync({ remoteHost: ... })`). - - From the client, list sessions and verify the plugin's session appears. - - From the client, execute a command (e.g., `queryState`) and verify it relays through the host to the plugin and back. - - Disconnect the client, verify the host and plugin remain connected. - -4. **`hand-off.test.ts`** -- Test full-stack failover: - - Start host, connect plugin and client. - - Kill host, verify client promotes to host. - - Verify plugin reconnects to the new host. - - Verify commands work through the new host. - -5. All tests must: - - Use ephemeral ports (`port: 0`) to avoid conflicts. - - Clean up all connections in `afterEach`. - - Use `vi.useFakeTimers()` for timing-sensitive assertions. - - Complete within 30 seconds per test file. - -**Acceptance Criteria**: -- `MockPluginClient` speaks the v2 protocol and is reusable across all E2E test files. -- Persistent session lifecycle test covers: connect, register, execute, queryState, captureScreenshot, queryDataModel, queryLogs, heartbeat, disconnect, reconnect. -- Split-server test verifies command relay through the bridge host. -- Hand-off test verifies failover and plugin reconnection. -- All tests pass: `cd tools/studio-bridge && npx vitest run src/test/e2e/`. - -**Do NOT**: -- Create ad-hoc WebSocket clients -- use `MockPluginClient` for all plugin simulation. -- Hard-code port numbers. -- Use default exports. -- Forget `.js` extensions on local imports. - ---- - -## Task 6.3: Migration Guide - -**Prerequisites**: All phases (0-5) must be completed first. The guide must reflect the final implemented behavior. - -**Context**: Users of the existing studio-bridge need a migration guide covering the new features. The guide should be practical and task-oriented, not a reference manual. - -**Objective**: Write a user-facing migration guide covering the key new capabilities. - -**Read First**: -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/index.ts` (public API surface) -- `/workspaces/NevermoreEngine/tools/studio-bridge/src/commands/index.ts` (all available commands) -- `/workspaces/NevermoreEngine/tools/studio-bridge/package.json` (version, bin entry) -- `/workspaces/NevermoreEngine/studio-bridge/plans/tech-specs/00-overview.md` (feature overview) - -**Files to Create**: -- Documentation content at a location determined by the docs structure (e.g., `docs/studio-bridge/migration-guide.md` or within the `tools/studio-bridge/` directory) - -**Requirements**: - -1. **Installing the persistent plugin**: - - `studio-bridge install-plugin` command and what it does. - - Where the plugin file is placed (platform-specific paths). - - How to verify installation (`studio-bridge sessions` shows the plugin). - - How to uninstall (`studio-bridge uninstall-plugin`). - -2. **New CLI commands**: - - `studio-bridge state` -- query Studio state. - - `studio-bridge screenshot` -- capture viewport screenshot. - - `studio-bridge logs` -- retrieve and stream output logs. - - `studio-bridge query ` -- query the DataModel. - - `studio-bridge sessions` -- list active sessions. - - Brief description and most-used flags for each. - -3. **Split-server mode for devcontainers**: - - When to use it (Docker/devcontainer/Codespaces environments). - - How to start the host: `studio-bridge serve` on the host OS. - - How to connect from the container: automatic detection or `--remote host:port`. - - Port forwarding requirements. - -4. **MCP configuration for AI agents**: - - How to register `studio-bridge mcp` as a Claude Code MCP tool provider. - - Example `.mcp.json` configuration. - - List of available MCP tools (`studio_sessions`, `studio_state`, `studio_screenshot`, `studio_logs`, `studio_query`, `studio_exec`). - -5. **Breaking changes** (if any): - - Document any changes to existing command behavior. - - Document any changes to the programmatic API (`index.ts` exports). - -**Acceptance Criteria**: -- Guide covers all four sections: persistent plugin, new commands, split-server, MCP. -- Each section has a concrete "getting started" example. -- All command names and flags match the actual implementation. -- Guide is accurate against the implemented code (review agent should verify). -- Guide is concise: aim for 2-4 pages total, not a reference manual. - -**Do NOT**: -- Include implementation details that users do not need. -- Reference internal types or file paths. -- Create placeholder sections for unimplemented features. - ---- - -## Cross-References - -- Phase plan: [studio-bridge/plans/execution/phases/06-integration.md](../phases/06-integration.md) -- Validation: [studio-bridge/plans/execution/validation/06-integration.md](../validation/06-integration.md) -- Tech spec: `studio-bridge/plans/tech-specs/00-overview.md` diff --git a/studio-bridge/plans/execution/output-modes-plan.md b/studio-bridge/plans/execution/output-modes-plan.md deleted file mode 100644 index 3b34708eed..0000000000 --- a/studio-bridge/plans/execution/output-modes-plan.md +++ /dev/null @@ -1,718 +0,0 @@ -# CLI Output Modes Plan - -This document describes how to extend `@quenty/cli-output-helpers` with command-level output mode abstractions so that studio-bridge can reuse the same formatting infrastructure as nevermore-cli without duplicating code. - -References: -- Existing package: `tools/cli-output-helpers/` -- Command system spec: `../tech-specs/02-command-system.md` -- Execution plan: `plan.md` - ---- - -## 1. Goal - -Studio-bridge commands need to output data in three modes: - -- **Table mode**: Human-readable formatted tables for TTY output (e.g., `studio-bridge sessions` shows a table of active sessions) -- **JSON mode**: Machine-readable JSON for piping and scripting (e.g., `--json` flag) -- **Watch mode**: Continuously updating output for real-time monitoring (e.g., `--watch` flag on `sessions`, `--follow` on `logs`) - -The existing `@quenty/cli-output-helpers` package (`tools/cli-output-helpers/`) already provides batch job reporting infrastructure used by both `nevermore-cli` and `studio-bridge`: `Reporter` lifecycle hooks, `SpinnerReporter` (TTY progress), `GroupedReporter` (CI output), `SummaryTableReporter` (final summary), `JsonFileReporter` (JSON output to file), `OutputHelper` (color/styling), and `CompositeReporter` (fan-out to multiple reporters). - -However, the existing reporters are designed for **batch job progress** (packages moving through phases toward pass/fail). Studio-bridge commands need **command-level output formatting** -- taking a `CommandResult` and rendering it as a table, JSON, or live-updating stream. These are complementary concerns, not conflicting ones. - -The goal is to add command output mode abstractions to `@quenty/cli-output-helpers` so that the CLI adapter in studio-bridge (and potentially nevermore-cli in the future) can select an output mode based on flags and context, without each command implementing its own formatting logic. - -## 2. Current State: What Already Exists - -### 2.1 Package: `@quenty/cli-output-helpers` (`tools/cli-output-helpers/`) - -Source modules: - -| Module | Purpose | Reusable for command output? | -|--------|---------|------------------------------| -| `outputHelper.ts` | Color formatting (`formatError`, `formatInfo`, `formatDim`, etc.), box drawing, verbose/buffered output | Yes -- color/styling is general-purpose | -| `cli-utils.ts` | `formatDurationMs()`, `isCI()` | Yes -- utility functions | -| `reporting/reporter.ts` | `Reporter` interface, `BaseReporter`, `PackageResult`, `BatchSummary` types | No -- batch-job-specific lifecycle | -| `reporting/composite-reporter.ts` | Fan-out to multiple reporters | No -- batch-job-specific | -| `reporting/spinner-reporter.ts` | TTY spinner with live-updating package status lines | Partially -- the TTY rewrite technique is reusable | -| `reporting/summary-table-reporter.ts` | Final summary table after batch run | Partially -- table formatting logic is reusable | -| `reporting/json-file-reporter.ts` | Write JSON results to a file | No -- writes to file, not stdout | -| `reporting/simple-reporter.ts` | Single-package pass/fail output | No -- batch-job-specific | -| `reporting/grouped-reporter.ts` | CI grouped output with `::group::` | No -- batch-job-specific | -| `reporting/github/*` | GitHub PR comments, job summaries, annotations | No -- GitHub-specific | -| `reporting/state/*` | `IStateTracker`, `LiveStateTracker`, `LoadedStateTracker` | No -- batch state tracking | - -### 2.2 What nevermore-cli uses - -nevermore-cli consumes `@quenty/cli-output-helpers` for: -- `OutputHelper` for colored console output in all commands -- The full `Reporter` stack (`CompositeReporter` + `SpinnerReporter`/`GroupedReporter` + `SummaryTableReporter` + `JsonFileReporter` + GitHub reporters) for `nevermore batch test` and `nevermore batch deploy` -- `GithubCommentColumn` / `GithubCommentTableConfig` types for customizing GitHub PR comment tables - -### 2.3 What studio-bridge uses today - -studio-bridge currently imports `OutputHelper` from `@quenty/cli-output-helpers` for colored error/info messages in its CLI commands. It does not use any of the reporting infrastructure. - -## 3. What to Add - -Add a new `output-modes/` directory inside `cli-output-helpers/src/` with command-level output formatting: - -### 3.1 Table formatter - -A utility that takes structured data (array of objects) and renders it as an aligned, colored terminal table. This is NOT the same as `SummaryTableReporter` (which is a batch reporter that renders pass/fail results). This is a general-purpose table renderer for arbitrary structured data. - -```typescript -// tools/cli-output-helpers/src/output-modes/table-formatter.ts - -export interface TableColumn { - /** Column header label */ - header: string; - /** Extract the cell value from a row */ - value: (row: T) => string; - /** Minimum width (default: header length) */ - minWidth?: number; - /** Alignment: 'left' | 'right' (default: 'left') */ - align?: 'left' | 'right'; - /** Optional color function for the cell value */ - format?: (value: string, row: T) => string; -} - -export interface TableOptions { - /** Whether to print column headers (default: true) */ - showHeaders?: boolean; - /** Whether to print a separator line below headers (default: true) */ - showSeparator?: boolean; - /** Indent prefix for each line (default: '') */ - indent?: string; -} - -/** - * Format an array of rows as an aligned terminal table. - * Returns the formatted string (does not print it). - */ -export function formatTable( - rows: T[], - columns: TableColumn[], - options?: TableOptions -): string; -``` - -Studio-bridge usage: the `sessions` command defines columns for Session ID, Place, State, Origin, Duration and calls `formatTable()` to produce the summary string in its `CommandResult`. - -### 3.2 JSON formatter - -A thin wrapper that standardizes JSON output for `--json` mode. Handles pretty-printing for TTY, compact for pipes, and consistent structure. - -```typescript -// tools/cli-output-helpers/src/output-modes/json-formatter.ts - -export interface JsonOutputOptions { - /** Pretty-print with indentation (default: true for TTY, false for pipe) */ - pretty?: boolean; -} - -/** - * Format structured data as JSON for stdout. - * Returns the formatted string. - */ -export function formatJson(data: unknown, options?: JsonOutputOptions): string; -``` - -This is intentionally simple. The value is consistency -- every command that supports `--json` produces output with the same formatting conventions. - -### 3.3 Watch renderer - -A utility for commands that support `--watch` or `--follow` mode. Handles the TTY rewrite loop (clear previous output, render updated state) and the non-TTY fallback (append new lines). - -```typescript -// tools/cli-output-helpers/src/output-modes/watch-renderer.ts - -export interface WatchRendererOptions { - /** Render interval in ms (default: 1000) */ - intervalMs?: number; - /** Whether to clear and rewrite (TTY) or append (non-TTY). Auto-detected if not set. */ - rewrite?: boolean; -} - -/** - * Create a watch renderer that periodically calls a render function - * and updates the terminal output. - * - * For TTY: clears previous output and rewrites (like SpinnerReporter). - * For non-TTY: appends only new/changed lines. - */ -export function createWatchRenderer( - render: () => string, - options?: WatchRendererOptions -): WatchRenderer; - -export interface WatchRenderer { - /** Start the render loop */ - start(): void; - /** Force an immediate re-render */ - update(): void; - /** Stop the render loop and show final state */ - stop(): void; -} -``` - -The TTY rewrite technique is extracted from `SpinnerReporter._render()` which already implements cursor-up + clear-to-end-of-screen for live-updating output. - -### 3.4 Output mode selector - -A utility that the CLI adapter uses to select the correct output mode based on flags and context. - -```typescript -// tools/cli-output-helpers/src/output-modes/output-mode.ts - -export type OutputMode = 'table' | 'json' | 'text'; - -/** - * Determine the output mode based on CLI flags and environment. - * - * Priority chain (first match wins): - * 1. --json flag -> 'json' - * 2. STUDIO_BRIDGE_OUTPUT=json env var -> 'json' - * 3. STUDIO_BRIDGE_OUTPUT=text env var -> 'text' - * 4. Non-TTY (piped) -> 'text' - * 5. TTY -> 'table' - * - * See section 8 for the full mode selection rules. - */ -export function resolveOutputMode(options: { - json?: boolean; - isTTY?: boolean; - envOverride?: string; -}): OutputMode; -``` - -### 3.5 Barrel export - -```typescript -// tools/cli-output-helpers/src/output-modes/index.ts - -export { formatTable, type TableColumn, type TableOptions } from './table-formatter.js'; -export { formatJson, type JsonOutputOptions } from './json-formatter.js'; -export { createWatchRenderer, type WatchRenderer, type WatchRendererOptions } from './watch-renderer.js'; -export { resolveOutputMode, type OutputMode } from './output-mode.js'; -``` - -Add to the package's top-level exports so consumers can import from `@quenty/cli-output-helpers/output-modes` or re-export from the main barrel. - -## 4. Package Structure After Changes - -No new package is created. The changes are additive to `tools/cli-output-helpers/`: - -``` -tools/cli-output-helpers/ - package.json # unchanged (no new dependencies needed) - tsconfig.json # unchanged - src/ - outputHelper.ts # unchanged - cli-utils.ts # unchanged - reporting/ # unchanged -- batch job reporting - index.ts - reporter.ts - composite-reporter.ts - spinner-reporter.ts - summary-table-reporter.ts - json-file-reporter.ts - simple-reporter.ts - grouped-reporter.ts - state/ - state-tracker.ts - live-state-tracker.ts - loaded-state-tracker.ts - github/ - index.ts - formatting.ts - comment-table-reporter.ts - job-summary-reporter.ts - github-api.ts - annotations.ts - output-modes/ # NEW -- command-level output formatting - index.ts # barrel export - table-formatter.ts # general-purpose table rendering - table-formatter.test.ts # unit tests - json-formatter.ts # standardized JSON output - json-formatter.test.ts # unit tests - watch-renderer.ts # live-updating output (TTY rewrite) - watch-renderer.test.ts # unit tests - output-mode.ts # output mode selection utility - output-mode.test.ts # unit tests -``` - -### 4.1 Why extend, not extract - -The original task description assumed reporting code lived inside `nevermore-cli` and needed to be extracted into a new package. In reality: - -1. **`@quenty/cli-output-helpers` already exists** as the shared reporting package, consumed by both `nevermore-cli` and `studio-bridge`. -2. **The batch reporting infrastructure is nevermore-cli-specific** in its domain (packages, phases, pass/fail) but not in its location -- it already lives in the shared package. -3. **Studio-bridge needs different abstractions** -- command output modes (table/JSON/watch) rather than batch job progress -- but they belong in the same shared package. -4. **Creating a second shared package** (`nevermore-cli-reporting`) would fragment the reporting surface and create confusion about which package to import from. - -The right approach is to add a new `output-modes/` directory to the existing `@quenty/cli-output-helpers` package. This keeps all CLI output formatting in one place, avoids a new package, and both consumers already depend on it. - -## 5. Integration with Studio-Bridge Command System - -### 5.1 CommandDefinition output configuration - -The `CommandDefinition` type (defined in `02-command-system.md` section 5) gains an optional `output` field that tells the CLI adapter how to format the handler's result: - -```typescript -export interface CommandOutputConfig { - /** Table columns for table output mode. If not provided, falls back to summary text. */ - table?: TableColumn[]; - /** Whether this command supports --watch mode */ - supportsWatch?: boolean; - /** Custom watch render function (if different from re-running the handler) */ - watchRender?: (data: T) => string; -} - -export interface CommandDefinition { - name: string; - description: string; - requiresSession: boolean; - args: ArgSpec[]; - handler: (input: TInput, context: CommandContext) => Promise; - /** Optional output formatting configuration */ - output?: CommandOutputConfig ? D : unknown>; -} -``` - -### 5.2 CLI adapter uses output modes - -The CLI adapter (`src/cli/adapters/cli-adapter.ts`) uses the output mode utilities from `@quenty/cli-output-helpers/output-modes`: - -```typescript -import { formatTable, formatJson, resolveOutputMode, createWatchRenderer } from '@quenty/cli-output-helpers/output-modes'; - -// In the handler: -const mode = resolveOutputMode({ - json: argv.json, - isTTY: !!process.stdout.isTTY, - envOverride: process.env.STUDIO_BRIDGE_OUTPUT, -}); - -if (mode === 'json') { - console.log(formatJson(result.data)); -} else if (mode === 'table' && definition.output?.table) { - const rows = Array.isArray(result.data) ? result.data : [result.data]; - console.log(formatTable(rows, definition.output.table)); -} else { - console.log(result.summary); -} -``` - -For watch mode: - -```typescript -if (argv.watch && definition.output?.supportsWatch) { - const renderer = createWatchRenderer(() => { - // Re-fetch and re-render - return definition.output!.watchRender?.(latestData) ?? result.summary; - }); - renderer.start(); - // Stop on Ctrl+C -} -``` - -### 5.3 MCP adapter skips formatting - -The MCP adapter always returns raw structured data -- it never uses table formatting, JSON formatting, or watch mode. It imports nothing from `output-modes/`. - -```typescript -// MCP adapter -- no formatting, just raw data -return { - content: [{ type: 'text', text: JSON.stringify(result.data) }], -}; -``` - -### 5.4 Terminal adapter uses summary text - -The terminal adapter prints `result.summary` (which may include inline table formatting if the handler used `formatTable` to compose it). It does not use `--json` or `--watch`. - -### 5.5 Example: sessions command with output config - -```typescript -// src/commands/sessions.ts -import { formatTable, type TableColumn } from '@quenty/cli-output-helpers/output-modes'; - -const sessionColumns: TableColumn[] = [ - { header: 'Session ID', value: (s) => s.sessionId.slice(0, 8) }, - { header: 'Place', value: (s) => s.placeName }, - { header: 'State', value: (s) => s.state, format: (v) => colorizeState(v) }, - { header: 'Origin', value: (s) => s.origin }, - { header: 'Connected', value: (s) => formatDuration(s.connectedAt) }, -]; - -export const sessionsCommand: CommandDefinition> = { - name: 'sessions', - description: 'List active Studio sessions', - requiresSession: false, - args: [], - output: { - table: sessionColumns, - supportsWatch: true, - }, - handler: async (_input, context) => { - const sessions = await context.connection.listSessionsAsync(); - return { - data: { sessions }, - summary: sessions.length > 0 - ? formatTable(sessions, sessionColumns) - : 'No active sessions.', - }; - }, -}; -``` - -## 6. Implementation Tasks - -### Task R.1: Table formatter - -**Description**: Implement `formatTable()` in `tools/cli-output-helpers/src/output-modes/table-formatter.ts`. Auto-sizes columns based on content width (respecting ANSI escape codes via `OutputHelper.stripAnsi`). Handles empty rows gracefully. - -**Complexity**: S - -**Acceptance criteria**: -- Columns auto-size to content width, with minimum width from `minWidth` or header length. -- ANSI escape codes in cell values do not break alignment (stripped for width calculation, preserved in output). -- Empty rows array produces empty string (no headers, no separator). -- Right-aligned columns pad on the left. -- Unit tests cover: basic table, empty data, ANSI colors, right alignment, custom indent. - -### Task R.2: JSON formatter - -**Description**: Implement `formatJson()` in `tools/cli-output-helpers/src/output-modes/json-formatter.ts`. Thin wrapper around `JSON.stringify` with TTY-aware pretty-printing. - -**Complexity**: XS - -**Acceptance criteria**: -- TTY output is pretty-printed with 2-space indentation. -- Non-TTY output is compact (single line). -- `pretty` option overrides auto-detection. -- Unit tests cover: pretty, compact, explicit override. - -### Task R.3: Watch renderer - -**Description**: Implement `createWatchRenderer()` in `tools/cli-output-helpers/src/output-modes/watch-renderer.ts`. Extract the TTY rewrite technique from `SpinnerReporter._render()` into a reusable utility. - -**Complexity**: S - -**Acceptance criteria**: -- TTY mode: clears previous output and rewrites on each interval. -- Non-TTY mode: appends only new content (no cursor manipulation). -- `update()` forces immediate re-render. -- `stop()` clears the interval and shows the cursor. -- Hides cursor on `start()`, shows cursor on `stop()`. -- Unit tests cover: start/stop lifecycle, update trigger, non-TTY fallback. - -### Task R.4: Output mode selector - -**Description**: Implement `resolveOutputMode()` in `tools/cli-output-helpers/src/output-modes/output-mode.ts`. - -**Complexity**: XS - -**Acceptance criteria**: -- `--json` flag returns `'json'` regardless of TTY. -- TTY without `--json` returns `'table'`. -- Non-TTY without `--json` returns `'text'`. -- Unit tests cover all three cases. - -### Task R.5: Barrel export and package integration - -**Description**: Create `output-modes/index.ts`, add exports to the cli-output-helpers package entry point. Ensure the new modules are included in the TypeScript build. - -**Complexity**: XS - -**Acceptance criteria**: -- `import { formatTable } from '@quenty/cli-output-helpers/output-modes'` works. -- All new modules are included in the build output. -- Existing imports from `@quenty/cli-output-helpers` and `@quenty/cli-output-helpers/reporting` are unchanged. - -### Task R.6: Add `output` field to CommandDefinition - -**Description**: Add the `CommandOutputConfig` type and optional `output` field to `CommandDefinition` in `src/commands/types.ts`. Update the CLI adapter in `src/cli/adapters/cli-adapter.ts` to use output mode utilities from `@quenty/cli-output-helpers/output-modes`. - -**Complexity**: S - -**Dependencies**: Tasks R.1, R.2, R.4, and execution plan Task 1.7 (command handler infrastructure). - -**Acceptance criteria**: -- `CommandDefinition` has an optional `output` field. -- CLI adapter selects output mode based on `--json` flag and TTY detection. -- Commands without an `output` field still work (fall back to `summary` text). -- MCP adapter ignores the `output` field entirely. - -## 7. Sequencing and Dependencies - -These tasks are **prerequisites for studio-bridge commands that need structured output** (specifically `sessions` in Task 2.6) but are **independent of the bridge networking infrastructure** (Phase 1 tasks 1.1-1.6). - -Recommended sequencing: - -1. **Tasks R.1-R.5** can be done in parallel with Phase 1 tasks (they modify `cli-output-helpers`, not `studio-bridge`). -2. **Task R.6** depends on Task 1.7 (command handler infrastructure) because it modifies `CommandDefinition`. -3. **Task 2.6** (sessions command) is the first command that benefits from `formatTable`. It can use table formatting from the handler's `summary` field even before Task R.6 integrates output modes into the CLI adapter. - -``` -R.1 (table) ────────┐ -R.2 (json) ─────────┤ -R.3 (watch) ────────┼──→ R.5 (barrel) ──→ R.6 (CommandDefinition output field) -R.4 (output mode) ──┘ ↑ - │ -1.7 (command handler infra) ───────────────────┘ -``` - -Tasks R.1-R.5 are independent of the studio-bridge execution plan and can be completed at any time before Phase 2. Task R.6 is part of Phase 2 and should be done alongside Task 1.7 or immediately after it. - -## 8. Mode Selection Rules - -The output mode is determined by a priority chain. The first matching rule wins: - -| Priority | Condition | Selected Mode | Rationale | -|----------|-----------|---------------|-----------| -| 1 | `--json` flag is set | `json` | Explicit user request for machine-readable output | -| 2 | `STUDIO_BRIDGE_OUTPUT=json` environment variable | `json` | CI/automation environments that always want JSON | -| 3 | `STUDIO_BRIDGE_OUTPUT=text` environment variable | `text` | Force plain text even on TTY | -| 4 | stdout is NOT a TTY (pipe detection via `process.stdout.isTTY`) | `text` | Piped output should not contain ANSI codes or table formatting | -| 5 | stdout IS a TTY | `table` | Human-friendly formatted output with colors | - -The `resolveOutputMode` function implements this chain: - -```typescript -export function resolveOutputMode(options: { - json?: boolean; - isTTY?: boolean; - envOverride?: string; // from STUDIO_BRIDGE_OUTPUT -}): OutputMode { - if (options.json) return 'json'; - if (options.envOverride === 'json') return 'json'; - if (options.envOverride === 'text') return 'text'; - if (!options.isTTY) return 'text'; - return 'table'; -} -``` - -The `--watch` flag is orthogonal to the output mode. It controls whether the command runs once or subscribes to live updates. Watch mode uses the `WatchRenderer` which adapts its behavior based on TTY detection (rewrite on TTY, append on non-TTY). - -There is no `--quiet` flag. The `text` mode serves this purpose -- it strips colors and table formatting, producing plain lines suitable for piping to `grep`, `jq`, or log files. Commands that output nothing on success (like `install-plugin`) simply print nothing in `text` mode. - -## 9. Exact Format Strings and Example Output - -### 9.1 Table mode (TTY, human-readable) - -Table mode uses the `formatTable` utility with column alignment and optional ANSI color formatting. The table format follows this structure: - -``` -{header1} {header2} {header3} ... -{sep1} {sep2} {sep3} ... -{value1} {value2} {value3} ... -``` - -- **Header row**: column headers, left-aligned by default, separated by 2 spaces minimum -- **Separator row**: dashes (`-`) matching the column width -- **Data rows**: cell values aligned to column width, separated by 2 spaces minimum -- **Column width**: `max(header.length, minWidth, max(cellValue.length for all rows))` -- **Padding character**: space (` `) -- **Column gap**: 2 spaces between columns - -**Color codes used by studio-bridge commands** (via `OutputHelper` from `@quenty/cli-output-helpers`): - -| Semantic | chalk function | ANSI code | Used for | -|----------|---------------|-----------|----------| -| Error | `chalk.redBright` | `\x1b[91m` | Error-level log entries, failed states, error messages | -| Warning | `chalk.yellowBright` | `\x1b[93m` | Warning-level log entries, `Paused` state | -| Info | `chalk.cyanBright` | `\x1b[96m` | `Edit` state, informational messages | -| Success | `chalk.greenBright` | `\x1b[92m` | `connected` state, `Play`/`Run` states, success messages | -| Dim | `chalk.dim` | `\x1b[2m` | Timestamps, durations, secondary metadata | -| Hint | `chalk.magentaBright` | `\x1b[95m` | Session IDs (truncated) | - -**Example: `studio-bridge sessions`** - -``` -Session ID Context Place State Origin Connected ----------- ------- -------------- ----- ------ --------- -a1b2c3d4 edit MyGame (12345) Edit user 5m ago -e5f6g7h8 server MyGame (12345) Run user 2m ago -i9j0k1l2 client MyGame (12345) Play user 2m ago -``` - -With colors: `State` column values are colorized (`Edit` = cyan, `Run`/`Play` = green, `Paused` = yellow). `Session ID` is magenta. `Connected` duration is dim. - -**Example: `studio-bridge state`** - -``` -Mode: Edit -Place: MyGame -PlaceId: 12345 -GameId: 67890 -Context: edit -``` - -This command uses key-value formatting (not a table), with keys left-padded to align the values. The `Mode` value is colorized by state (same color mapping as sessions). - -**Example: `studio-bridge logs`** - -``` -[14:30:01] [Print] Hello from server -[14:30:02] [Warning] Something suspicious happened -[14:30:03] [Error] Script error at line 5: attempt to index nil -(showing 3 of 342 entries) -``` - -Log entries use the format: `[{timestamp}] [{level}] {body}`. Timestamps are dim. Level labels are colorized: `[Print]` = default, `[Warning]` = yellow, `[Error]` = red. The summary line at the bottom is dim. - -**Example: `studio-bridge query Workspace.SpawnLocation`** - -``` -Name: SpawnLocation -ClassName: SpawnLocation -Path: game.Workspace.SpawnLocation -Properties: - Position: { type: "Vector3", value: [0, 5, 0] } - Anchored: true - Size: { type: "Vector3", value: [4, 1.2, 4] } -Children: 0 -``` - -**Example: `studio-bridge screenshot`** - -``` -Screenshot saved to /tmp/studio-bridge/screenshot-2026-02-23-143052.png (1920x1080) -``` - -### 9.2 JSON mode (`--json`) - -JSON mode outputs the raw `CommandResult.data` object serialized as JSON. On TTY, it is pretty-printed with 2-space indentation. When piped (non-TTY), it is compact (single line). - -**Format string**: -- TTY: `JSON.stringify(data, null, 2)` + newline -- Non-TTY: `JSON.stringify(data)` + newline - -No ANSI color codes are ever included in JSON output, regardless of TTY status. - -**Example: `studio-bridge sessions --json` (TTY)** - -```json -{ - "sessions": [ - { - "sessionId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - "instanceId": "inst-abc-123", - "context": "edit", - "placeName": "MyGame", - "placeId": 12345, - "gameId": 67890, - "state": "Edit", - "origin": "user", - "pluginVersion": "1.0.0", - "capabilities": ["execute", "queryState", "captureScreenshot", "queryDataModel", "queryLogs", "subscribe", "heartbeat"], - "connectedAt": "2026-02-23T14:25:00.000Z" - } - ] -} -``` - -**Example: `studio-bridge sessions --json` (piped)** - -``` -{"sessions":[{"sessionId":"a1b2c3d4-e5f6-7890-abcd-ef1234567890","instanceId":"inst-abc-123","context":"edit","placeName":"MyGame","placeId":12345,"gameId":67890,"state":"Edit","origin":"user","pluginVersion":"1.0.0","capabilities":["execute","queryState","captureScreenshot","queryDataModel","queryLogs","subscribe","heartbeat"],"connectedAt":"2026-02-23T14:25:00.000Z"}]} -``` - -**Example: `studio-bridge state --json`** - -```json -{ - "state": "Edit", - "placeName": "MyGame", - "placeId": 12345, - "gameId": 67890, - "context": "edit" -} -``` - -**Example: `studio-bridge logs --json`** - -```json -{ - "entries": [ - { "timestamp": "2026-02-23T14:30:01.000Z", "level": "Print", "body": "Hello from server" }, - { "timestamp": "2026-02-23T14:30:02.000Z", "level": "Warning", "body": "Something suspicious happened" }, - { "timestamp": "2026-02-23T14:30:03.000Z", "level": "Error", "body": "Script error at line 5: attempt to index nil" } - ], - "totalCount": 342, - "returnedCount": 3 -} -``` - -**Example: `studio-bridge screenshot --json`** - -```json -{ - "filePath": "/tmp/studio-bridge/screenshot-2026-02-23-143052.png", - "width": 1920, - "height": 1080, - "sizeBytes": 245760 -} -``` - -### 9.3 Text mode (non-TTY / piped / quiet) - -Text mode strips all ANSI color codes and table formatting. Output is plain lines, one per logical entry. This mode is designed for piping to `grep`, `awk`, `jq`, or redirecting to files. - -**Format rules**: -- No ANSI escape codes -- No box-drawing characters -- No spinner/progress indicators -- Tab-separated values for tabular data (instead of space-padded columns) -- No separator row below headers -- Timestamps in ISO 8601 format (not relative "5m ago") - -**Example: `studio-bridge sessions` (piped)** - -``` -Session ID Context Place State Origin Connected -a1b2c3d4-e5f6-7890-abcd-ef1234567890 edit MyGame (12345) Edit user 2026-02-23T14:25:00.000Z -e5f6g7h8-i9j0-1234-abcd-ef5678901234 server MyGame (12345) Run user 2026-02-23T14:28:00.000Z -``` - -Note: full session ID (not truncated) and ISO timestamp (not relative). - -**Example: `studio-bridge state` (piped)** - -``` -Mode: Edit -Place: MyGame -PlaceId: 12345 -GameId: 67890 -Context: edit -``` - -Key-value pairs, no padding alignment. - -**Example: `studio-bridge logs` (piped)** - -``` -2026-02-23T14:30:01.000Z Print Hello from server -2026-02-23T14:30:02.000Z Warning Something suspicious happened -2026-02-23T14:30:03.000Z Error Script error at line 5: attempt to index nil -``` - -Tab-separated: timestamp, level, body. No brackets, no color, no summary line. - -**Example: `studio-bridge screenshot` (piped)** - -``` -/tmp/studio-bridge/screenshot-2026-02-23-143052.png -``` - -Just the file path, nothing else. This allows `studio-bridge screenshot | xargs open`. - -## 10. What This Does NOT Cover - -- **Batch job reporting refactoring**: The existing `Reporter` / `SpinnerReporter` / `CompositeReporter` stack is not being changed. It continues to serve `nevermore batch test` and `nevermore batch deploy`. -- **GitHub reporters**: No changes to the GitHub comment/annotation/job-summary reporters. -- **A new package**: No new `nevermore-cli-reporting` package is created. Everything goes into the existing `@quenty/cli-output-helpers`. -- **nevermore-cli migration**: nevermore-cli does not need to change its imports or behavior. It can optionally adopt the `output-modes/` utilities for its own commands in the future, but that is not part of this plan. diff --git a/studio-bridge/plans/execution/phases/00-prerequisites.md b/studio-bridge/plans/execution/phases/00-prerequisites.md deleted file mode 100644 index 257ef44cd9..0000000000 --- a/studio-bridge/plans/execution/phases/00-prerequisites.md +++ /dev/null @@ -1,98 +0,0 @@ -# Phase 0: Prerequisites (Independent of studio-bridge) - -Goal: Extend `@quenty/cli-output-helpers` with command-level output mode utilities (table formatting, JSON output, watch/follow mode) that the CLI adapter will use. These tasks modify `tools/cli-output-helpers/`, not `tools/studio-bridge/`, and can be completed in parallel with Phase 1. - -Full design: `studio-bridge/plans/execution/output-modes-plan.md` - -References: -- Output modes plan: `studio-bridge/plans/execution/output-modes-plan.md` - -Base path for all file references: `/workspaces/NevermoreEngine/tools/studio-bridge/` - -Cross-references: -- Detailed design: `studio-bridge/plans/execution/output-modes-plan.md` -- Agent prompts: `studio-bridge/plans/execution/agent-prompts/00-prerequisites.md` -- Note: Phase 0 has no validation file (tests are specified in `studio-bridge/plans/execution/output-modes-plan.md`) - ---- - -### Task 0.1: Table formatter - -**Description**: Implement `formatTable()` in `tools/cli-output-helpers/src/output-modes/table-formatter.ts`. A general-purpose utility that renders an array of objects as an aligned, colored terminal table with auto-sized columns. - -**Files to create**: -- `tools/cli-output-helpers/src/output-modes/table-formatter.ts` -- `tools/cli-output-helpers/src/output-modes/table-formatter.test.ts` - -**Dependencies**: None. - -**Complexity**: S - -**Acceptance criteria**: -- Columns auto-size to content width, with minimum width from `minWidth` or header length. -- ANSI escape codes in cell values do not break alignment (stripped for width calculation via `OutputHelper.stripAnsi`, preserved in output). -- Empty rows array produces empty string. -- Right-aligned columns pad on the left. -- Unit tests cover: basic table, empty data, ANSI colors, right alignment, custom indent. - -### Task 0.2: JSON formatter - -**Description**: Implement `formatJson()` in `tools/cli-output-helpers/src/output-modes/json-formatter.ts`. TTY-aware JSON formatting (pretty for TTY, compact for pipes). - -**Files to create**: -- `tools/cli-output-helpers/src/output-modes/json-formatter.ts` -- `tools/cli-output-helpers/src/output-modes/json-formatter.test.ts` - -**Dependencies**: None. - -**Complexity**: XS - -### Task 0.3: Watch renderer - -**Description**: Implement `createWatchRenderer()` in `tools/cli-output-helpers/src/output-modes/watch-renderer.ts`. Extract the TTY rewrite technique from `SpinnerReporter._render()` into a reusable utility for live-updating command output. - -**Files to create**: -- `tools/cli-output-helpers/src/output-modes/watch-renderer.ts` -- `tools/cli-output-helpers/src/output-modes/watch-renderer.test.ts` - -**Dependencies**: None. - -**Complexity**: S - -### Task 0.4: Output mode selector and barrel export - -**Description**: Implement `resolveOutputMode()` in `tools/cli-output-helpers/src/output-modes/output-mode.ts`. Create `output-modes/index.ts` barrel. Ensure new modules are included in the build. - -**Files to create**: -- `tools/cli-output-helpers/src/output-modes/output-mode.ts` -- `tools/cli-output-helpers/src/output-modes/output-mode.test.ts` -- `tools/cli-output-helpers/src/output-modes/index.ts` - -**Dependencies**: Tasks 0.1, 0.2, 0.3 (for barrel exports). - -**Complexity**: XS - -### Parallelization within Phase 0 - -Tasks 0.1, 0.2, and 0.3 have no dependencies and can proceed in parallel. Task 0.4 depends on all three for the barrel export but is trivially small. - -``` -0.1 (table) --------+ -0.2 (json) ---------+---> 0.4 (barrel + output mode selector) -0.3 (watch) --------+ -``` - -Phase 0 is fully independent of Phases 1-6. It modifies only `tools/cli-output-helpers/`. The output mode utilities are consumed by Task 1.7 (command handler infrastructure, specifically the CLI adapter) and by individual command handlers (Tasks 2.6, 3.1-3.4) that use `formatTable` in their `summary` composition. - ---- - -## Failure Modes - -Default policy: **escalate integration issues to review agent, self-fix isolated issues.** - -| Task | Likely Failure | Recovery Action | -|------|---------------|-----------------| -| 0.1 (table formatter) | ANSI stripping regex misses edge cases, breaking column alignment | Self-fix: add failing ANSI sequences to test suite and fix regex | -| 0.2 (JSON formatter) | TTY detection returns wrong value in CI or piped contexts | Self-fix: add explicit `isTTY` parameter override for testability | -| 0.3 (watch renderer) | Terminal rewrite technique does not work on all terminal emulators | Self-fix: degrade gracefully to append-only output when TERM is unsupported | -| 0.4 (barrel export) | Import paths break when consumed from `tools/studio-bridge/` | Escalate: this affects the cross-package contract between cli-output-helpers and studio-bridge; verify with the consuming package before merging | diff --git a/studio-bridge/plans/execution/phases/00.5-plugin-modules.md b/studio-bridge/plans/execution/phases/00.5-plugin-modules.md deleted file mode 100644 index 4d76965f6a..0000000000 --- a/studio-bridge/plans/execution/phases/00.5-plugin-modules.md +++ /dev/null @@ -1,197 +0,0 @@ -# Phase 0.5: Lune-Testable Plugin Modules - -Goal: Extract pure Luau logic modules from the plugin that can be tested via Lune (a standalone Luau runtime) without Roblox Studio. These modules form Layer 1 of the plugin architecture -- they have zero Roblox API dependencies. Phase 2 (Task 2.1) then writes only the thin Layer 2 glue that wires these modules to actual Roblox services. - -References: -- Protocol: `studio-bridge/plans/tech-specs/01-protocol.md` -- Persistent plugin: `studio-bridge/plans/tech-specs/03-persistent-plugin.md` -- Action specs: `studio-bridge/plans/tech-specs/04-action-specs.md` - -Base path for plugin template files: `/workspaces/NevermoreEngine/tools/studio-bridge/templates/studio-bridge-plugin/` - -Cross-references: -- Phase 2 (Task 2.1) depends on this phase for Layer 1 modules -- Task 0.5.4 depends on Task 1.3a (bridge host) for the TypeScript WebSocket server - ---- - -## Architecture: Three Layers - -**Layer 1 -- Pure Luau modules** (this phase, testable via Lune): -- `Protocol.luau` -- Message encoding/decoding, frame parsing, WebSocket message construction -- `DiscoveryStateMachine.luau` -- State machine for discovery flow (idle -> searching -> connecting -> connected), transition logic, retry/backoff -- `ActionRouter.luau` -- Dispatches incoming action messages to handler functions, returns response messages -- `MessageBuffer.luau` -- Ring buffer for log messages, configurable capacity - -**Layer 2 -- Thin Roblox glue** (~100-150 LOC, built in Phase 2 Task 2.1): -- Wires Layer 1 modules to actual Roblox services (HttpService, RunService, LogService, CaptureService) -- Entry point: reads build constants, calls `DiscoveryStateMachine.start()`, connects `ActionRouter` to WebSocket - -**Layer 3 -- Lune integration tests** (Task 0.5.4): -- Lune test scripts that start a real TypeScript WebSocket server (from Task 1.3a) and connect a mock plugin client using the Protocol module -- Tests: handshake, register/welcome, action dispatch round-trip, heartbeat, reconnection - ---- - -### Task 0.5.1: Protocol module + Test harness - -**Description**: Create the shared Lune test harness (prerequisite for all Phase 0.5 tasks) AND implement the pure Luau message encoding/decoding module. The test harness consists of `test/roblox-mocks.luau` (minimal Roblox service stubs: HttpService, RunService, LogService, Signal mock) and `test/test-runner.luau` (discovers and runs `*.test.luau` files, prints pass/fail, exits 0 or 1). The Protocol module handles all v2 message types (register, welcome, execute, scriptComplete, queryState, stateResult, captureScreenshot, screenshotResult, queryDataModel, dataModelResult, queryLogs, logsResult, subscribe, unsubscribe, stateChange, heartbeat, shutdown, error) as well as v1 message types (hello, welcome, execute, scriptComplete, output). No Roblox API dependencies -- uses only standard Luau string/table operations. - -**Files to create**: -- `test/roblox-mocks.luau` -- Minimal Roblox service stubs for HttpService (JSONEncode/JSONDecode), RunService (IsStudio/IsRunning/Heartbeat signal), LogService (MessageOut signal), and a basic Signal mock (Connect/Disconnect/Fire). -- `test/test-runner.luau` -- Simple Lune test runner: takes test file paths as args (or auto-discovers `*.test.luau` in `test/`), runs each test via pcall, prints pass/fail per test, exits 0 (all pass) or 1 (any fail). Agents run: `lune run test/test-runner.luau`. -- `src/Shared/Protocol.luau` -- Message encode (table -> JSON string) and decode (JSON string -> typed table). Frame construction for WebSocket messages. Message type constants. Request ID generation. -- `test/protocol.test.luau` -- Lune unit tests for every message type encode/decode round-trip, malformed input handling, v1/v2 compatibility. - -**Dependencies**: None. (The test harness created here is a prerequisite for Tasks 0.5.2, 0.5.3, and 0.5.4.) - -**Complexity**: S - -**Agent-assignable**: yes - -**Acceptance criteria**: -- `test/roblox-mocks.luau` exists and exports stubs for HttpService, RunService, LogService, and Signal. -- `test/test-runner.luau` exists, discovers and runs `*.test.luau` files, prints pass/fail, and exits with code 0 or 1. -- `lune run test/test-runner.luau` runs successfully (exit code 0) with the protocol tests. -- `Protocol.encode(message)` serializes a message table to a JSON string. -- `Protocol.decode(jsonString)` deserializes a JSON string to a typed message table, returning `nil` for malformed input. -- All v2 message types round-trip correctly (encode then decode produces the original table). -- v1 message types (`hello`, `welcome`, `execute`, `scriptComplete`, `output`) also round-trip correctly. -- `Protocol.createRequestId()` returns a unique string suitable for request correlation. -- No `require` of any Roblox service (HttpService, game, etc.). -- All Lune tests pass. - -### Task 0.5.2: Discovery state machine - -**Description**: Implement the discovery state machine as a pure Luau module. The state machine drives the plugin's connection lifecycle: idle -> searching -> connecting -> connected, with retry logic and exponential backoff. All side effects (HTTP requests, WebSocket connections) are injected via callbacks, making the state machine fully testable without Roblox APIs. - -**Files to create**: -- `src/Shared/DiscoveryStateMachine.luau` -- State machine with states: `idle`, `searching`, `connecting`, `connected`, `reconnecting`. Transition functions. Retry logic with configurable exponential backoff (1s, 2s, 4s, 8s, max 30s). Takes injected callbacks: `onHttpPoll(url) -> result`, `onWebSocketConnect(url) -> connection`, `onStateChange(oldState, newState)`. -- `test/discovery.test.luau` -- Lune unit tests for state transitions, retry timing, backoff progression, callback invocation order, error handling during transitions. - -**Dependencies**: Task 0.5.1 (uses Protocol module for message types). - -**Complexity**: M - -**Agent-assignable**: yes - -**Acceptance criteria**: -- State machine starts in `idle` state. -- `start()` transitions from `idle` to `searching`. -- The heartbeat loop runs as a `task.spawn` coroutine in the Layer 2 glue, not inside the state machine. The state machine exposes `isConnected()` which the coroutine checks each iteration. When the state leaves `connected`, the coroutine exits cleanly (do not use `task.cancel` -- it can leave partial WebSocket frames). See `studio-bridge/plans/tech-specs/03-persistent-plugin.md` section 6.3 for the full pattern. -- In `searching` state, the machine calls the injected HTTP poll callback at configurable intervals. -- When the health check succeeds, transitions to `connecting` and calls the WebSocket connect callback. -- When WebSocket connects successfully, transitions to `connected`. -- When WebSocket disconnects while in `connected`, transitions to `reconnecting` with exponential backoff (1s, 2s, 4s, 8s, max 30s). -- `stop()` transitions to `idle` from any state and cancels pending retries. -- The `onStateChange` callback fires for every transition with `(oldState, newState)`. -- All callbacks are injected -- no Roblox API dependencies. -- All Lune tests pass. - -### Task 0.5.3: Action router and message buffer - -**Description**: Implement the action router (dispatches incoming action messages to registered handler functions) and the message buffer (ring buffer for log collection) as pure Luau modules. Both are used by the plugin to process server commands and buffer output. - -**Files to create**: -- `src/Shared/ActionRouter.luau` -- `ActionRouter.new()` constructor. `router:registerHandler(actionType, handlerFn)` registers a handler. `router:dispatch(message) -> responseMessage` looks up the handler by `message.type`, calls it, and returns the response message. Returns an error response for unregistered action types. -- `src/Shared/MessageBuffer.luau` -- `MessageBuffer.new(capacity)` constructor. `buffer:push(message)` adds a message (oldest evicted when full). `buffer:flush() -> messages[]` returns all buffered messages and clears the buffer. `buffer:count() -> number`. -- `test/actions.test.luau` -- Lune unit tests for handler registration, dispatch, unknown action handling, error responses, buffer push/flush/eviction. - -**Dependencies**: Task 0.5.1 (uses Protocol module for message types). - -**Complexity**: S - -**Agent-assignable**: yes - -**Acceptance criteria**: -- `ActionRouter:registerHandler("execute", fn)` registers the handler for `execute` messages. -- `ActionRouter:dispatch(message)` calls the registered handler and returns its response. -- Dispatching an unregistered action type returns an error response with `type = "error"` and a descriptive message. -- `MessageBuffer.new(100)` creates a buffer with capacity 100. -- `MessageBuffer:push(msg)` adds messages; when capacity is exceeded, the oldest message is evicted. -- `MessageBuffer:flush()` returns all buffered messages in insertion order and clears the buffer. -- `MessageBuffer:count()` returns the current number of buffered messages. -- No Roblox API dependencies. -- All Lune tests pass. - -### Task 0.5.4: Lune integration tests - -**Description**: Cross-language integration tests that start a real TypeScript WebSocket server (the bridge host from Task 1.3a) and connect a mock Lune-based plugin client using the Protocol module. These tests validate the full protocol round-trip without requiring Roblox Studio. - -**Files to create**: -- `test/integration/lune-bridge.test.luau` -- Lune test script that: - 1. Spawns the TypeScript bridge host process - 2. Creates a mock plugin client using the Protocol module - 3. Performs a register -> welcome handshake - 4. Sends/receives action messages (execute, queryState) - 5. Tests heartbeat keepalive - 6. Tests reconnection after intentional disconnect - -**Dependencies**: Tasks 0.5.1, 0.5.2, 0.5.3 (all Layer 1 modules), Task 1.3a (bridge host for the TypeScript WebSocket server). - -**Complexity**: M - -**Agent-assignable**: yes (requires Lune installed via aftman) - -**Acceptance criteria**: -- Integration test starts a TypeScript bridge host and connects a Lune mock plugin. -- Register -> welcome handshake completes successfully. -- Execute action round-trip: server sends `execute`, mock plugin processes it via ActionRouter, server receives `scriptComplete`. -- Heartbeat messages are sent and acknowledged. -- After intentional disconnect, the DiscoveryStateMachine drives reconnection and the mock plugin re-registers. -- All tests clean up spawned processes in teardown. - ---- - -### Parallelization within Phase 0.5 - -Tasks 0.5.1, 0.5.2, and 0.5.3 have minimal dependencies on each other (0.5.2 and 0.5.3 use Protocol types from 0.5.1, but the Protocol interface is small enough that they can be developed against a stub). Task 0.5.4 depends on all three Layer 1 modules plus the bridge host from Phase 1 (Task 1.3a). - -``` -0.5.1 (Protocol) --+--> 0.5.2 (Discovery state machine) --+--> 0.5.4 (Lune integration) - | | - +--> 0.5.3 (Action router + buffer) ---+ - | -Phase 1: 1.3a (bridge host) --------------------------------+ -``` - -Phase 0.5 has NO dependency on Phase 0 or Phase 1 (except 0.5.4 needs 1.3a). Tasks 0.5.1-0.5.3 can run in parallel with everything. - ---- - -## Phase 0.5 Gate - -All Lune unit tests pass. Integration test (Task 0.5.4) completes a register -> welcome -> exec -> result round-trip against the real TypeScript bridge host. - ---- - -## Testing Strategy (Phase 0.5) - -**Lune unit tests** (Tasks 0.5.1-0.5.3): -- Protocol encode/decode for every v2 message type, including malformed input. -- Discovery state machine transitions: idle -> searching -> connecting -> connected -> reconnecting -> connected. -- Discovery backoff progression: 1s, 2s, 4s, 8s, 8s (capped at max). -- ActionRouter dispatch to registered handlers, error response for unknown types. -- MessageBuffer push/flush/eviction at capacity boundary. - -**Lune integration tests** (Task 0.5.4): -- Full protocol round-trip with real TypeScript WebSocket server. -- Handshake, action dispatch, heartbeat, reconnection. -- Cross-language message compatibility (Luau JSON encoding matches TypeScript expectations). - ---- - -## Failure Modes - -Default policy: **escalate integration issues to review agent, self-fix isolated issues.** - -| Task | Likely Failure | Recovery Action | -|------|---------------|-----------------| -| 0.5.1 (Protocol) | JSON encoding differences between Luau and TypeScript (e.g., number precision, nil vs null) | Self-fix: add round-trip tests that encode in Luau and decode in TypeScript (and vice versa). Fix encoding to match JSON spec. | -| 0.5.1 (test harness) | Roblox mock stubs are too minimal, causing downstream test failures in 0.5.2/0.5.3 | Self-fix: extend mocks as needed. Keep mocks minimal but sufficient. | -| 0.5.2 (DiscoveryStateMachine) | State machine has unreachable states or missing transitions that only surface in real Studio | Self-fix for logic errors caught by Lune tests. Escalate if the state machine design itself is flawed (e.g., missing a state that Studio requires). | -| 0.5.2 (DiscoveryStateMachine) | Backoff timing is untestable without real delays | Self-fix: inject a clock/timer abstraction so tests can advance time without waiting. | -| 0.5.3 (ActionRouter) | Dispatch table design does not match how Phase 2 action handlers need to register | Escalate: this is a cross-phase interface issue. Review with Phase 2 task owner before changing the API. | -| 0.5.3 (MessageBuffer) | Ring buffer eviction order is wrong (LIFO instead of FIFO) | Self-fix: add explicit ordering test and fix. | -| 0.5.4 (Lune integration) | TypeScript bridge host process spawning fails in CI or different environments | Self-fix: ensure process spawn uses absolute paths and waits for port readiness before connecting. | -| 0.5.4 (Lune integration) | Lune WebSocket client API differs from Roblox WebSocket API, invalidating the integration test | Escalate: this affects the cross-language contract. The Lune mock may need adjustment, or the Protocol module may need to abstract the transport layer. | diff --git a/studio-bridge/plans/execution/phases/01-bridge-network.md b/studio-bridge/plans/execution/phases/01-bridge-network.md deleted file mode 100644 index 8df62bdaaa..0000000000 --- a/studio-bridge/plans/execution/phases/01-bridge-network.md +++ /dev/null @@ -1,588 +0,0 @@ -# Phase 1: Foundation - -Goal: Extend the protocol, build the bridge host/client module, and wrap `BridgeConnection` in the existing `StudioBridge` export -- without changing any user-visible behavior. All existing tests pass, all existing CLI commands work identically. - -References: -- Protocol: `studio-bridge/plans/tech-specs/01-protocol.md` -- Command system: `studio-bridge/plans/tech-specs/02-command-system.md` -- Bridge Network layer: `studio-bridge/plans/tech-specs/07-bridge-network.md` - -Base path for all file references: `/workspaces/NevermoreEngine/tools/studio-bridge/` - -Cross-references: -- Agent prompts: `studio-bridge/plans/execution/agent-prompts/01-bridge-network.md` -- Validation: `studio-bridge/plans/execution/validation/01-bridge-network.md` -- Failover tasks (1.8-1.10) have been moved to Phase 1b: `01b-failover.md` -- Note: Task 1.7a depends on Phase 0 for output mode utilities (see `00-prerequisites.md`) - ---- - -### Public API Freeze - -The following method signatures, type exports, and re-exports from `src/index.ts` MUST remain unchanged throughout Phase 1. Any change to these is a backward-compatibility break: - -```typescript -// From StudioBridgeServer (exported as StudioBridge via: export { StudioBridgeServer as StudioBridge }): -constructor(options?: StudioBridgeServerOptions) -startAsync(): Promise -executeAsync(options: ExecuteOptions): Promise -stopAsync(): Promise -``` - -These are consumed by `LocalJobContext` in `/workspaces/NevermoreEngine/tools/nevermore-cli/src/utils/job-context/local-job-context.ts`. New methods and new exports are additive and permitted; changes to the above signatures are not. - ---- - -### Task 1.1: Protocol v2 type definitions - -**Description**: Add all v2 message types, capability strings, error codes, and serialization types to the protocol module. Extend the existing `decodePluginMessage` to handle new message types. Add a new `decodeServerMessage` function. Preserve every existing type and function signature unchanged. - -**Files to create or modify**: -- Modify: `src/server/web-socket-protocol.ts` -- add base message hierarchy (`BaseMessage`, `RequestMessage extends BaseMessage`, `PushMessage extends BaseMessage`), all v2 `PluginMessage` and `ServerMessage` variants (each extending the appropriate base), `Capability`, `ErrorCode`, `StudioState`, `SerializedValue`, `DataModelInstance` types. `protocolVersion` belongs only in the wire envelope (not in base types). Extend `encodeMessage` to handle v2 types. Extend `decodePluginMessage` switch with new cases. Add `decodeServerMessage`. - -**Note on `decodeServerMessage` scope**: This function decodes messages that the *server sends* (welcome, execute, queryState, captureScreenshot, queryDataModel, queryLogs, subscribe, unsubscribe, shutdown, error). It is the counterpart to `decodePluginMessage` (which decodes messages the *plugin sends*). The function is used by test code and by the bridge client (Phase 1, Task 1.3c) to parse messages received from the bridge host. It is NOT used by the server itself (the server creates these messages, it does not parse them). - -**Dependencies**: None (first task). - -**Complexity**: M - -**Acceptance criteria**: -- All existing type exports (`HelloMessage`, `OutputMessage`, `ScriptCompleteMessage`, `WelcomeMessage`, `ExecuteMessage`, `ShutdownMessage`, `PluginMessage`, `ServerMessage`, `OutputLevel`, `encodeMessage`, `decodePluginMessage`) continue to exist with identical signatures. -- New types are exported: `RegisterMessage`, `StateResultMessage`, `ScreenshotResultMessage`, `DataModelResultMessage`, `LogsResultMessage`, `StateChangeMessage`, `HeartbeatMessage`, `SubscribeResultMessage`, `UnsubscribeResultMessage`, `PluginErrorMessage`, `QueryStateMessage`, `CaptureScreenshotMessage`, `QueryDataModelMessage`, `QueryLogsMessage`, `SubscribeMessage`, `UnsubscribeMessage`, `ServerErrorMessage`. -- `decodePluginMessage` returns typed objects for all v2 plugin messages, returns `null` for unknown types. -- `decodeServerMessage` returns typed objects for all v1 and v2 server messages. -- Existing protocol tests in `web-socket-protocol.test.ts` and `web-socket-protocol.smoke.test.ts` pass without modification. -- New unit tests cover every v2 message type encode/decode round-trip. - -**V2 message type hierarchy (inlined from tech-spec `01-protocol.md` section 8)**: - -The base message hierarchy uses three internal interfaces: - -```typescript -interface BaseMessage { type: string; sessionId: string; } -interface RequestMessage extends BaseMessage { requestId: string; } -interface PushMessage extends BaseMessage { /* no requestId */ } -``` - -`protocolVersion` is a wire envelope field present only on `hello`, `welcome`, and `register` during handshake -- it does NOT belong in the base message types. - -**Concrete v2 types to export:** - -*Plugin-to-Server:* -- `RegisterMessage extends PushMessage` -- `type: 'register'`, `protocolVersion: number`, payload: `{ pluginVersion, instanceId, context: SessionContext, placeName, placeId, gameId, placeFile?, state: StudioState, pid?, capabilities: Capability[] }` -- `StateResultMessage extends RequestMessage` -- `type: 'stateResult'`, payload: `{ state: StudioState, placeId, placeName, gameId }` -- `ScreenshotResultMessage extends RequestMessage` -- `type: 'screenshotResult'`, payload: `{ data: string, format: 'png', width, height }` -- `DataModelResultMessage extends RequestMessage` -- `type: 'dataModelResult'`, payload: `{ instance: DataModelInstance }` -- `LogsResultMessage extends RequestMessage` -- `type: 'logsResult'`, payload: `{ entries: Array<{ level, body, timestamp }>, total, bufferCapacity }` -- `StateChangeMessage extends PushMessage` -- `type: 'stateChange'`, payload: `{ previousState, newState, timestamp }` -- `HeartbeatMessage extends PushMessage` -- `type: 'heartbeat'`, payload: `{ uptimeMs, state, pendingRequests }` -- `SubscribeResultMessage extends RequestMessage` -- `type: 'subscribeResult'`, payload: `{ events: SubscribableEvent[] }` -- `UnsubscribeResultMessage extends RequestMessage` -- `type: 'unsubscribeResult'`, payload: `{ events: SubscribableEvent[] }` -- `PluginErrorMessage extends BaseMessage` -- `type: 'error'`, `requestId?: string`, payload: `{ code: ErrorCode, message, details? }` - -*Server-to-Plugin:* -- `QueryStateMessage extends RequestMessage` -- `type: 'queryState'`, payload: `{}` -- `CaptureScreenshotMessage extends RequestMessage` -- `type: 'captureScreenshot'`, payload: `{ format?: 'png' }` -- `QueryDataModelMessage extends RequestMessage` -- `type: 'queryDataModel'`, payload: `{ path, depth?, properties?, includeAttributes?, find?, listServices? }` -- `QueryLogsMessage extends RequestMessage` -- `type: 'queryLogs'`, payload: `{ count?, direction?, levels?, includeInternal? }` -- `SubscribeMessage extends RequestMessage` -- `type: 'subscribe'`, payload: `{ events: SubscribableEvent[] }` -- `UnsubscribeMessage extends RequestMessage` -- `type: 'unsubscribe'`, payload: `{ events: SubscribableEvent[] }` -- `ServerErrorMessage extends BaseMessage` -- `type: 'error'`, `requestId?: string`, payload: `{ code: ErrorCode, message, details? }` - -*Shared types:* -- `StudioState = 'Edit' | 'Play' | 'Paused' | 'Run' | 'Server' | 'Client'` -- `SessionContext = 'edit' | 'client' | 'server'` -- `SubscribableEvent = 'stateChange' | 'logPush'` -- `Capability = 'execute' | 'queryState' | 'captureScreenshot' | 'queryDataModel' | 'queryLogs' | 'subscribe' | 'heartbeat'` -- `ErrorCode` -- 12 string literal union (see `01-protocol.md` section 7.1) -- `SerializedValue` -- union of primitives and typed Roblox values -- `DataModelInstance` -- recursive structure with name, className, path, properties, attributes, childCount, children - -See `01-protocol.md` section 8 for the full TypeScript definitions. - -### Task 1.2: Request/response correlation layer - -**Description**: Build a `PendingRequestMap` utility that tracks in-flight requests by `requestId`, enforces timeouts, and resolves/rejects promises when responses arrive. This is a standalone utility with no dependency on the server or WebSocket. - -**Files to create**: -- `src/server/pending-request-map.ts` -- `PendingRequestMap` class with `addRequest(requestId, timeoutMs): Promise`, `resolveRequest(requestId, result)`, `rejectRequest(requestId, error)`, `cancelAll()`. -- `src/server/pending-request-map.test.ts` - -**Dependencies**: None. - -**Complexity**: S - -**Acceptance criteria**: -- `addRequest` returns a promise that resolves when `resolveRequest` is called with the same ID. -- `addRequest` returns a promise that rejects when `rejectRequest` is called with the same ID. -- If neither resolve nor reject is called within `timeoutMs`, the promise rejects with a timeout error. -- `cancelAll` rejects all pending promises. -- Calling `resolveRequest` for an unknown ID is a no-op (does not throw). -- Unit tests cover: happy path, timeout, cancel, duplicate ID, resolve after timeout (no-op). - -### Task 1.3a: Transport layer and bridge host - -**Description**: Create the low-level transport server and the bridge host that accepts plugin and client WebSocket connections. This is the networking foundation that all other bridge sub-tasks build on. Includes the HTTP health check endpoint and port binding with `SO_REUSEADDR`. - -**Files to create**: -- `src/bridge/internal/transport-server.ts` -- WebSocket server with path-based routing (`/plugin`, `/client`, `/health`). Binds to a configurable port (default 38741). Sets `reuseAddr: true` on the underlying `net.Server` to allow rapid port rebind after host death (avoids TIME_WAIT). Emits events for new connections by path. -- `src/bridge/internal/bridge-host.ts` -- Accepts plugin connections on `/plugin`, accepts client connections on `/client`. Manages connection lifecycle (connect, disconnect, error). Routes messages between clients and plugins. Exposes methods for listing connected plugins and clients. -- `src/bridge/internal/health-endpoint.ts` -- HTTP health check handler for `GET /health`. Returns `{ status, port, protocolVersion, serverVersion }`. Returns 404 for non-matching paths. -- `src/bridge/internal/bridge-host.test.ts` -- `src/bridge/internal/transport-server.test.ts` - -**Dependencies**: Task 1.1 (protocol types). - -**Complexity**: M - -**Agent-assignable**: yes (well-scoped networking code) - -**Acceptance criteria**: -- `TransportServer` binds to the configured port and accepts WebSocket connections on `/plugin` and `/client` paths. -- `TransportServer` sets `reuseAddr: true` on the underlying `net.Server`. -- `BridgeHost` accepts plugin connections and tracks them by session ID. -- `BridgeHost` accepts client connections and routes messages between clients and plugins. -- `GET /health` returns a JSON response with status, port, protocol version, and server version. Non-`/health` HTTP requests return 404. -- Port binding failure (`EADDRINUSE`) is reported cleanly (not swallowed). -- Unit tests use configurable port to avoid conflicts. - -### Task 1.3b: Session tracker and bridge session - -**Description**: Build the session tracking layer that manages the in-memory session map and the `BridgeSession` class that wraps action dispatch to a plugin. Sessions are uniquely identified by `(instanceId, context)` and grouped by `instanceId` to represent a single Studio instance. - -**Files to create**: -- `src/bridge/internal/session-tracker.ts` -- In-memory session map with `(instanceId, context)` grouping. Tracks session lifecycle: add, remove, list, get by ID, list instances (grouped by `instanceId`). Emits session lifecycle events (connect, disconnect, state-change). -- `src/bridge/bridge-session.ts` -- `BridgeSession` class: handle to a single Studio session with action methods (`execAsync`, `queryStateAsync`, `captureScreenshotAsync`, `queryLogsAsync`, `queryDataModelAsync`, `subscribeAsync`, `unsubscribeAsync`). Wraps action dispatch to the plugin via the bridge host. -- `src/bridge/types.ts` -- `SessionInfo`, `SessionContext`, `InstanceInfo`, `SessionOrigin` type definitions. These are the public types that consumers use to understand session metadata. -- `src/bridge/internal/session-tracker.test.ts` -- `src/bridge/bridge-session.test.ts` - -**Dependencies**: Task 1.3a (needs bridge host for plugin message routing). - -**Complexity**: M - -**Agent-assignable**: yes - -**Acceptance criteria**: -- `SessionTracker` maintains a map of sessions keyed by session ID. -- Sessions are uniquely identified by `(instanceId, context)`. Adding a session with the same `(instanceId, context)` replaces the previous one. -- `listInstances()` groups sessions by `instanceId` and returns `InstanceInfo` objects with the list of connected contexts. -- `SessionInfo` includes: `sessionId`, `instanceId`, `context` (`'edit'` | `'client'` | `'server'`), `origin` (`'user'` | `'managed'`), `placeId`, `gameId`, `placeName`, `placeFile`, `state`, `pluginVersion`, `capabilities`, `connectedAt`. -- `BridgeSession` action methods send typed protocol messages to the plugin and wait for correlated responses. -- Session lifecycle events fire on connect, disconnect, and state-change. -- A single Studio instance in Play mode contributes up to 3 sessions (edit, client, server contexts), all grouped by `instanceId`. -- `BridgeSession` methods reject with `SessionDisconnectedError` when the underlying transport disconnects. This is basic "connection lost" behavior, not full failover (host takeover and client promotion are in Phase 1b). - -### Task 1.3c: Bridge client and host protocol - -**Description**: Build the WebSocket client that connects to an existing bridge host, and the client-to-host envelope protocol that enables forwarding commands through the host. This allows multiple CLI processes to share the same bridge host. - -**Files to create**: -- `src/bridge/internal/bridge-client.ts` -- WebSocket client connecting to an existing host on port 38741 via `/client`. Sends command requests, receives results and session updates. Implements the same consumer-facing interface as the host path so `BridgeConnection` callers see no difference. -- `src/bridge/internal/host-protocol.ts` -- `HostEnvelope` and `HostResponse` message types for client-to-host forwarding. Message types: `listSessions`, `commandRequest`, `commandResponse`, `hostTransfer`, `hostReady`. -- `src/bridge/internal/transport-client.ts` -- Low-level WebSocket client with automatic reconnection (exponential backoff). Handles connection lifecycle, send/receive, and disconnect detection. -- `src/bridge/internal/bridge-client.test.ts` -- `src/bridge/internal/transport-client.test.ts` - -**Dependencies**: Task 1.3a (needs bridge host to connect to). - -**Complexity**: M - -**Agent-assignable**: yes - -**Acceptance criteria**: -- `BridgeClient` connects to an existing bridge host via WebSocket on `/client`. -- `BridgeClient` can list sessions by sending a `listSessions` envelope to the host. -- `BridgeClient` can send commands to sessions by sending `commandRequest` envelopes and receiving `commandResponse` envelopes. -- `TransportClient` implements automatic reconnection with exponential backoff. -- `HostEnvelope`/`HostResponse` types are well-defined for all client-to-host message types. -- Consumer code using `BridgeClient` cannot tell whether it is talking to the host directly or through the forwarding layer. - -### Task 1.3d: BridgeConnection and role detection (split into subtasks 1.3d1-1.3d5) - -> **ORCHESTRATOR INSTRUCTION**: Task 1.3d has been split into 5 subtasks to reduce the review checkpoint bottleneck. Subtasks 1.3d1-1.3d4 are agent-assignable and should be executed in sequence (each builds on the previous). Subtask 1.3d5 (barrel export and API surface review) is a review checkpoint that a review agent can verify against the tech spec checklist. Do NOT dispatch any tasks that depend on 1.3d (Wave 3.5 and later: Tasks 1.4, 1.7a, 1.7b, 1.10, 2.3, 4.1, 4.2, 4.3, 2.6, 6.5) until 1.3d5 is validated and merged. Other Wave 3 tasks that do NOT depend on 1.3d (0.5.4, 1.6, 1.9, 2.1) may continue executing in parallel while awaiting the review checkpoint. - -**Description**: Build the public API entry point (`BridgeConnection`) that transparently handles host vs. client role detection, and the barrel export for the bridge module. This is the integration task that wires together the transport, session tracker, bridge host, and bridge client into a single cohesive API. Split into 5 subtasks to allow agent execution and reduce the review bottleneck from "review entire BridgeConnection integration" to "review the export surface." - ---- - -#### Task 1.3d1: `BridgeConnection.connectAsync()` and role detection - -**Description**: Implement the core `BridgeConnection` class with `connectAsync(options?)` and `disconnectAsync()`, plus the environment detection module that determines host vs. client role. This is the foundational wiring that all other 1.3d subtasks build on. - -**Files to create**: -- `src/bridge/bridge-connection.ts` -- `BridgeConnection` class with `connectAsync(options?)`, `disconnectAsync()`, `role` getter, `isConnected` getter. Internally uses `BridgeHost` or `BridgeClient` based on role detection. Stores `BridgeConnectionOptions`, wires up the transport. Events: `error`. -- `src/bridge/internal/environment-detection.ts` -- Detect host vs client role. Algorithm: try to bind port -> host; port taken (`EADDRINUSE`) -> connect as client; stale (health check fails) -> retry bind after delay. -- `src/bridge/bridge-connection.test.ts` -- role detection tests -- `src/bridge/internal/environment-detection.test.ts` - -**Dependencies**: Tasks 1.3a, 1.3b, 1.3c. - -**Complexity**: M - -**Agent-assignable**: yes (well-scoped role detection and lifecycle wiring) - -**Acceptance criteria**: -- `BridgeConnection.connectAsync()` binds port 38741 if no host is running (becomes host), or connects as a client if a host already exists. -- Role detection algorithm: try bind -> host; `EADDRINUSE` -> connect as client; stale host (health check fails) -> retry bind after delay. -- Two concurrent `BridgeConnection.connectAsync()` calls on the same port: the first becomes host, the second becomes client. -- `BridgeConnection.role` returns `'host'` or `'client'`. -- `disconnectAsync()` as host triggers the hand-off protocol. As client, simply disconnects. -- Idle behavior: host started by `exec`/`run` exits after a 5-second grace period when no clients and no pending commands. `keepAlive: true` keeps the host alive indefinitely. -- `isConnected` reflects connection state accurately. -- Unit tests use configurable port to avoid conflicts. - -**Test specification**: -- **Test 1**: Start `BridgeConnection.connectAsync()` on an unused port. Verify `role === 'host'` and `isConnected === true`. -- **Test 2**: Start two `BridgeConnection.connectAsync()` calls concurrently on the same port. Verify first becomes host, second becomes client. -- **Test 3**: Start a connection, call `disconnectAsync()`. Verify `isConnected === false`. -- **Test 4**: Environment detection: mock port bind success -> returns `'host'`. Mock `EADDRINUSE` -> returns `'client'`. -- **Test 5**: Environment detection: mock `EADDRINUSE` then health check fails -> retry bind after delay -> returns `'host'` (stale host recovery). - ---- - -#### Task 1.3d2: `BridgeConnection.listSessions()` and `listInstances()` - -**Description**: Add session query methods to `BridgeConnection` that delegate to the session tracker (from Task 1.3b). As host, queries the local session tracker directly. As client, sends a `listSessions` envelope through the host (via Task 1.3c's bridge client). - -**Files to modify**: -- `src/bridge/bridge-connection.ts` -- add `listSessions(): SessionInfo[]` and `listInstances(): InstanceInfo[]` methods. - -**Dependencies**: Task 1.3d1. - -**Complexity**: S - -**Agent-assignable**: yes (delegation to existing session tracker) - -**Acceptance criteria**: -- `listSessions()` returns all currently connected plugins with full `SessionInfo` metadata. -- `listInstances()` groups sessions by `instanceId` and returns `InstanceInfo` objects. -- Works correctly in both host mode (direct session tracker query) and client mode (forwarded through host). - -**Test specification**: -- **Test 1**: Create a `BridgeConnection` (host mode), connect a mock plugin that sends `register`. Call `listSessions()`. Verify session appears in list with correct metadata. -- **Test 2**: Create a `BridgeConnection` (host mode), connect 3 mock plugins sharing `instanceId` with different contexts. Call `listInstances()`. Verify one instance with 3 contexts. -- **Test 3**: Create host + client connections. Connect a mock plugin to the host. Call `listSessions()` on the client. Verify session is visible through the client. - ---- - -#### Task 1.3d3: `BridgeConnection.resolveSession()` - -**Description**: Implement the instance-aware session resolution algorithm on `BridgeConnection`. This is the logic that CLI commands use to determine which session to target based on `--session`, `--instance`, and `--context` flags. - -**Files to modify**: -- `src/bridge/bridge-connection.ts` -- add `resolveSession(sessionId?, context?, instanceId?): Promise`. - -**Dependencies**: Task 1.3d2. - -**Complexity**: S - -**Agent-assignable**: yes (well-specified algorithm from tech-spec section 2.1) - -**Acceptance criteria**: -- `resolveSession()` implements the following instance-aware session resolution algorithm (from `07-bridge-network.md` section 6.7): - -``` -resolveSession(sessionId?, context?, instanceId?): - 1. If sessionId is provided: - -> Look up the session by ID. - -> If found, return it. If not found, throw SessionNotFoundError. - - 2. If instanceId is provided: - -> Look up the instance by instanceId. - -> If not found, throw SessionNotFoundError. - -> If found, apply context selection (step 5a-5c below) within that instance. - - 3. Collect unique instances from SessionTracker.listInstances(). - - 4. If 0 instances: - -> Wait up to timeoutMs for an instance to connect. - -> If timeout expires, throw ActionTimeoutError. - -> When an instance connects, continue to step 5. - - 5. If 1 instance: - a. If context is provided: - -> Look up that context's session within the instance. - -> If found, return it. - -> If not found (e.g., --context server but Studio is in Edit mode): - throw ContextNotFoundError { context, instanceId, availableContexts } - b. If instance has only 1 context (Edit mode): - -> Return the Edit session. - c. If instance has multiple contexts (Play mode): - -> Return the Edit context session (default). - - 6. If N instances (N > 1): - -> Throw SessionNotFoundError with the instance list, e.g.: - "Multiple Studio instances connected. Use --session or --instance ." - List each instance with instanceId, placeName, and connected contexts. -``` - - **Why Edit is the default in Play mode:** Most CLI operations (exec, query, run) target the Edit context because it represents the authoritative editing environment. Server and Client contexts are transient (destroyed when Play stops). Consumers who want Server or Client must explicitly pass `context: 'server'` or `context: 'client'`. - -- `getSession(id)` returns a `BridgeSession` or `undefined`. - -**Test specification**: -- **Test 1**: 0 sessions connected -> `resolveSession()` throws an error (or times out waiting). -- **Test 2**: 1 session connected, no args -> `resolveSession()` returns that session. -- **Test 3**: N sessions from different instances, no args -> `resolveSession()` throws with a list of instances for disambiguation. -- **Test 4**: Explicit `sessionId` -> returns that session. Unknown `sessionId` -> throws. -- **Test 5**: 1 instance with 3 contexts (edit, client, server), no context arg -> returns Edit context. -- **Test 6**: 1 instance with 3 contexts, `context: 'server'` -> returns the server context. -- **Test 7**: `instanceId` filter with `context` -> returns matching session. - ---- - -#### Task 1.3d4: `BridgeConnection.waitForSession()` - -**Description**: Implement the async wait method that resolves when at least one plugin session connects, or rejects on timeout. Used by `exec` and `run` commands to wait for Studio to connect after launch. - -**Files to modify**: -- `src/bridge/bridge-connection.ts` -- add `waitForSession(timeout?): Promise`. Wire session-connected events: `session-connected`, `session-disconnected`, `instance-connected`, `instance-disconnected`. - -**Dependencies**: Task 1.3d3. - -**Complexity**: S - -**Agent-assignable**: yes (event-driven promise resolution) - -**Acceptance criteria**: -- `waitForSession(timeoutMs)` resolves when at least one plugin connects, or rejects on timeout. -- If a session is already connected when `waitForSession` is called, resolves immediately. -- Session lifecycle events fire correctly: `session-connected`, `session-disconnected`, `instance-connected`, `instance-disconnected`. - -**Test specification**: -- **Test 1**: Call `waitForSession()` before any plugin connects. Connect a mock plugin. Verify the promise resolves with the session. -- **Test 2**: Connect a mock plugin first, then call `waitForSession()`. Verify it resolves immediately. -- **Test 3**: Call `waitForSession(500)` with no plugin. Verify it rejects after ~500ms with a timeout error. -- **Test 4**: Subscribe to `session-connected` event. Connect a mock plugin. Verify the event fires with the session. -- **Test 5**: Subscribe to `session-disconnected` event. Connect then disconnect a mock plugin. Verify the event fires with the session ID. - ---- - -#### Task 1.3d5: Barrel export and public API surface review -- REVIEW CHECKPOINT - -> **ORCHESTRATOR INSTRUCTION**: This is the only review checkpoint in the 1.3d subtask chain. After subtasks 1.3d1-1.3d4 are complete, the orchestrator dispatches this task to a review agent (or performs the checklist verification itself). Do NOT dispatch any tasks that depend on 1.3d (Wave 3.5 and later) until 1.3d5 is validated and merged. - -**Description**: Create the barrel export file for the bridge module and review the public API surface to ensure it matches the specification in `07-bridge-network.md` section 2.1. This is a lightweight review task (~30 minutes) rather than a multi-hour integration review. - -**Files to create**: -- `src/bridge/index.ts` -- Barrel export of public API only: `BridgeConnection`, `BridgeConnectionOptions`, `BridgeSession`, `SessionInfo`, `SessionContext`, `InstanceInfo`, `SessionOrigin`. Nothing from `src/bridge/internal/` is re-exported. - -**Dependencies**: Tasks 1.3d1, 1.3d2, 1.3d3, 1.3d4. - -**Complexity**: XS - -**Agent-assignable**: **yes** (review agent verifies that exports match tech spec `07-bridge-network.md` section 2.1 -- the checklist items are concrete and automatable) - -**Acceptance criteria**: -- Barrel export exposes only public types; nothing from `internal/` is re-exported. -- Exported types match `07-bridge-network.md` section 2.1 exactly: `BridgeConnection`, `BridgeConnectionOptions`, `BridgeSession`, `SessionInfo`, `SessionContext`, `InstanceInfo`, `SessionOrigin`. -- All public methods on `BridgeConnection` match the spec: `connectAsync`, `disconnectAsync`, `listSessions`, `listInstances`, `getSession`, `waitForSession`, `resolveSession`, `role`, `isConnected`, and the event interface. -- No internal types (`TransportServer`, `BridgeHost`, `BridgeClient`, `SessionTracker`, etc.) leak through the barrel export. - -**Review agent verifies**: -- [ ] `BridgeConnection` public API matches tech spec `07-bridge-network.md` section 2.1 signature exactly (every method, property, and event listed in the spec exists with the correct types) -- [ ] No `any` casts outside constructor boundaries (review `bridge-connection.ts`, `bridge-session.ts`, `types.ts` for unnecessary `as any`) -- [ ] All existing tests still pass (`cd tools/studio-bridge && npm run test`) -- [ ] New integration test covers connect -> execute -> disconnect lifecycle (verify in `bridge-connection.test.ts`) -- [ ] `StudioBridge` wrapper delegates without duplicating logic (no copy-pasted session resolution, action dispatch, or lifecycle management that already exists in `BridgeConnection`) - -### Task 1.4: Integrate BridgeConnection into StudioBridge class - -**Description**: Wrap `BridgeConnection` inside the existing `StudioBridge` export so that library consumers (e.g. `LocalJobContext` in nevermore-cli) see no API change. `StudioBridge.startAsync()` calls `BridgeConnection.connectAsync()` internally, `StudioBridge.executeAsync()` delegates to `BridgeSession.execAsync()`, and `StudioBridge.stopAsync()` calls `BridgeConnection.disconnectAsync()`. - -**Files to modify**: -- `src/index.ts` -- replace internal `StudioBridgeServer` usage with `BridgeConnection` and `BridgeSession`. Preserve the public `StudioBridge` class signature (`startAsync`, `executeAsync`, `stopAsync`). - -**Dependencies**: Task 1.3d5. - -**Complexity**: S - -**Acceptance criteria**: -- `new StudioBridge()` / `startAsync()` / `executeAsync()` / `stopAsync()` work identically from the caller's perspective. -- Internally, `startAsync` creates a `BridgeConnection` (with `keepAlive: true`) and waits for a session. -- `executeAsync` delegates to `BridgeSession.execAsync()` on the connected session. -- `stopAsync` calls `disconnectAsync` on the `BridgeConnection`. -- Existing `studio-bridge-server.test.ts` tests pass without modification. -- `index.ts` exports `BridgeConnection`, `BridgeSession`, `BridgeConnectionOptions`, `SessionInfo` from `src/bridge/`. - -### Task 1.5: v2 handshake support in StudioBridgeServer - -**Description**: Update the server's handshake handler to detect v2 plugins (via `protocolVersion` or `register` message), negotiate capabilities, and store the negotiated protocol version and capability set on the connection. Legacy v1 plugins continue to work unchanged. - -**Files to modify**: -- `src/server/studio-bridge-server.ts` -- update `_waitForHandshakeAsync` to handle `register` messages, extract capabilities, respond with appropriate `welcome` (v1 or v2 style). - -**Dependencies**: Task 1.1. - -**Complexity**: S - -**Acceptance criteria**: -- A v1 plugin sending `hello` without `protocolVersion` receives a v1-style `welcome` (no capabilities, no protocolVersion). -- A v2 plugin sending `hello` with `protocolVersion: 2` and `capabilities` receives a v2-style `welcome` with `protocolVersion: 2` and the negotiated `capabilities`. -- A v2 plugin sending `register` receives a v2-style `welcome`. -- The server stores the negotiated protocol version and capabilities on the connection for later use. -- If `pluginVersion` is present and older than the server's minimum-supported plugin version, the server logs a warning and includes `pluginUpdateAvailable: true` in the `welcome` payload. The handshake still completes (backward compatible). -- Heartbeat messages from the plugin are accepted and tracked (last heartbeat timestamp stored). - -### Task 1.6: Action dispatch on the server - -**Description**: Add a `performActionAsync` method to `StudioBridgeServer` that sends a typed request message to the plugin and waits for the correlated response. Uses `PendingRequestMap` internally. This is the server-side counterpart to the plugin's action handler. - -**Files to create or modify**: -- Create: `src/server/action-dispatcher.ts` -- orchestrates sending a request message and waiting for the matching response via `PendingRequestMap`. -- Modify: `src/server/studio-bridge-server.ts` -- add `performActionAsync(message: ServerMessage): Promise`, wire the message listener to route responses through the dispatcher. - -**Dependencies**: Tasks 1.1, 1.2, 1.5. - -**Complexity**: M - -**Acceptance criteria**: -- `performActionAsync` sends a v2 message with a generated `requestId` and returns a promise. -- The promise resolves when the plugin sends a matching response (same `requestId`). -- The promise rejects on timeout (per-message-type defaults from the protocol spec). -- The promise rejects with a structured error if the plugin sends an `error` message with the same `requestId`. -- If called when the negotiated protocol version is 1, `performActionAsync` throws immediately with a clear error ("Plugin does not support v2 actions"). -- If called with an action type not in the negotiated capabilities, throws with "Plugin does not support capability: X". -- Existing `executeAsync` continues to work unchanged (it uses the v1 path). - -### Task 1.7a: Shared CLI utilities - -**Description**: Create the shared CLI utility modules that all commands will use: instance-aware session resolution, output mode formatting, and the minimal handler type. These utilities are the foundation that Task 1.7b's reference command and all Phase 3 commands build on. - -**Files to create**: -- `src/cli/resolve-session.ts` -- Instance-aware session resolution with `--session`, `--instance`, `--context` flags. Implements the resolution algorithm: explicit ID lookup, auto-select single instance, context selection within an instance, error on multiple instances. -- `src/cli/format-output.ts` -- Output mode selection (table/JSON/text) using `@quenty/cli-output-helpers/output-modes`. -- `src/cli/types.ts` -- Minimal handler type: `type CommandHandler = (connection: BridgeConnection, options: Record) => Promise`. -- `src/cli/resolve-session.test.ts` - -**Dependencies**: Task 1.3d5 (needs `BridgeConnection` for session resolution), Phase 0 (output mode utilities). - -**Complexity**: S - -**Agent-assignable**: yes - -**Acceptance criteria**: -- `resolveSession` implements the full resolution algorithm: explicit ID lookup, auto-select single instance, context selection within an instance, error on multiple instances. -- `formatOutput` selects the correct output mode (table/JSON/text) based on CLI flags. -- `CommandHandler` type is exported and matches the pattern: `(connection, options) => Promise`. -- ~80 LOC total across the three files. -- Unit tests cover: resolve with 0, 1, N sessions; explicit ID; missing ID; context selection. - -### Task 1.7b: Reference `sessions` command + barrel export pattern - -**Description**: Implement the `sessions` command as the reference pattern that all future commands copy, and establish the barrel export pattern in `src/commands/index.ts` that eliminates per-command modifications to `cli.ts`. This is a merge-conflict mitigation measure: because 7+ tasks need to register commands, having each task modify `cli.ts` directly would cause merge conflicts when tasks run in parallel worktrees. Instead, `cli.ts` imports `allCommands` from the barrel file and registers them in a loop. Each subsequent task only adds an export line to the barrel file (append-only, auto-mergeable). - -**Files to create**: -- `src/commands/sessions.ts` -- The reference command handler. Calls `BridgeConnection.listSessions()` to get live session data. Formats the result as a table (summary) and structured JSON (data). -- `src/commands/index.ts` -- Barrel file that re-exports all command handlers and exposes an `allCommands` array. The `sessions` command is the first entry. All surfaces (CLI, terminal, MCP) import from this single barrel file. -- `src/cli/commands/sessions-command.ts` -- CLI wiring (yargs) for the sessions command. - -**Files to modify**: -- `src/cli/cli.ts` -- replace per-command `.command()` registration with a loop over `allCommands` from `src/commands/index.ts`. This is the LAST time `cli.ts` is modified for command registration. All future commands are registered by adding an export to the barrel file only. - -**Dependencies**: Task 1.7a. - -**Complexity**: S - -**Agent-assignable**: yes - -**Acceptance criteria**: -- The handler is defined in `src/commands/sessions.ts` and wired via `src/cli/commands/sessions-command.ts`. -- `src/commands/index.ts` exports `sessionsCommand` and an `allCommands` array containing it. -- `src/cli/cli.ts` registers commands via `for (const cmd of allCommands) { cli.command(createCliCommand(cmd)); }` -- it does NOT import individual command modules. -- Lists all sessions with columns: Session ID, Instance, Context, Place, State, Origin, Connected duration. -- `--json` flag outputs a JSON array. -- When no bridge host is running, prints: "No bridge host running. Start one with `studio-bridge terminal` or `studio-bridge exec`." -- When the host is running but no plugins are connected, prints: "No active sessions. Is Studio running with the studio-bridge plugin?" -- ~60 LOC total across handler and CLI wiring files (barrel file is additional). -- Establishes the concrete pattern that all future commands copy: create handler file in `src/commands/`, add export to `src/commands/index.ts`. No other files need to change when adding a command. - -### Parallelization within Phase 1 - -Tasks 1.1, 1.2, and 1.3a have no dependencies on each other and can be done in parallel. Task 1.3a (transport and host) should start early as the first step of the bridge module. Tasks 1.3b (sessions) and 1.3c (client) depend on 1.3a but are independent of each other and can run in parallel. Task 1.3d has been split into 5 subtasks: 1.3d1 (role detection) depends on 1.3a, 1.3b, and 1.3c; subtasks 1.3d2-1.3d4 are sequential (each builds on the previous); 1.3d5 (barrel export, review checkpoint) depends on 1.3d4. Tasks 1.4 and 1.5 both depend on earlier tasks but are independent of each other. Task 1.6 depends on 1.1, 1.2, and 1.5. Task 1.7a depends on 1.3d5 (for session resolution) and Phase 0 (for output mode utilities), but can proceed in parallel with 1.4, 1.5, and 1.6. Task 1.7b depends on 1.7a. - -Failover tasks (1.8, 1.9, 1.10) have been moved to Phase 1b (`01b-failover.md`). Phase 1b runs in parallel with Phases 2-3 and is NOT a gate for them. Basic `SessionDisconnectedError` handling (rejecting pending actions when the transport disconnects) is part of Phase 1 core (Task 1.3b). - -``` -Phase 0 (output modes, runs in parallel with Phase 1): -0.1-0.3 (table, json, watch) --> 0.4 (barrel) - | -Phase 1: | -1.1 (protocol v2) ----------+ | - +---> 1.5 (v2 handshake) --> 1.6 (action dispatch) -1.2 (pending requests) ------+ ^ - | -1.3a (transport + host) --+--> 1.3b (sessions) --+ | - | | | - +--> 1.3c (client) -----+ | - | | - 1.3d1 (role detection) -+ | - 1.3d2 (listSessions) ---+ | - 1.3d3 (resolveSession) -+ | - 1.3d4 (waitForSession) -+ | - 1.3d5 (barrel export) --+ [REVIEW] | - | | -1.3d5 --> 1.4 (StudioBridge wrapper) | | - --+ | | - +---> 1.7a (shared CLI utils) --> 1.7b (sessions) | -0.4 (barrel) --+ | - | -1.2 -------------------------------------------------------->+ -``` - ---- - -## Testing Strategy (Phase 1) - -**Unit tests** (run before proceeding to Phase 2): -- Protocol encode/decode for every v2 message type, including malformed input. -- `PendingRequestMap` timeout, resolve, reject, cancel. -- `TransportServer` port binding and WebSocket path routing (Task 1.3a). -- `BridgeHost` plugin and client connection management (Task 1.3a). -- `SessionTracker` session map with `(instanceId, context)` grouping (Task 1.3b). -- `BridgeSession` action dispatch and `SessionDisconnectedError` on transport disconnect (Task 1.3b). -- `BridgeClient` command forwarding through host (Task 1.3c). -- `TransportClient` reconnection with backoff (Task 1.3c). -- `BridgeConnection` host/client role detection (bind port = host, EADDRINUSE = client) (Task 1.3d1). -- Environment detection: host vs client role (Task 1.3d1). -- `BridgeConnection.listSessions()` and `listInstances()` delegation (Task 1.3d2). -- `BridgeConnection.resolveSession()` algorithm: 0, 1, N instances (Task 1.3d3). -- `BridgeConnection.waitForSession()` async wait and timeout (Task 1.3d4). -- Session resolution: 0, 1, N sessions; explicit ID; missing ID; context selection (Task 1.7a). -- Sessions command output formatting (Task 1.7b). - -**Integration tests**: -- Start a `BridgeConnection` (becomes host), simulate plugin connecting via `/plugin`, verify `listSessions()` returns the session. -- Start two `BridgeConnection` instances on the same port -- first becomes host, second becomes client (Task 1.3d1). -- Simulate a v2 plugin client connecting via WebSocket, performing handshake with capabilities. -- `SessionTracker` correctly groups multi-context sessions from the same `instanceId`. - -**Regression**: -- All existing tests in `web-socket-protocol.test.ts`, `web-socket-protocol.smoke.test.ts`, `studio-bridge-server.test.ts`, `plugin-injector.test.ts` pass unchanged. - -Note: Failover tests (graceful shutdown, crash recovery, inflight requests, TIME_WAIT, multi-client takeover) are in Phase 1b (`01b-failover.md`). - ---- - -## Failure Modes - -Default policy: **escalate integration issues to review agent, self-fix isolated issues.** - -| Task | Likely Failure | Recovery Action | -|------|---------------|-----------------| -| 1.1 (protocol v2 types) | New type definitions break existing `decodePluginMessage` for v1 messages | Self-fix: existing tests catch this. Fix the decode switch to preserve v1 behavior. | -| 1.1 (protocol v2 types) | `BaseMessage`/`RequestMessage` hierarchy conflicts with existing message shapes | Self-fix: adjust hierarchy to make existing types extend correctly. Do not break existing type exports. | -| 1.2 (pending request map) | Timer leaks in tests cause vitest to hang | Self-fix: ensure `afterEach` calls `cancelAll()`. Use `vi.useFakeTimers()` for timeout tests. | -| 1.3a (transport + host) | Port binding race conditions in tests | Self-fix: use ephemeral ports (`port: 0`) in all tests. | -| 1.3a (transport + host) | `SO_REUSEADDR` not supported on all platforms identically | Self-fix: wrap in try/catch, log warning if unsupported. The feature is critical for failover but not for basic operation. | -| 1.3b (session tracker) | `(instanceId, context)` key design does not match how plugins actually register | Escalate: this is a protocol contract issue. Review the register message spec with Phase 0.5/Phase 2 task owners. | -| 1.3c (bridge client) | Client-to-host envelope protocol has version mismatch with host | Self-fix: add version field to `HostEnvelope`, validate on receipt. | -| 1.3d1 (role detection) | Stale host detection (health check after EADDRINUSE) has timing issues | Self-fix: add configurable retry delay and max retries. Test with mock health endpoint. | -| 1.3d3 (resolveSession) | Resolution algorithm does not handle edge case of instance with only client+server contexts (no edit) | Self-fix: adjust default context selection to fall back to first available context if edit is not present. | -| 1.3d5 (barrel export) | Internal types leak through re-exports | Escalate: this is an API surface issue. Human must review the barrel file. | -| 1.4 (StudioBridge wrapper) | Existing `StudioBridge` consumers rely on internal behavior that changes with `BridgeConnection` wrapping | Self-fix if existing tests catch it. Escalate if the breakage is in downstream consumers (nevermore-cli) that are not tested here. | -| 1.5 (v2 handshake) | Capability negotiation produces empty intersection, breaking all actions | Self-fix: ensure server advertises all capabilities it supports. Log a warning if negotiated set is empty. | -| 1.6 (action dispatch) | `performActionAsync` timeout too short for some actions in slow Studio environments | Self-fix: make timeouts configurable per-call with generous defaults. | -| 1.7a (shared CLI utils) | `resolveSession` algorithm does not match the spec in `07-bridge-network.md` | Self-fix: write tests from the spec's resolution table first, then implement to pass. | -| 1.7b (sessions command) | Barrel export `allCommands` pattern does not work with yargs `CommandModule` type system | Escalate: this is a foundational pattern issue. If the barrel pattern is broken, all Phase 2-3 command tasks are blocked. Fix before proceeding. | diff --git a/studio-bridge/plans/execution/phases/01b-failover.md b/studio-bridge/plans/execution/phases/01b-failover.md deleted file mode 100644 index a70fec39cf..0000000000 --- a/studio-bridge/plans/execution/phases/01b-failover.md +++ /dev/null @@ -1,162 +0,0 @@ -# Phase 1b: Failover - -Goal: Make the bridge host resilient to process death via graceful hand-off and crash recovery. This phase is decoupled from the Phase 1 core gate -- it can run in parallel with Phases 2-3 since it only depends on Task 1.3a (transport server and bridge host). - -Note: Basic `SessionDisconnectedError` handling (pending actions reject when the WebSocket drops) is part of Phase 1 core (Task 1.3b acceptance criterion). Phase 1b builds the full failover protocol on top: host takeover, client promotion, plugin reconnection. - -References: -- Host failover: `studio-bridge/plans/tech-specs/08-host-failover.md` -- Bridge Network layer: `studio-bridge/plans/tech-specs/07-bridge-network.md` -- Protocol: `studio-bridge/plans/tech-specs/01-protocol.md` - -Base path for all file references: `/workspaces/NevermoreEngine/tools/studio-bridge/` - -Cross-references: -- Phase 1 core: `01-bridge-network.md` (Tasks 1.1-1.7b) -- Agent prompts: `studio-bridge/plans/execution/agent-prompts/01-bridge-network.md` (failover tasks) -- Validation: `studio-bridge/plans/execution/validation/01-bridge-network.md` (failover tests) - ---- - -### Task 1.8: Bridge host failover implementation - -**Description**: Extract and harden the host failover logic from the bridge module. This task is dedicated to making failover production-ready: graceful shutdown notification via SIGTERM/SIGINT handlers, client takeover after host death, plugin reconnection handling on the new host, `SO_REUSEADDR` (`reuseAddr: true`) on the server socket to avoid TIME_WAIT delays, and timeout handling for inflight requests during host death. This task implements the full protocol described in `08-host-failover.md`. - -Full spec: `studio-bridge/plans/tech-specs/08-host-failover.md` - -**Files to create or modify**: -- Create: `src/bridge/internal/hand-off.ts` -- implement `GracefulHandOff` (host sends `hostTransfer` to clients, waits for one to send `hostReady`, then shuts down) and `CrashRecoveryHandOff` (clients detect disconnect, apply random jitter 0-500ms, race to bind port). -- Modify: `src/bridge/internal/bridge-host.ts` -- register SIGTERM/SIGINT handlers that trigger graceful hand-off. On plugin reconnection after takeover, accept re-registrations and restore session state. -- Modify: `src/bridge/internal/bridge-client.ts` -- on host disconnect, enter takeover standby: wait for jitter, attempt to bind port. If bind succeeds, promote to host (create `BridgeHost`, send `hostReady` to remaining clients). If bind fails, reconnect as client to the new host. -- Modify: `src/bridge/bridge-session.ts` -- when the underlying transport disconnects during an inflight action, reject all pending requests with `SessionDisconnectedError` (not silent timeout). When the transport reconnects (new host), re-establish session handles. -- Create: `src/bridge/internal/hand-off.test.ts` -- unit tests for hand-off state machine transitions. - -**Dependencies**: Task 1.3a (needs transport server and bridge host infrastructure). - -**Complexity**: M - -**Acceptance criteria**: -- **Graceful shutdown**: when bridge host receives SIGTERM/SIGINT, it sends `hostTransfer` to all connected clients before closing the server socket. First client to bind port 38741 becomes the new host and sends `hostReady` to remaining clients. -- **Crash recovery**: when the bridge host dies without sending `hostTransfer` (kill -9, OOM), clients detect WebSocket disconnect within 2 seconds (via close/error event), wait random jitter (0-500ms), and race to bind port 38741. First to succeed becomes new host. -- **Plugin reconnection**: after host transfer, plugins detect WebSocket close, poll `/health` with exponential backoff (1s, 2s, 4s, 8s, max 30s), and reconnect to the new host. The new host accepts `register` messages from plugins and restores session tracking. A Studio instance in Play mode has 3 sessions (edit, client, server contexts) that each independently reconnect on their own schedule. (The edit instance was already connected before Play mode, but its connection was also severed by the host death.) -- **Multi-context recovery**: the new host correlates re-registrations by `(instanceId, context)`. Instance grouping is rebuilt progressively as each context reconnects. During recovery, `listSessions()` may return partially-populated instance groups. -- **`SO_REUSEADDR`**: server socket sets `reuseAddr: true` so that port 38741 can be rebound immediately after the previous host's socket enters TIME_WAIT. Port rebind succeeds within 1 second of host death on all platforms. -- **Inflight request handling**: any `BridgeSession` action that is in-flight when the host dies is rejected with `SessionDisconnectedError` (not left hanging until timeout). The consumer receives the rejection within 2 seconds of host death. -- **No clients connected**: when the host dies with no clients, the port is freed. Next CLI invocation binds the port and becomes the new host. Plugins reconnect via polling. -- **State machine correctness**: hand-off transitions are deterministic. A client cannot simultaneously be in "takeover standby" and "connected as client". The state machine has exactly three states: `connected`, `taking-over`, `promoted`. -- Unit tests for hand-off state machine transitions (graceful path, crash path, no-clients path) pass. - -### Task 1.9: Failover integration tests - -**Description**: Comprehensive integration tests for the bridge host failover protocol. These tests verify that the full failover flow works end-to-end with mock plugins and multiple bridge connections. They cover both graceful and crash failover, timing assertions, inflight request behavior, and plugin reconnection. This is the primary quality gate for the networking layer's resilience -- failover bugs will be painful to debug in production, so the test suite must be thorough. - -Full spec: `studio-bridge/plans/tech-specs/08-host-failover.md` - -**Files to create**: -- `src/bridge/internal/__tests__/failover-graceful.test.ts` -- tests for graceful host shutdown and client takeover. -- `src/bridge/internal/__tests__/failover-crash.test.ts` -- tests for unclean host death and crash recovery. -- `src/bridge/internal/__tests__/failover-plugin-reconnect.test.ts` -- tests for plugin reconnection to the new host after failover. -- `src/bridge/internal/__tests__/failover-inflight.test.ts` -- tests for inflight request behavior during failover. -- `src/bridge/internal/__tests__/failover-timing.test.ts` -- timing assertions for recovery bounds. - -**Dependencies**: Tasks 1.3d5, 1.8 (full bridge module and failover implementation must exist). - -**Complexity**: M - -**Acceptance criteria**: -- **Graceful shutdown test**: start bridge host + bridge client + mock plugin. Host calls `disconnectAsync()`. Verify: client receives `hostTransfer`, client rebinds port within 2 seconds, client sends `hostReady`, plugin reconnects to new host within 5 seconds, actions work through the new host. -- **Hard kill test**: start bridge host + bridge client + mock plugin. Kill the host (close transport server without sending `hostTransfer`). Verify: client detects disconnect, client becomes new host within 5 seconds, plugin reconnects, actions work. -- **Inflight request test**: start bridge host + bridge client + mock plugin. Client sends an action through the host. While the action is in-flight (mock plugin has not responded), kill the host. Verify: the inflight action rejects with `SessionDisconnectedError` (not `ActionTimeoutError`), and the rejection happens within 2 seconds of host death. -- **TIME_WAIT recovery test**: start bridge host on port X. Stop the host. Immediately start a new host on the same port X. Verify: port bind succeeds within 1 second (thanks to `SO_REUSEADDR`). No `EADDRINUSE` error. -- **Rapid restart test**: start bridge host + mock plugin. Kill the host. Within 3 seconds, start a new CLI command that needs the bridge. Verify: new CLI becomes host, plugin reconnects, command executes successfully -- all within 5 seconds of the original host's death. -- **No-clients test**: start bridge host + mock plugin (no clients). Stop the host. Start a new CLI. Verify: new CLI becomes host, plugin reconnects. -- **Multiple clients takeover**: start host + 3 clients + mock plugin. Kill host. Verify: exactly one client becomes host, other two reconnect as clients, plugin reconnects, no duplicate sessions. -- **Multi-context failover**: start host + 3 mock plugins sharing the same `instanceId` but with different `context` values (edit, client, server). Kill host. Client takes over. Verify: all 3 context sessions re-register independently, the new host groups them by `instanceId`, and `listSessions()` eventually returns 3 sessions for the instance. -- **Partial multi-context recovery**: same setup as above but one mock plugin (e.g., the client context) delays reconnection. Verify: the other 2 sessions are available immediately, and commands can target them by context while the third is still reconnecting. -- **Jitter prevents thundering herd**: start host + 5 clients. Kill host. Verify: bind attempts are spread over 0-500ms (measure timestamps of bind attempts). No more than one client succeeds in binding. -- All tests use ephemeral ports to avoid conflicts. -- All tests clean up connections in `afterEach`. - -### Task 1.10: Failover debugging and observability - -**Description**: Implement debugging affordances that make failover issues diagnosable. Failover is the single hardest thing to debug in this architecture because it involves multiple processes, timing races, and state transitions. Without clear observability, developers will waste hours on issues that should take minutes. - -**Files to create or modify**: -- Modify: `src/bridge/internal/hand-off.ts` -- add structured debug logging for every state transition: `[bridge:handoff] state=taking-over reason=host-disconnect jitter=342ms`, `[bridge:handoff] state=promoted port=38741 elapsed=487ms`. -- Modify: `src/bridge/internal/bridge-host.ts` -- log when clients connect/disconnect, when plugins connect/disconnect, when `hostTransfer` is sent, when host starts idle shutdown countdown. -- Modify: `src/bridge/internal/bridge-client.ts` -- log when host disconnect is detected, when takeover is attempted, when takeover succeeds/fails, when reconnecting as client to new host. -- Modify: `src/bridge/bridge-connection.ts` -- expose `role` transitions on the `BridgeConnection` instance. Emit events on role change: `'role-changed'` with `{ previousRole, newRole, reason }`. -- Modify: `src/commands/sessions.ts` -- when the bridge host is in the middle of a failover (no host available), print: "Bridge host is recovering. Retry in a few seconds." instead of "No bridge host running." -- Modify: `src/bridge/internal/health-endpoint.ts` -- add `hostUptime` and `lastFailoverAt` fields to the health response so diagnostics can detect recent failovers. - -**Dependencies**: Tasks 1.3d5, 1.8. - -**Complexity**: S - -**Acceptance criteria**: -- All hand-off state transitions produce structured log messages at `debug` level (not visible by default, visible with `--log-level debug` or `STUDIO_BRIDGE_LOG_LEVEL=debug`). -- Log messages include: timestamp, component (`bridge:handoff`, `bridge:host`, `bridge:client`), state transition, relevant context (port, session count, instance count, elapsed time, jitter value). During multi-context recovery, log messages distinguish between individual session reconnections and instance-group completeness. -- `studio-bridge sessions` during failover recovery prints a clear recovery message (not an opaque connection error). When instance groups are partially populated during recovery, the output indicates how many contexts have reconnected per instance (e.g., "2 of 3 contexts reconnected for instance abc-123"). -- Health endpoint includes `hostUptime` (ms since host started) and `lastFailoverAt` (ISO 8601 timestamp of last failover, `null` if none). -- `BridgeConnection.role` is updated when a client promotes to host (from `'client'` to `'host'`). -- Error messages for host-unavailable scenarios include actionable guidance: "Bridge host is not reachable. If you just restarted, wait a few seconds for failover to complete." - ---- - -### Parallelization within Phase 1b - -Task 1.8 depends only on Task 1.3a (transport and host). Tasks 1.9 and 1.10 depend on Task 1.8 and Task 1.3d5 (full bridge module). - -``` -Phase 1 core: 1.3a (transport + host) --> 1.8 (failover impl) --> 1.9 (failover tests) - | -Phase 1 core: 1.3d5 (BridgeConnection) -----+--------------------------+ - | - +--> 1.10 (failover observability) -``` - -Phase 1b can run entirely in parallel with Phases 2-3. It is NOT a gate for those phases. - ---- - -## Phase 1b Gate - -All failover unit tests pass. All failover integration tests pass (graceful, crash, inflight, TIME_WAIT, rapid restart, multi-client, multi-context). Observability logging is in place at debug level. - ---- - -## Testing Strategy (Phase 1b) - -**Unit tests** (Task 1.8): -- Hand-off state machine transitions: `connected` -> `taking-over` -> `promoted`, and `connected` -> `taking-over` -> `reconnected-as-client`. -- Graceful path: host sends `hostTransfer`, client receives it, client takes over. -- Crash path: host dies without `hostTransfer`, clients detect disconnect, apply jitter, race to bind. -- No-clients path: host dies, port freed, next CLI binds. - -**Integration tests** (Task 1.9): -- Graceful shutdown: host sends `hostTransfer`, client takes over within 2 seconds, plugin reconnects within 5 seconds. -- Hard kill: host dies without notification, client takes over within 5 seconds, plugin reconnects. -- Inflight request during host death: pending actions reject with `SessionDisconnectedError` within 2 seconds (not silent timeout). -- TIME_WAIT recovery: port rebind with `SO_REUSEADDR` succeeds within 1 second. -- Rapid restart: kill host + start new CLI within 3 seconds, command executes successfully. -- Multiple clients: exactly one becomes host, others reconnect as clients, no duplicate sessions. -- Multi-context failover: 3 sessions (edit/client/server) sharing an instanceId all re-register and are grouped correctly. -- Partial multi-context recovery: 2 of 3 sessions available while third reconnects. -- Jitter distribution: bind attempts spread over 0-500ms, preventing thundering herd. - ---- - -## Failure Modes - -Default policy: **escalate integration issues to review agent, self-fix isolated issues.** - -| Task | Likely Failure | Recovery Action | -|------|---------------|-----------------| -| 1.8 (failover impl) | Hand-off state machine has race condition where two clients both promote to host | Self-fix: add mutex/flag to ensure only one promotion attempt per client. Add a multi-client race test. | -| 1.8 (failover impl) | SIGTERM handler interferes with Node.js graceful shutdown, causing hang on exit | Self-fix: ensure handler calls `process.exit()` after hand-off completes or after a safety timeout (e.g., 5s). | -| 1.8 (failover impl) | Plugin reconnection to new host fails because the new host's session tracker does not accept re-registration | Escalate: this is a cross-component issue between the session tracker (1.3b) and the failover logic. The session tracker's `(instanceId, context)` replacement behavior must be verified with the failover flow. | -| 1.8 (failover impl) | `SO_REUSEADDR` does not prevent TIME_WAIT on Windows | Escalate: this is a platform-specific issue. Document the limitation and consider `SO_REUSEPORT` or a port-check retry loop as a workaround. | -| 1.9 (failover tests) | Integration tests are flaky due to timing sensitivity | Self-fix: use generous timeouts in assertions (e.g., "within 5 seconds" not "within 100ms"). Use event-driven waits rather than fixed delays. | -| 1.9 (failover tests) | Tests leave orphaned processes or bound ports, breaking subsequent test runs | Self-fix: use ephemeral ports, add `afterEach` cleanup that force-closes all connections and kills child processes. | -| 1.10 (observability) | Debug logging causes performance regression when enabled | Self-fix: ensure all debug logs are gated behind a level check. Do not construct log message strings unless the level is active. | -| 1.10 (observability) | Health endpoint `lastFailoverAt` field leaks internal timing information | Self-fix: this is intentional for diagnostics. Document that the health endpoint is local-only (not exposed to the internet). | diff --git a/studio-bridge/plans/execution/phases/02-plugin.md b/studio-bridge/plans/execution/phases/02-plugin.md deleted file mode 100644 index c9f8194a71..0000000000 --- a/studio-bridge/plans/execution/phases/02-plugin.md +++ /dev/null @@ -1,313 +0,0 @@ -# Phase 2: Persistent Plugin - -Goal: Ship the permanent Luau plugin, the `install-plugin` CLI command, and the discovery mechanism so that a user who installs the plugin can connect to a running Studio without re-launching it. - -References: -- Persistent plugin: `studio-bridge/plans/tech-specs/03-persistent-plugin.md` -- Action specs: `studio-bridge/plans/tech-specs/04-action-specs.md` - -Base path for all file references: `/workspaces/NevermoreEngine/tools/studio-bridge/` - -Cross-references: -- Agent prompts: `studio-bridge/plans/execution/agent-prompts/02-plugin.md` -- Validation: `studio-bridge/plans/execution/validation/02-plugin.md` -- Depends on Phase 0.5 (Layer 1 plugin modules) -- see `00.5-plugin-modules.md` -- Depends on Phase 1 core (especially Tasks 1.3, 1.6, 1.7a) -- see `01-bridge-network.md` - ---- - -### Task 2.1: Unified plugin -- Layer 2 glue (upgrade existing template) -- REVIEW CHECKPOINT (requires Studio validation) - -**Description**: Wire the Layer 1 pure Luau modules (built in Phase 0.5) to Roblox services via a thin glue layer (~100-150 LOC). Phase 0.5 already provides: `Protocol.luau` (message encoding/decoding), `DiscoveryStateMachine.luau` (connection lifecycle), `ActionRouter.luau` (action dispatch), and `MessageBuffer.luau` (log buffering). This task writes only the Roblox-specific entry point and service bindings. - -Build constants are injected via a two-step pipeline: Handlebars template substitution (in TemplateHelper) replaces placeholders like `{{PORT}}`, `{{SESSION_ID}}`, and `{{IS_EPHEMERAL}}` in the Lua source, then Rojo builds the substituted sources into an `.rbxm` plugin file. The entry point detects whether build-time constants have been substituted: if yes, it connects directly (ephemeral mode); if no, it enters the discovery state machine (persistent mode). - -**Files to create or modify**: -- Modify: `templates/studio-bridge-plugin/src/StudioBridgePlugin.server.lua` -- thin entry point (~100-150 LOC) that reads build constants, instantiates Layer 1 modules, and wires them to Roblox services (HttpService for HTTP polling, WebSocket for connections, RunService for state detection, LogService/CaptureService for action handlers). Boot mode detection: if build constants are substituted, connect directly (ephemeral); otherwise, call `DiscoveryStateMachine.start()` (persistent). -- Create: `templates/studio-bridge-plugin/src/Actions/` -- thin Roblox-specific action handler implementations that register with `ActionRouter`. Each handler calls Roblox APIs and returns response messages. -- Modify: `templates/studio-bridge-plugin/default.project.json` -- update Rojo project to include Layer 1 modules from `src/Shared/` and the new action handlers. - -**Dependencies**: Phase 0.5 (Layer 1 modules), Task 1.1 (v2 message format). - -**Complexity**: M - -**Review agent verifies** (code quality and structure). **Requires Studio validation** for runtime behavior: -- [ ] Plugin enters `connected` state in Studio when bridge host is running (verify `[StudioBridge] Connected` appears in Studio output log) -- [ ] Plugin stays in `searching` state when no bridge host is running (no error spam in output log, only periodic `[StudioBridge] Searching...` messages) -- [ ] All Phase 0.5 modules are imported and wired (Protocol, DiscoveryStateMachine, ActionRouter, MessageBuffer all referenced in entry point with correct callback injection) -- [ ] Heartbeat loop runs independently from script execution (start a long `exec`, verify heartbeat messages continue arriving at the server every 15s) -- [ ] Edit plugin survives Play/Stop mode transitions (enter Play mode, stop Play mode, verify edit session remains connected and functional via `studio-bridge sessions`) - -**Wiring sequence** (numbered steps for connecting Phase 0.5 Layer 1 modules to Roblox services): -1. Import `Protocol` module from `src/Shared/Protocol.luau` (Phase 0.5) -2. Import `DiscoveryStateMachine` from `src/Shared/DiscoveryStateMachine.luau` (Phase 0.5) -3. Import `ActionRouter` from `src/Shared/ActionRouter.luau` (Phase 0.5) -4. Import `MessageBuffer` from `src/Shared/MessageBuffer.luau` (Phase 0.5) -5. Read build constants (`{{PORT}}`, `{{SESSION_ID}}`, `{{IS_EPHEMERAL}}`). Detect ephemeral vs persistent mode using the following explicit check: - -```lua --- Build constants are Handlebars templates before substitution -local IS_EPHEMERAL = (PORT ~= "{{PORT}}") -if IS_EPHEMERAL then - -- Connect directly using substituted build constants -else - -- Enter discovery state machine (persistent mode) -end -``` - -If `IS_EPHEMERAL` is true (build constants were substituted by Handlebars), the plugin connects directly to the known port. If false (build constants are still literal template strings), the plugin enters the discovery state machine. - -6. In plugin init, create `DiscoveryStateMachine` with injected callbacks: - - `onHttpPoll = function(url) return HttpService:GetAsync(url) end` - - `onWebSocketConnect = function(url) return HttpService:CreateWebStreamClient(url) end` - - `onStateChange = function(old, new) -- log transition end` -7. On discovery success (or immediate connect in ephemeral mode), create WebSocket connection. -8. Wire `WebSocket.OnMessage` -> `Protocol.decode()` -> `ActionRouter:dispatch()` for incoming messages. -9. Wire `ActionRouter` responses through `Protocol.encode()` -> `WebSocket:Send()` for outgoing messages. -10. Start heartbeat coroutine: `task.spawn(function() while stateMachine:isConnected() do ... task.wait(15) end end)` using the pattern from `studio-bridge/plans/tech-specs/03-persistent-plugin.md` section 6.3. Do NOT use `task.cancel`. -11. Wire `LogService.MessageOut:Connect()` -> `MessageBuffer:push()` for log buffering. -12. Wire `RunService` state detection: check `RunService:IsRunMode()`, `RunService:IsStudio()`, `RunService:IsRunning()` to determine context (`edit`, `client`, `server`). -13. Send `register` message with all capabilities and session identity fields. - -**Acceptance criteria**: -- Plugin loads in Studio without errors when no server is running (goes to idle/searching, does not spam warnings). -- Plugin discovers a running server via HTTP health check and connects via WebSocket. -- Plugin sends `register` message with all capabilities (`execute`, `queryState`, `captureScreenshot`, `queryDataModel`, `queryLogs`, `subscribe`, `heartbeat`) plus session identity fields: `instanceId`, `context` (`'edit'` | `'client'` | `'server'`), `placeId`, and `gameId`. -- Plugin detects its context at startup by checking the DataModel environment: `edit` for the Edit-mode plugin instance, `client` for the LocalPlayer-side instance in Play mode, `server` for the server-side instance in Play mode. In Play mode, Studio has 3 concurrent plugin instances: the edit instance (which was already running and connected) plus the 2 new server and client instances, each connecting as a separate session. -- Plugin falls back to `hello` if `register` gets no response within 3 seconds (compatible with v1 servers). -- Plugin sends heartbeat every 15 seconds. The heartbeat loop runs as a `task.spawn` coroutine that checks a `connected` flag each iteration and exits cleanly on disconnect (do not use `task.cancel` -- it can leave partial WebSocket frames). See `studio-bridge/plans/tech-specs/03-persistent-plugin.md` section 6.3. -- Plugin reconnects automatically when the WebSocket drops, with exponential backoff (1s, 2s, 4s, 8s, max 30s). (Reconnection logic is in Layer 1's `DiscoveryStateMachine`; this task wires the injected callbacks.) -- Plugin detects state transitions (Edit/Play/Run/Paused) via RunService and sends `stateChange` push messages if subscribed. -- Plugin handles `shutdown` message by disconnecting cleanly (in persistent mode: returns to searching; in ephemeral mode: exits). -- In ephemeral mode (build-time constants present), the plugin connects directly to the hardcoded port and session ID, behaving identically to the old temporary plugin. -- The entry point is ~100-150 LOC of Roblox glue; all protocol logic, state machine logic, action routing, and message buffering are in Layer 1 modules from Phase 0.5. -- **Rojo build validation**: `rojo build templates/studio-bridge-plugin/default.project.json -o dist/studio-bridge-plugin.rbxm` succeeds and output file `dist/studio-bridge-plugin.rbxm` exists and is > 1KB. Agents should run this build command after every change to plugin source files. -- **Lune test plan**: Rojo build succeeds. Module structure matches `default.project.json` tree. All Luau modules required by the entry point are resolvable within the Rojo project. - -### Task 2.2: Execute action handler in plugin - -**Description**: Implement the execute action in the persistent plugin. This is a refactored version of the existing temporary plugin's execute logic, but integrated with the action dispatch table and supporting `requestId` correlation. - -**Files to modify**: -- `templates/studio-bridge-plugin/src/Actions/ExecuteAction.lua` - -**Dependencies**: Task 2.1. - -**Complexity**: S - -**Acceptance criteria**: -- Handles `execute` messages with or without `requestId`. -- Sends `output` messages during execution (batched, same as current plugin). -- Sends `scriptComplete` with matching `requestId` when present. -- Queues concurrent `execute` requests and processes them sequentially. -- `loadstring` failures return `scriptComplete` with `success: false`. -- **Lune test plan**: Test file: `test/execute-handler.test.luau`. Required test cases: script execution returns success/error result, requestId echoed in response, timeout behavior returns error. - -### Task 2.3: Health endpoint on bridge host - -**Description**: The `/health` HTTP endpoint is served by the bridge host's WebSocket server on port 38741 (already created in Task 1.3 as part of `bridge-host.ts`). This task ensures the endpoint returns the correct JSON shape and is used by the persistent plugin for discovery. - -**Files to modify**: -- `src/bridge/bridge-host.ts` -- ensure the HTTP handler for `GET /health` on port 38741 returns host status and all connected session metadata. - -**Dependencies**: Task 1.3. - -**Complexity**: S - -**Acceptance criteria**: -- `GET http://localhost:38741/health` returns `200 OK` with JSON body: `{ status, port, protocolVersion, serverVersion, sessions: SessionInfo[] }`. -- The `sessions` array lists all currently connected plugins with their metadata. -- Non-matching HTTP requests (not `/health`, `/plugin`, or `/client`) return `404`. -- The health endpoint is available immediately after `BridgeConnection.connectAsync()` resolves (when the process is the host). - -### Task 2.4: Universal plugin management module + installer commands - -**Description**: Build the universal `PluginManager` subsystem in `src/plugins/` and implement `studio-bridge install-plugin` / `studio-bridge uninstall-plugin` as commands that delegate to it. The plugin manager is a general-purpose utility -- not specific to studio-bridge. It operates on `PluginTemplate` descriptors and never hard-codes paths, filenames, or build constants for any specific plugin. studio-bridge registers its own template; future tools register theirs. See `03-persistent-plugin.md` section 2 for the full API design. - -**Files to create**: -- `src/plugins/plugin-manager.ts` -- `PluginManager` class: `registerTemplate()`, `buildAsync()`, `installAsync()`, `uninstallAsync()`, `isInstalledAsync()`, `listInstalledAsync()`, `discoverPluginsDirAsync()`. -- `src/plugins/plugin-template.ts` -- `PluginTemplate` interface definition and validation. -- `src/plugins/plugin-discovery.ts` -- `discoverPluginsDirAsync()` platform-specific Studio plugins folder detection (extracted from `findPluginsFolder()` in `studio-process-manager.ts`). -- `src/plugins/types.ts` -- `InstalledPlugin`, `BuiltPlugin`, `BuildOverrides` types. -- `src/plugins/index.ts` -- barrel export for the plugin management subsystem. -- `src/commands/install-plugin.ts` -- `install-plugin` command handler delegating to `PluginManager`. -- `src/commands/uninstall-plugin.ts` -- `uninstall-plugin` command handler delegating to `PluginManager`. - -**Files to modify**: -- `src/commands/index.ts` -- add `installPluginCommand` and `uninstallPluginCommand` exports to the barrel file and `allCommands` array. Do NOT modify `cli.ts` -- it already loops over `allCommands` (established in Task 1.7b). -- `src/plugin/plugin-injector.ts` -- refactor to delegate to `PluginManager.isInstalledAsync()` and `PluginManager.buildAsync()` with overrides for ephemeral builds. - -**Dependencies**: Task 2.1 (needs the template to exist), Task 1.7b (barrel pattern must be established). - -**Complexity**: M - -**Acceptance criteria**: -- **PluginManager API is generic enough that a second plugin template could be added without modifying the manager.** This is the key design constraint. The manager operates on `PluginTemplate` values and never references studio-bridge by name in its implementation. -- `PluginManager.registerTemplate(template)` accepts any valid `PluginTemplate` and stores it in the registry. -- `PluginManager.buildAsync(template)` builds any registered template via Rojo and returns a `BuiltPlugin` with the .rbxm path and hash. -- `PluginManager.buildAsync(template, { constants: { PORT: '49201', SESSION_ID: 'abc' } })` produces an ephemeral build with overridden constants. -- `PluginManager.installAsync(built)` copies the .rbxm to the Studio plugins folder and writes a per-plugin version tracking sidecar. -- `PluginManager.isInstalledAsync('studio-bridge')` returns `true` when the studio-bridge plugin sidecar exists. -- `PluginManager.uninstallAsync('studio-bridge')` removes the .rbxm and sidecar. -- `PluginManager.listInstalledAsync()` returns metadata for all installed plugins (across all registered templates). -- `PluginManager.discoverPluginsDirAsync()` correctly resolves the Studio plugins folder on macOS and Windows. -- `studio-bridge install-plugin` builds the persistent plugin and writes it to the Studio plugins folder via `PluginManager`. -- `src/commands/index.ts` exports `installPluginCommand` and `uninstallPluginCommand` and includes them in `allCommands`. -- Running `install-plugin` again updates the existing plugin (hash comparison, overwrite if changed). -- `studio-bridge uninstall-plugin` removes the plugin file via `PluginManager`. -- Both commands print clear success/failure messages with the file path. -- Unit tests verify PluginManager generality with a concrete second-template test: - -```typescript -describe('PluginManager generality', () => { - it('registers and builds a second template without code changes', async () => { - const manager = new PluginManager(); - manager.registerTemplate(studioBridgeTemplate); - manager.registerTemplate({ - name: 'test-plugin', - templateDir: path.join(__dirname, 'fixtures/test-plugin-template'), - buildConstants: { TEST_VALUE: 'hello' }, - outputFilename: 'test-plugin.rbxm', - version: '1.0.0', - }); - const built = await manager.buildAsync('test-plugin'); - expect(built.filePath).toContain('test-plugin.rbxm'); - const installed = await manager.installAsync('test-plugin'); - expect(installed.name).toBe('test-plugin'); - const list = await manager.listInstalledAsync(); - expect(list).toHaveLength(2); - }); -}); -``` - - The test fixture `fixtures/test-plugin-template/` must contain a minimal `default.project.json` and a single `.lua` file sufficient for Rojo to produce a valid `.rbxm`. - -### Task 2.5: Persistent plugin detection and fallback - -**Description**: The bridge host always accepts plugin connections on `/plugin`. When a persistent plugin is installed, it will discover the host via the `/health` endpoint and connect automatically. If no persistent plugin connects within a timeout window, fall back to temporary plugin injection + Studio launch for backward compatibility and CI environments. Plugin detection uses the universal `PluginManager.isInstalledAsync()` API; ephemeral fallback uses `PluginManager.buildAsync()` with constant overrides. - -**Files to modify**: -- `src/bridge/bridge-connection.ts` -- after becoming host (or connecting as client), wait for a plugin to connect. If none connects within a configurable grace period and `pluginManager.isInstalledAsync('studio-bridge')` returns `false`, trigger the legacy temporary injection path. -- `src/plugin/plugin-injector.ts` -- refactored in Task 2.4 to use `PluginManager.buildAsync(template, { constants: { PORT, SESSION_ID } })` for ephemeral builds. - -**Dependencies**: Tasks 2.3, 2.4. - -**Complexity**: S - -**Acceptance criteria**: -- When persistent plugin is installed (per `PluginManager.isInstalledAsync()`): the bridge host waits for the plugin to discover it and connect via `/plugin`. No temporary injection occurs. -- When persistent plugin is NOT installed: after a brief grace period (e.g., 3 seconds), falls back to temporary plugin injection + Studio launch (current behavior). The ephemeral build is produced via `PluginManager.buildAsync(template, { constants: { PORT: String(port), SESSION_ID: sessionId } })`. -- A `BridgeConnectionOptions` field `preferPersistentPlugin?: boolean` (default: `true`). Setting it to `false` forces temporary injection even if the persistent plugin is installed (useful for CI). -- Timeout behavior is unchanged: if no plugin connects within `timeoutMs`, the connection attempt rejects. - -### Task 2.6: Refactor exec/run to handler pattern + session selection + launch command - -**Description**: Refactor `exec` and `run` into the single-handler pattern and add session selection support. This task has three parts: - -1. **Extract exec/run handlers**: Create `src/commands/exec.ts` and `src/commands/run.ts` as `CommandDefinition` handlers that extract the core logic from the existing `exec-command.ts` and `run-command.ts`. The existing yargs command files become thin wrappers that call `createCliCommand` on these handlers. Do NOT leave exec and run as separate implementations outside the handler pattern -- they must use the same `CommandDefinition` / `resolveSessionAsync` / adapter infrastructure as all other commands. - -2. **Session selection via resolveSession**: All session resolution uses the shared `resolveSession` utility from Task 1.7a. The `--session` / `-s` global flag feeds into this utility. No per-command session resolution logic. - -3. **Launch command**: Create `src/commands/launch.ts` as a `CommandDefinition` handler that explicitly launches a new Studio session. - -**Files to create**: -- `src/commands/exec.ts` -- `CommandDefinition>` handler. Extracts core execution logic from `exec-command.ts` / `script-executor.ts`. -- `src/commands/run.ts` -- `CommandDefinition>` handler. Reads file, delegates to exec handler logic. -- `src/commands/launch.ts` -- `CommandDefinition>` handler. - -**Files to modify**: -- `src/cli/args/global-args.ts` -- add `session?: string` and `context?: SessionContext` to `StudioBridgeGlobalArgs`. -- `src/commands/index.ts` -- add `execCommand`, `runCommand`, and `launchCommand` exports to the barrel file and `allCommands` array. Do NOT add per-command `.command()` calls to `cli.ts` -- it already loops over `allCommands` (established in Task 1.7b). -- `src/cli/cli.ts` -- add `--session` / `-s` and `--context` / `-c` global options only. Do NOT add per-command registrations; the `allCommands` loop handles that. -- `src/cli/commands/exec-command.ts` -- replace with thin wrapper calling `createCliCommand(execCommand)`, or delete if redundant. -- `src/cli/commands/run-command.ts` -- same. -- `src/cli/commands/terminal/terminal-mode.ts` -- support attaching to an existing session via `BridgeSession`; register exec handler with terminal adapter for the implicit REPL execution path. - -**Dependencies**: Tasks 1.3, 1.4, 1.7a. - -**Complexity**: M - -**Acceptance criteria**: -- The exec handler is defined once in `src/commands/exec.ts` and registered with both the CLI and terminal adapters. The MCP tool (Phase 5) will also use this same handler. -- The run handler is defined once in `src/commands/run.ts` and registered with the CLI adapter. The terminal `.run` dot-command uses the same handler. -- Session resolution in exec, run, and terminal all delegates to `resolveSessionAsync` -- no per-command resolution logic. -- `studio-bridge exec --session abc-123 'print("hi")'` connects to the bridge, resolves session `abc-123` via `resolveSessionAsync`, and executes. -- `studio-bridge exec --context server 'print("hi")'` targets the server context of the resolved instance. When a Studio instance is in Play mode, `--context` selects which of the 3 contexts to execute against. Defaults to `server` for Play mode (most useful for gameplay testing) or `edit` for Edit mode. -- `studio-bridge exec 'print("hi")'` with exactly one active instance auto-selects it (and picks the default context). -- `studio-bridge exec 'print("hi")'` with zero sessions falls back to launching Studio (current behavior). -- `studio-bridge exec 'print("hi")'` with multiple instances and no `--session` flag prints the list and errors. -- `studio-bridge terminal --session abc-123` enters REPL attached to the existing session. -- When connecting to an existing session, the session's origin is `user` (not `managed`). When launching a new Studio, the session's origin is `managed`. -- When connected to a session the CLI did not launch, `disconnectAsync` does not kill Studio. -- `studio-bridge launch ./MyGame.rbxl` explicitly launches a new Studio session and prints the session info. - -### Parallelization within Phase 2 - -``` -Phase 0.5 (Layer 1 modules) --+ - +--> 2.1 (Layer 2 glue) --> 2.2 (execute action) --> 2.5 (detection + fallback) -Phase 1: 1.1 (protocol v2) ---+ --> 2.4 (plugin manager) --> 2.5 - ^ -2.3 (health endpoint) -- needs 1.3 ---------------------------------------------------+ - -2.6 (exec/run refactor + session selection) -- needs 1.3 + 1.4 + 1.7a -``` - -Task 2.1 depends on Phase 0.5 (Layer 1 modules) and Task 1.1 (v2 message format). Tasks 2.3 and 2.6 can start as soon as their Phase 1 dependencies are met. Task 2.6 depends on Task 1.7a (shared CLI utilities). Task 2.4 (PluginManager module) should start as soon as Task 2.1 is ready. - -Note: The `sessions` command (previously Task 2.6) has been moved to Phase 1 as Task 1.7b, where it serves as the reference command pattern. - ---- - -## Phase 2 Gate - -All unit tests pass. Plugin template builds successfully via Rojo. PluginManager API works for both persistent and ephemeral builds. Detection/fallback logic selects the correct path. Exec/run commands use session resolution. - -Note: Manual Studio verification is deferred to Phase 6 (integration). Phase 2 gate is automated tests only. - -**Phase 2 gate reviewer checklist**: -- [ ] `rojo build templates/studio-bridge-plugin/default.project.json -o dist/studio-bridge-plugin.rbxm` succeeds and output is > 1KB -- [ ] `cd tools/studio-bridge && npm run test` passes with zero failures (all Phase 1 + Phase 2 tests) -- [ ] `studio-bridge install-plugin` writes the `.rbxm` to the correct platform-specific plugins folder (verify path in output) -- [ ] `studio-bridge exec 'print("hello")'` with one active session auto-selects it and returns output -- [ ] PluginManager generality test passes: second template registers, builds, and installs without PluginManager code changes - ---- - -## Testing Strategy (Phase 2) - -**Unit tests**: -- `PluginManager.registerTemplate()` stores templates and `getTemplate()` retrieves them by name. -- `PluginManager.buildAsync()` produces a .rbxm with the correct build constants for persistent mode. -- `PluginManager.buildAsync()` with `BuildOverrides` produces a .rbxm with overridden constants for ephemeral mode. -- `PluginManager.installAsync()` writes the .rbxm to the correct path and creates a version tracking sidecar. -- `PluginManager.isInstalledAsync()` detects plugin presence via sidecar file. -- `PluginManager.uninstallAsync()` removes both the .rbxm and the sidecar. -- `PluginManager.listInstalledAsync()` returns metadata for all installed plugins across all registered templates. -- **Generality test**: Register a second `PluginTemplate` using the `fixtures/test-plugin-template/` test fixture (minimal `default.project.json` + single `.lua` file). Verify that `buildAsync`, `installAsync`, `isInstalledAsync`, `listInstalledAsync`, and `uninstallAsync` all work correctly for both templates without any PluginManager code changes. See the concrete test specification in Task 2.4 acceptance criteria. -- `install-plugin` command delegates to `PluginManager` and writes to correct path. -- Health endpoint returns correct JSON with connected sessions. -- Session selection logic via `resolveSession` (auto-select single session, error for multiple, error for none). - -Note: Manual Studio testing (plugin loads, discovers server, reconnects) is deferred to Phase 6. - ---- - -## Failure Modes - -Default policy: **escalate integration issues to review agent, self-fix isolated issues.** - -| Task | Likely Failure | Recovery Action | -|------|---------------|-----------------| -| 2.1 (plugin glue) | Layer 1 module API does not match expected callback signatures in Layer 2 glue | Escalate: this is a cross-phase interface issue between Phase 0.5 and Phase 2. Review Layer 1 API with Phase 0.5 task owner before adapting. | -| 2.1 (plugin glue) | `HttpService:CreateWebStreamClient` is unavailable or behaves differently than expected in current Studio build | Self-fix: wrap in `pcall`, log error, fall back to retry. Document the Studio build version requirement. | -| 2.1 (plugin glue) | Rojo build fails due to incorrect `default.project.json` tree structure | Self-fix: run `rojo build` after every change. Fix the project file to match the actual directory structure. | -| 2.1 (plugin glue) | Context detection (`edit`/`client`/`server`) is wrong in Play mode | Escalate: context detection logic requires real Studio testing. Document the detection algorithm and verify manually. | -| 2.2 (execute action) | `loadstring` is disabled in the plugin security context | Escalate: this is a platform constraint. If `loadstring` is unavailable, the entire execute capability is blocked. Investigate alternative approaches (e.g., `require` with dynamic modules). | -| 2.2 (execute action) | Concurrent execute requests cause state corruption | Self-fix: ensure the sequential queue implementation is correct. Add a test that sends 3 concurrent executes and verifies they complete in order. | -| 2.3 (health endpoint) | HTTP handler conflicts with WebSocket upgrade handler on the same port | Self-fix: ensure the `noServer: true` WebSocket pattern is implemented correctly. The HTTP server handles requests, the upgrade event routes to WebSocket. | -| 2.4 (plugin manager) | `findPluginsFolder()` returns wrong path on a platform | Self-fix if the path logic is a simple bug. Escalate if the platform is unsupported (e.g., Linux/Wine). | -| 2.4 (plugin manager) | Rojo is not installed or not found in PATH | Self-fix: check for rojo before build and provide a clear error message with installation instructions. | -| 2.5 (detection + fallback) | Race condition between persistent plugin connection and fallback timeout | Escalate: timing-sensitive integration between plugin discovery and fallback. Requires manual testing with real Studio to verify the grace period is sufficient. | -| 2.6 (exec/run refactor) | Refactored exec/run breaks existing `studio-bridge exec` behavior | Self-fix: existing tests catch regressions. Ensure all existing exec-command tests pass after refactoring. | -| 2.6 (exec/run refactor) | Session resolution in exec does not work with the `resolveSessionAsync` utility | Self-fix: verify that `resolveSessionAsync` returns a `BridgeSession` that has `execAsync`. If the types do not match, adapt the exec handler's session usage. | diff --git a/studio-bridge/plans/execution/phases/03-commands.md b/studio-bridge/plans/execution/phases/03-commands.md deleted file mode 100644 index 92752ab5ec..0000000000 --- a/studio-bridge/plans/execution/phases/03-commands.md +++ /dev/null @@ -1,360 +0,0 @@ -# Phase 3: New Actions - -Goal: Implement the four new plugin capabilities (state, screenshot, logs, DataModel query) end-to-end -- from plugin Luau handler to server dispatch to CLI command. - -References: -- Command system: `studio-bridge/plans/tech-specs/02-command-system.md` -- Action specs: `studio-bridge/plans/tech-specs/04-action-specs.md` - -Base path for all file references: `/workspaces/NevermoreEngine/tools/studio-bridge/` - -Cross-references: -- Agent prompts: `studio-bridge/plans/execution/agent-prompts/03-commands.md` -- Validation: `studio-bridge/plans/execution/validation/03-commands.md` -- Depends on Tasks 1.6, 1.7, 2.1 -- see `01-bridge-network.md` and `02-plugin.md` - ---- - -### Task 3.1: State query action - -**Plugin side**: -- Create: `templates/studio-bridge-plugin/src/Actions/StateAction.lua` -- reads `RunService` state, place info from `DataModel`, returns `stateResult`. - -**Server side**: -- Create: `src/server/actions/query-state.ts` -- typed wrapper around `performActionAsync` for `queryState`. - -**Command handler** (single-handler pattern): -- Create: `src/commands/state.ts` -- ONE `CommandDefinition>` handler. Calls `session.queryStateAsync()`, formats result. The CLI command is generated from this handler via `createCliCommand(stateCommand)`. The terminal `.state` dot-command is registered from the same handler via the terminal adapter. The MCP `studio_state` tool (Phase 5) will also use this same handler. Do NOT create a separate `src/cli/commands/state-command.ts`. -- Modify: `src/commands/index.ts` -- add `stateCommand` export and add it to the `allCommands` array. Do NOT modify `cli.ts` -- it already loops over `allCommands` (established in Task 1.7b). - -**Dependencies**: Tasks 1.6, 1.7, 2.1. - -**Complexity**: S - -**Acceptance criteria**: -- The handler is defined once in `src/commands/state.ts` and registered with both the CLI and terminal adapters. -- `src/commands/index.ts` exports `stateCommand` and includes it in `allCommands`. -- `studio-bridge state` prints: Place, PlaceId, GameId, Mode, Context. -- `--context ` targets a specific context within a Studio instance. When a Studio instance is in Play mode and `--session` resolves to an instance with multiple contexts, `--context` selects which one to query. Defaults to `edit` if not specified and multiple contexts exist. -- `--json` outputs structured JSON (handled by the CLI adapter's standard `--json` support). -- `--watch` subscribes to `stateChange` events via the WebSocket push subscription protocol (`subscribe { events: ['stateChange'] }`) and prints updates as `stateChange` push messages arrive from the plugin through the bridge host. On Ctrl+C, sends `unsubscribe { events: ['stateChange'] }`. See `01-protocol.md` section 5.2 and `07-bridge-network.md` section 5.3. -- Timeout after 5 seconds with clear error. -- **Lune test plan**: Test file: `test/state-action.test.luau`. Required test cases: StudioState values are correct strings (e.g. `"Edit"`, `"Play"`, `"Run"`, `"Paused"`), `--watch` sends subscribe message with `stateChange` event, requestId is echoed in response. - -### Task 3.2: Screenshot capture action - -**Plugin side**: -- Create: `templates/studio-bridge-plugin/src/Actions/ScreenshotAction.lua` -- uses `CaptureService:CaptureScreenshot(callback)` (confirmed working in Studio plugins). The callback receives a `contentId` string, which is loaded into an `EditableImage` via `AssetService:CreateEditableImageAsync(contentId)`. Pixel bytes are read from the `EditableImage` (e.g., `ReadPixels`), then base64-encoded. Dimensions come from `editableImage.Size`. Returns `screenshotResult`. Note: implementer should verify exact `EditableImage` method names against the Roblox API at implementation time. - -**Server side**: -- Create: `src/server/actions/capture-screenshot.ts` -- typed wrapper, writes base64 data to temp PNG file. - -**Command handler** (single-handler pattern): -- Create: `src/commands/screenshot.ts` -- ONE `CommandDefinition>` handler. Calls `session.captureScreenshotAsync()`, handles `--output`/`--base64`/`--open` logic. The CLI command is generated from this handler via `createCliCommand(screenshotCommand)`. The terminal `.screenshot` dot-command is registered from the same handler via the terminal adapter. The MCP `studio_screenshot` tool (Phase 5) will also use this same handler. Do NOT create a separate `src/cli/commands/screenshot-command.ts`. -- Modify: `src/commands/index.ts` -- add `screenshotCommand` export and add it to the `allCommands` array. Do NOT modify `cli.ts` -- it already loops over `allCommands` (established in Task 1.7b). - -**Dependencies**: Tasks 1.6, 1.7, 2.1. - -**Complexity**: M - -**Acceptance criteria**: -- The handler is defined once in `src/commands/screenshot.ts` and registered with both the CLI and terminal adapters. -- `studio-bridge screenshot` writes a PNG to a temp directory and prints the path. -- `--context ` targets a specific context for the screenshot (e.g., `--context client` captures the client viewport during Play mode). -- `--output /path/to/file.png` writes to the specified path. -- `--base64` prints raw base64 to stdout. -- `--open` opens the file in the default viewer (using `open` on macOS, `xdg-open` on Linux). -- Timeout after 15 seconds with clear error. -- Error message if CaptureService call fails at runtime (e.g., Studio minimized, rendering error). -- **Lune test plan**: Test file: `test/screenshot-action.test.luau`. Required test cases: returns base64 data with dimensions, error on CaptureService failure returns protocol error message, requestId is echoed in response. - -### Task 3.3: Log query action - -**Plugin side**: -- Create: `templates/studio-bridge-plugin/src/Actions/LogAction.lua` -- maintains a ring buffer (capacity: 1000) of `{ level, body, timestamp }` entries. Responds to `queryLogs` by slicing the buffer per the `count`/`offset`/`levels` params. Supports continuous `logPush` push via the WebSocket push subscription protocol (when the server subscribes to `logPush` events, the plugin pushes individual `logPush` messages for each new LogService entry). -- Modify: `templates/studio-bridge-plugin/src/StudioBridgePlugin.server.lua` -- integrate the ring buffer with the LogService connection (entries go into both the buffer and the real-time batch). - -**Server side**: -- Create: `src/server/actions/query-logs.ts` -- typed wrapper. - -**Command handler** (single-handler pattern): -- Create: `src/commands/logs.ts` -- ONE `CommandDefinition>` handler. Calls `session.queryLogsAsync()`, handles `--tail`/`--head`/`--follow`/`--level`/`--all` logic. The CLI command is generated from this handler via `createCliCommand(logsCommand)`. The terminal `.logs` dot-command is registered from the same handler via the terminal adapter. The MCP `studio_logs` tool (Phase 5) will also use this same handler. Do NOT create a separate `src/cli/commands/logs-command.ts`. -- Modify: `src/commands/index.ts` -- add `logsCommand` export and add it to the `allCommands` array. Do NOT modify `cli.ts` -- it already loops over `allCommands` (established in Task 1.7b). - -**Dependencies**: Tasks 1.6, 1.7, 2.1. - -**Complexity**: M - -**Acceptance criteria**: -- The handler is defined once in `src/commands/logs.ts` and registered with both the CLI and terminal adapters. -- `studio-bridge logs` prints the last 50 log lines (default `--tail 50`). -- `--context ` targets a specific context's log buffer. Defaults to `edit` context (read-only command; see the context default table in `tech-specs/04-action-specs.md`). Use `--context server` to query server-side gameplay logs during Play mode. -- `--tail 100` prints the last 100. -- `--head 20` prints the first 20 since plugin connected. -- `--follow` streams new lines in real time via the WebSocket push subscription protocol (`subscribe { events: ['logPush'] }`). The plugin pushes individual `logPush` messages for each new LogService entry, and the bridge host forwards them to subscribed clients. On Ctrl+C, sends `unsubscribe { events: ['logPush'] }`. Note: `logPush` is distinct from `output` (which is batched and scoped to a single `execute` request). See `01-protocol.md` section 5.2 and `07-bridge-network.md` section 5.3. -- `--level Error,Warning` filters to only those levels. -- `--all` includes `[StudioBridge]` internal messages (filtered by default). -- `--json` outputs each line as `{ timestamp, level, body }` (handled by the CLI adapter's standard `--json` support). -- Ring buffer handles more than 1000 entries by evicting the oldest. -- **Lune test plan**: Test file: `test/log-action.test.luau`. Required test cases: returns entries array with correct shape, `--follow` sends subscribe message with `logPush` event, level filter works (filters entries by OutputLevel), ring buffer respects count limit and evicts oldest entries, requestId is echoed in response. - -### Task 3.4: DataModel query action - -**Plugin side**: -- Create: `templates/studio-bridge-plugin/src/Actions/DataModelAction.lua` -- resolves dot-separated path from `game` (split on `.`, walk `FindFirstChild` from `game`), reads properties/attributes, serializes Roblox types to the `SerializedValue` format, traverses children up to `depth`. -- Create: `templates/studio-bridge-plugin/src/ValueSerializer.lua` -- reusable Luau module for converting Roblox values (Vector3, CFrame, Color3, UDim2, UDim, etc.) to JSON-compatible tables with `type` discriminant and flat `value` arrays. Primitives (string, number, boolean) pass through as bare values. See `04-action-specs.md` section 6 for the full SerializedValue format. - -**Server side**: -- Create: `src/server/actions/query-datamodel.ts` -- typed wrapper. - -**Command handler** (single-handler pattern): -- Create: `src/commands/query.ts` -- ONE `CommandDefinition>` handler. Calls `session.queryDataModelAsync()`, handles expression-to-path translation and `--children`/`--descendants`/`--properties`/`--attributes`/`--depth` logic. The CLI command is generated from this handler via `createCliCommand(queryCommand)`. The terminal `.query` dot-command is registered from the same handler via the terminal adapter. The MCP `studio_query` tool (Phase 5) will also use this same handler. Do NOT create a separate `src/cli/commands/query-command.ts`. -- Modify: `src/commands/index.ts` -- add `queryCommand` export and add it to the `allCommands` array. Do NOT modify `cli.ts` -- it already loops over `allCommands` (established in Task 1.7b). - -**Dependencies**: Tasks 1.6, 1.7, 2.1. - -**Complexity**: L - -**Acceptance criteria**: -- The handler is defined once in `src/commands/query.ts` and registered with both the CLI and terminal adapters. -- `studio-bridge query Workspace.SpawnLocation` returns JSON with name, className, path, properties, childCount. -- `--context ` targets a specific context's DataModel. This is important because the server and client DataModels differ during Play mode (server has ServerStorage/ServerScriptService; client has LocalPlayer, PlayerGui). -- `studio-bridge query Workspace --children` lists immediate children with name and className. -- `studio-bridge query Workspace --descendants --depth 2` traverses 2 levels deep. -- `--properties Position,Anchored,Size` returns only those properties. -- `--attributes` includes all attributes. -- Properties with Roblox types (Vector3, CFrame, Color3, UDim2, UDim, etc.) serialize correctly with `type` discriminant and flat `value` arrays (e.g., `{ "type": "Vector3", "value": [1, 2, 3] }`). -- Path `game.Workspace.NonExistent` returns a clear error: "No instance found at path: game.Workspace.NonExistent". -- Timeout after 10 seconds. -- **Lune test plan**: Test file: `test/datamodel-action.test.luau`. Required test cases: dot-path resolution walks FindFirstChild correctly, SerializedValue format is correct for each type (Vector3 as `{ type, value: [x,y,z] }`, CFrame as flat 12-element array, Color3, UDim2, UDim, EnumItem, Instance ref, primitives as bare values), error cases return protocol error messages for invalid paths, requestId is echoed in response. - -### Task 3.5: Wire terminal adapter registry into terminal-mode.ts - -**Description**: Wire the terminal adapter registry (from Task 1.7) into the terminal REPL so that all command handlers registered via `createDotCommandHandler` are available as dot-commands. This task does NOT create new dot-command handlers -- those already exist from tasks 2.6, 2.7, 3.1-3.4 as `CommandDefinition`s in `src/commands/`. This task replaces the hard-coded dot-command dispatch in `terminal-editor.ts` with the adapter-based registry, adds the `connect` and `disconnect` commands, and updates `.help`. - -**Files to create**: -- `src/commands/connect.ts` -- `CommandDefinition` handler for switching sessions within terminal mode. -- `src/commands/disconnect.ts` -- `CommandDefinition` handler for disconnecting without killing Studio. - -**Files to modify**: -- `src/cli/commands/terminal/terminal-mode.ts` -- import all command definitions from `src/commands/index.ts`, create the dot-command dispatcher via `createDotCommandHandler([sessionsCommand, stateCommand, screenshotCommand, logsCommand, queryCommand, execCommand, runCommand, connectCommand, disconnectCommand])`, and wire it into the input handler. -- `src/cli/commands/terminal/terminal-editor.ts` -- replace the hard-coded if/else dot-command chain (lines 342-403) with the adapter registry. Keep `.help`, `.exit`, `.clear` as built-in commands. The `.help` output is auto-generated from the registered command definitions. - -**Dependencies**: Tasks 1.7, 2.6, 2.7, 3.1, 3.2, 3.3, 3.4. - -**Complexity**: S - -**Wiring sequence** (numbered steps for connecting the terminal adapter registry to terminal-mode.ts): -1. Import all command definitions from `src/commands/index.ts` (the barrel file: `sessionsCommand`, `stateCommand`, `screenshotCommand`, `logsCommand`, `queryCommand`, `execCommand`, `runCommand`). -2. Create `connectCommand` in `src/commands/connect.ts` -- handler calls `connection.resolveSession(sessionId)` and stores the result as the active session in terminal state. -3. Create `disconnectCommand` in `src/commands/disconnect.ts` -- handler clears the active session reference without killing Studio (for persistent sessions). -4. Import `connectCommand` and `disconnectCommand` into `terminal-mode.ts`. -5. Build the dot-command dispatcher: `const dotCommands = createDotCommandHandler([sessionsCommand, stateCommand, screenshotCommand, logsCommand, queryCommand, execCommand, runCommand, connectCommand, disconnectCommand])`. -6. In `terminal-editor.ts`, replace the hard-coded if/else dot-command chain (lines 342-403) with: `if (input.startsWith('.')) { const result = await dotCommands.dispatch(input, connection, activeSession); if (result) { formatOutput(result, terminalOutputStream); } }`. -7. Keep `.help`, `.exit`, `.clear` as built-in commands handled before the adapter dispatch. -8. Auto-generate `.help` output from the registered command definitions: `dotCommands.listCommands().map(cmd => \`.${cmd.name}\` + ' ' + cmd.description)`. -9. Wire the implicit REPL execution path: when input does NOT start with `.`, delegate to the `execCommand` handler with the current `activeSession`. -10. Ensure all dot-command output goes through `formatOutput()` from `src/cli/format-output.ts` for consistent formatting. - -**Concrete output specs for each dot-command**: - -``` -Input: .state -Expected output (connected, Edit mode): - Mode: Edit - Place: MyGame - PlaceId: 12345 - GameId: 67890 - -Input: .sessions -Expected output (two sessions): - ID Context Place State Connected - abc-123 edit MyGame (12345) ready 2m ago - def-456 server MyGame (12345) ready 1m ago - -Input: .screenshot -Expected output: - Screenshot saved to /tmp/studio-bridge/screenshot-2026-02-23-1430.png - -Input: .logs -Expected output (default --tail 50): - [14:30:01] [Print] Hello from server - [14:30:02] [Warning] Something suspicious - [14:30:03] [Error] Script error at line 5 - (50 entries, 342 total in buffer) - -Input: .query Workspace.SpawnLocation -Expected output: - Name: SpawnLocation - ClassName: SpawnLocation - Path: game.Workspace.SpawnLocation - Properties: - Position: { type: "Vector3", value: [0, 5, 0] } - Anchored: true - Size: { type: "Vector3", value: [4, 1.2, 4] } - Children: 0 - -Input: .connect abc-123 -Expected output: - Connected to session abc-123 (edit, MyGame) - -Input: .disconnect -Expected output: - Disconnected from session abc-123 - -Input: .help -Expected output: - .state Query the current Studio state - .sessions List active sessions - .screenshot Capture a screenshot - .logs Retrieve output logs - .query Query the DataModel - .connect Switch to a different session - .disconnect Disconnect from current session - .clear Clear the terminal - .exit Exit terminal mode -``` - -**Acceptance criteria**: -- `.state` prints the current session state (dispatched to the handler from `src/commands/state.ts`). -- `.screenshot` captures and prints the file path (dispatched to handler from `src/commands/screenshot.ts`). -- `.logs` prints recent logs (dispatched to handler from `src/commands/logs.ts`). -- `.query ` queries the DataModel (dispatched to handler from `src/commands/query.ts`). -- `.sessions` lists all sessions (dispatched to handler from `src/commands/sessions.ts`). -- `.connect ` switches to a different session. -- `.disconnect` disconnects without killing Studio (when connected to a persistent session). -- `.help` lists all commands including the new ones (auto-generated from definitions). -- No command handler logic exists in `terminal-mode.ts` or `terminal-editor.ts` -- all dispatch goes through the adapter. -- **E2e test spec**: Spawn the terminal as a subprocess, send stdin commands, assert stdout patterns. Test file: `src/test/e2e/terminal-dot-commands.test.ts`. Required test cases: - -```typescript -describe('terminal dot-commands e2e', () => { - // Setup: start a bridge host with a mock plugin connected, - // then spawn `studio-bridge terminal --session ` as a subprocess. - - it('.state prints studio state', async () => { - await sendStdin('.state\n'); - const output = await readStdoutUntil('Mode:'); - expect(output).toContain('Mode:'); - expect(output).toMatch(/Mode:\s+(Edit|Play|Run|Paused)/); - expect(output).toContain('Place:'); - expect(output).toContain('PlaceId:'); - }); - - it('.sessions prints session table', async () => { - await sendStdin('.sessions\n'); - const output = await readStdoutUntil('session(s) connected'); - expect(output).toContain('ID'); - expect(output).toContain('Context'); - expect(output).toContain('Place'); - }); - - it('.screenshot prints saved path', async () => { - await sendStdin('.screenshot\n'); - const output = await readStdoutUntil('.png'); - expect(output).toMatch(/Screenshot saved to .+\.png/); - }); - - it('.logs prints log entries', async () => { - await sendStdin('.logs\n'); - const output = await readStdoutUntil('total in buffer'); - expect(output).toMatch(/\[Print\]|\[Warning\]|\[Error\]/); - expect(output).toContain('total in buffer'); - }); - - it('.query prints DataModel node', async () => { - await sendStdin('.query Workspace\n'); - const output = await readStdoutUntil('ClassName:'); - expect(output).toContain('Name:'); - expect(output).toContain('ClassName:'); - expect(output).toContain('Workspace'); - }); - - it('.connect switches session', async () => { - await sendStdin('.connect def-456\n'); - const output = await readStdoutUntil('Connected to'); - expect(output).toContain('Connected to session def-456'); - }); - - it('.disconnect disconnects from session', async () => { - await sendStdin('.disconnect\n'); - const output = await readStdoutUntil('Disconnected'); - expect(output).toContain('Disconnected'); - }); - - it('.help lists all commands', async () => { - await sendStdin('.help\n'); - const output = await readStdoutUntil('.exit'); - expect(output).toContain('.state'); - expect(output).toContain('.sessions'); - expect(output).toContain('.screenshot'); - expect(output).toContain('.logs'); - expect(output).toContain('.query'); - expect(output).toContain('.connect'); - expect(output).toContain('.disconnect'); - }); - - it('unknown dot-command prints error', async () => { - await sendStdin('.notacommand\n'); - const output = await readStdoutUntil('Unknown'); - expect(output).toContain('Unknown command'); - }); -}); -``` - -### Phase 3 Gate -- REVIEW CHECKPOINT - -**Phase 3 gate reviewer checklist**: -- [ ] All four commands (`state`, `screenshot`, `logs`, `query`) are defined once in `src/commands/` and registered via `src/commands/index.ts` barrel -- no per-command `cli.ts` modifications exist -- [ ] `studio-bridge state --json` returns valid JSON with Place, PlaceId, GameId, Mode, Context fields (verify with mock plugin test) -- [ ] `studio-bridge logs --follow` subscribes to `logPush` events via WebSocket push protocol and streams output (verify subscribe/unsubscribe messages in mock plugin test) -- [ ] `studio-bridge query Workspace.NonExistent` returns a clear error message "No instance found at path: game.Workspace.NonExistent" (not a stack trace or unhandled rejection) -- [ ] `cd tools/studio-bridge && npm run test` passes with zero failures (all Phase 1 + 2 + 3 tests) - -### Parallelization within Phase 3 - -Tasks 3.1, 3.2, 3.3, and 3.4 are independent of each other -- they each implement a self-contained action end-to-end (plugin handler + server action + command handler in `src/commands/`). All four can proceed in parallel. All four depend on Task 1.7 (command handler infrastructure) for the `CommandDefinition` types and adapters. Task 3.5 depends on all four being complete and is now a smaller wiring task. - -``` -1.7 (command handler infra) --> 3.1 (state) --------+ - --> 3.2 (screenshot) ----+ - --> 3.3 (logs) ----------+--> 3.5 (wire terminal adapter) - --> 3.4 (query) ---------+ -``` - ---- - -## Testing Strategy (Phase 3) - -**Per-action unit tests** (mock WebSocket plugin): -- State query returns valid `StudioState`. -- Screenshot action returns base64 data, CLI writes to file. -- Log query respects `count`, `direction`, `levels` params. -- DataModel query resolves paths correctly, serializes types. -- DataModel query returns `INSTANCE_NOT_FOUND` for invalid paths. - -**Integration tests** (mock plugin client): -- Full lifecycle: connect, query state, execute script, query logs, capture screenshot, query DataModel, disconnect. -- Concurrent requests: send state query and log query simultaneously, verify both resolve. -- Subscription: subscribe to `stateChange`, trigger a state change, verify push message arrives. - ---- - -## Failure Modes - -Default policy: **escalate integration issues to review agent, self-fix isolated issues.** - -| Task | Likely Failure | Recovery Action | -|------|---------------|-----------------| -| 3.1 (state query) | `RunService` state detection returns unexpected values in some Studio modes (e.g., team test) | Self-fix: add the unexpected state to the `StudioState` enum and handle it gracefully. | -| 3.1 (state query) | `--watch` subscription never receives `stateChange` events because the push protocol is not wired | Self-fix: if subscribe is not available yet, print "watch not yet supported" and exit cleanly. Wire when push protocol is ready. | -| 3.2 (screenshot) | `CaptureService:CaptureScreenshot` callback never fires (Studio is minimized or viewport is not rendering) | Self-fix: add a timeout (15s) and return `SCREENSHOT_FAILED` with a descriptive error. | -| 3.2 (screenshot) | `EditableImage` API has different method names than expected (Roblox API changes) | Escalate: check Roblox API documentation at implementation time. If the API has changed, update the action handler. | -| 3.2 (screenshot) | Base64-encoded image data is too large for a single WebSocket frame | Self-fix: verify WebSocket frame size limits (16MB configured). If exceeded, compress or chunk. | -| 3.3 (log query) | Ring buffer ordering is wrong after wrap-around | Self-fix: add unit tests for wrap-around scenarios (buffer full, push N more, verify oldest are evicted and order is correct). | -| 3.3 (log query) | `--follow` mode leaks memory because log entries accumulate without limit on the server side | Self-fix: `--follow` streams individual `logPush` messages to stdout and does not buffer them. Ensure no accumulation on the server. | -| 3.4 (DataModel query) | Instance names containing dots break path resolution (known limitation) | Self-fix: document the limitation. Do not attempt to fix with escaping in this phase. | -| 3.4 (DataModel query) | Some Roblox property types are not serializable (e.g., `RBXScriptSignal`, `RBXScriptConnection`) | Self-fix: return `{ type: "Unsupported", typeName: "...", toString: "..." }` for unserializable types. | -| 3.4 (DataModel query) | `FindFirstChild` traversal hits a locked/inaccessible instance (e.g., CoreGui) | Self-fix: wrap each `FindFirstChild` call in `pcall`. Return `INSTANCE_NOT_FOUND` with a note about access restrictions. | -| 3.5 (terminal adapter) | Hard-coded dot-command chain in `terminal-editor.ts` has been modified since the plan was written (line numbers shifted) | Self-fix: search for the if/else chain by content pattern rather than line number. Replace the entire block. | -| 3.5 (terminal adapter) | `createDotCommandHandler` type signature does not match the `CommandDefinition` array | Escalate: this is a cross-task interface issue with Task 1.7. Review the adapter type with the command handler infrastructure owner. | diff --git a/studio-bridge/plans/execution/phases/04-split-server.md b/studio-bridge/plans/execution/phases/04-split-server.md deleted file mode 100644 index a901add611..0000000000 --- a/studio-bridge/plans/execution/phases/04-split-server.md +++ /dev/null @@ -1,122 +0,0 @@ -# Phase 4: Split Server Mode - -Goal: Enable the workflow where Studio runs on the host OS but the CLI runs inside a devcontainer. The bridge host pattern from Phase 1 already provides the core mechanism -- `BridgeConnection` handles host/client role detection. The split server is an operational concern (how to start the host), not an API concern. No new abstractions, no daemon layer, no separate protocol. See `05-split-server.md` for the full spec. - -References: -- Split server mode: `studio-bridge/plans/tech-specs/05-split-server.md` - -Base path for all file references: `/workspaces/NevermoreEngine/tools/studio-bridge/` - -Cross-references: -- Agent prompts: `studio-bridge/plans/execution/agent-prompts/04-split-server.md` -- Validation: `studio-bridge/plans/execution/validation/04-split-server.md` -- Depends on Task 1.3 (bridge module) -- see `01-bridge-network.md` - ---- - -### Task 4.1: Serve command -- thin wrapper - -**Description**: Implement `studio-bridge serve` as a thin command handler in `src/commands/serve.ts` (following the same `CommandDefinition` pattern as all other commands). It calls `BridgeConnection.connectAsync({ keepAlive: true })` to start a headless bridge host that stays alive indefinitely. This is the same bridge host that any CLI process creates when it is first to bind port 38741 -- the only difference is that `serve` always becomes the host (never a client) and never exits on idle. Unlike the implicit host behavior (which falls back to client on EADDRINUSE), `serve` errors if the port is already in use. - -**Files to create**: -- `src/commands/serve.ts` -- `CommandDefinition>` handler with `requiresSession: false`. Calls `BridgeConnection.connectAsync({ keepAlive: true })`, sets up SIGTERM/SIGINT signal handlers, logs status to stdout. Accepts `--port`, `--log-level`, `--json`, `--timeout` flags. - -**Files to modify**: -- `src/commands/index.ts` -- add `serveCommand` to named exports and `allCommands` array. -- `src/cli/cli.ts` -- no change needed (it already loops over `allCommands`). - -**Dependencies**: Task 1.3, Task 1.7. - -**Complexity**: S - -**Acceptance criteria**: -- `studio-bridge serve` binds port 38741 (or `--port N`) and stays alive until killed. -- Plugin can discover and connect via the `/health` endpoint. -- Other CLIs can connect as bridge clients. -- `--json` outputs structured status on stdout (for programmatic consumers). -- `--log-level` controls verbosity (silent, error, warn, info, debug). -- `--timeout ` enables auto-shutdown after idle period with no connections (default: none). -- SIGTERM/SIGINT trigger graceful `disconnectAsync()` (which runs the hand-off protocol). -- If port 38741 is already in use, prints a clear error: "Port 38741 is already in use. A bridge host is already running. Connect as a client with any studio-bridge command, or use --port to start on a different port." -- There is NO `src/cli/commands/serve-command.ts` -- the command lives in `src/commands/serve.ts` like all other commands. -- There is NO `src/server/daemon-server.ts` or `src/server/daemon-client.ts` -- the serve command uses `bridge-host.ts` from `src/bridge/internal/` directly via `BridgeConnection`. - -### Task 4.2: Remote bridge client (devcontainer CLI) - -**Description**: When the CLI runs inside a devcontainer (or when `--remote` is specified), `BridgeConnection.connectAsync()` connects to a remote bridge host instead of trying to bind locally. The CLI is just a bridge client pointing at a different host. No separate "daemon client" abstraction is needed -- `bridge-client.ts` from `src/bridge/internal/` already handles this. - -**Files to modify**: -- `src/bridge/bridge-connection.ts` -- add `remoteHost?: string` to `BridgeConnectionOptions`. When set, skip the local bind attempt and connect directly as a client to the specified host via the existing `bridge-client.ts`. -- `src/cli/args/global-args.ts` -- add `--remote` flag (e.g., `--remote localhost:38741`) and `--local` flag (force local mode, disable auto-detection). - -**Dependencies**: Task 1.3. - -**Complexity**: S - -**Acceptance criteria**: -- `studio-bridge exec --remote localhost:38741 'print("hi")'` connects as a bridge client to the remote host and executes. -- `studio-bridge exec --local 'print("hi")'` forces local mode even inside a devcontainer. -- All commands work through the remote connection: `exec`, `run`, `terminal`, `state`, `screenshot`, `logs`, `query`, `sessions`. -- Connection errors produce clear messages: "Could not connect to bridge host at localhost:38741. Is `studio-bridge serve` or `studio-bridge terminal --keep-alive` running on the host?" - -### Task 4.3: Devcontainer auto-detection - -**Description**: When running inside a devcontainer, automatically try connecting to a remote bridge host before falling back to local mode. Detection is based on the `REMOTE_CONTAINERS` or `CODESPACES` environment variables, or the existence of `/.dockerenv`. The detection utility lives inside the bridge module's internal directory because it is part of the connection logic -- not visible to consumers. - -**Files to create**: -- `src/bridge/internal/environment-detection.ts` -- `isDevcontainer(): boolean`, `getDefaultRemoteHost(): string | null`. Uses environment variable checks and file existence checks. - -**Files to modify**: -- `src/bridge/bridge-connection.ts` -- in `connectAsync`, if `remoteHost` is not set but `isDevcontainer()` is true, attempt remote connection to `localhost:38741` before falling back to local bind. - -**Dependencies**: Task 4.2. **Must complete before Task 6.5 starts** (sequential chain: 4.2 -> 4.3 -> 6.5 -- all modify `bridge-connection.ts`). - -**Complexity**: S - -**Acceptance criteria**: -- Inside a devcontainer with a bridge host running on the host (port-forwarded), `studio-bridge exec 'print("hi")'` works without `--remote` flag. -- Outside a devcontainer, behavior is unchanged (local host/client detection). -- If the remote host is not reachable from inside devcontainer, falls back to local mode with a warning. -- The environment detection module is in `src/bridge/internal/` (not `src/server/`) -- it is internal to the bridge module. - -### Parallelization within Phase 4 - -Tasks 4.1 and 4.2 both depend only on Task 1.3 (bridge module) and can proceed in parallel. Task 4.1 also depends on 1.7 (command handler infra) for the `CommandDefinition` pattern. Task 4.3 depends on 4.2. - -> **Sequential chain (bridge-connection.ts)**: Tasks 4.2, 4.3, and 6.5 all modify `bridge-connection.ts` and MUST be sequenced: 4.2 -> 4.3 -> 6.5. Do NOT run them in parallel. Task 6.5 (CI integration, Phase 6) is included in this chain because it modifies the same file. - -``` -4.1 (serve command) ------------------------------------------------+ - +--> (both done) -4.2 (remote client) --> 4.3 (auto-detection) --> 6.5 (CI integration)| -``` - ---- - -## Testing Strategy (Phase 4) - -**Integration tests**: -- Start `studio-bridge serve` (bridge host), connect mock plugin, connect CLI as bridge client via `--remote`, execute script, verify output. -- Kill CLI client, verify bridge host stays alive. -- Kill bridge host, verify plugin detects disconnect and polls for reconnection. -- Start `studio-bridge terminal --keep-alive`, connect from a second CLI as client, verify commands relay correctly. - -**Manual testing** (devcontainer): -- Start `studio-bridge serve` (or `terminal --keep-alive`) on host. -- Inside devcontainer, run `studio-bridge exec 'print("hello")'` -- verify auto-detection and relay via port-forwarded 38741. -- Verify port forwarding works. - ---- - -## Failure Modes - -Default policy: **escalate integration issues to review agent, self-fix isolated issues.** - -| Task | Likely Failure | Recovery Action | -|------|---------------|-----------------| -| 4.1 (serve command) | `serve` command fails because `BridgeConnection.connectAsync({ keepAlive: true })` does not prevent idle exit as expected | Self-fix: verify that `keepAlive: true` disables the 5-second idle timer. Add a test that starts `serve`, waits 10 seconds with no connections, and verifies the process is still alive. | -| 4.1 (serve command) | Port 38741 is already in use by a non-bridge process, and the error message is confusing | Self-fix: detect `EADDRINUSE`, check if the existing process is a bridge host (via `/health`), and print a specific error message for each case. | -| 4.2 (remote client) | `--remote` connection fails because the remote host's WebSocket server rejects the client path | Self-fix: ensure the remote host accepts `/client` connections. Add a test that connects via `--remote localhost:`. | -| 4.2 (remote client) | Remote connection latency causes action timeouts that work fine locally | Self-fix: increase default timeouts when `remoteHost` is set. Add a `--timeout` override flag. | -| 4.3 (auto-detection) | Devcontainer detection returns false positive (running in Docker but not a devcontainer) | Self-fix: use multiple signals (`REMOTE_CONTAINERS`, `CODESPACES`, `/.dockerenv`) and require at least one to match. Log which signal triggered detection. | -| 4.3 (auto-detection) | Port forwarding from host to devcontainer is not set up, causing silent connection failure | Self-fix: when remote connection fails after auto-detection, print a clear error with instructions to add port 38741 to `forwardPorts` in `devcontainer.json`. Fall back to local mode. | diff --git a/studio-bridge/plans/execution/phases/05-mcp-server.md b/studio-bridge/plans/execution/phases/05-mcp-server.md deleted file mode 100644 index 25c3d48543..0000000000 --- a/studio-bridge/plans/execution/phases/05-mcp-server.md +++ /dev/null @@ -1,150 +0,0 @@ -# Phase 5: MCP Integration - -Goal: Expose all capabilities as MCP tools so AI agents (Claude Code, etc.) can discover and use them. The MCP server is a thin adapter over the same `CommandDefinition` handlers used by the CLI and terminal -- no separate business logic. Full design: `studio-bridge/plans/tech-specs/06-mcp-server.md`. - -References: -- MCP server: `studio-bridge/plans/tech-specs/06-mcp-server.md` - -Base path for all file references: `/workspaces/NevermoreEngine/tools/studio-bridge/` - -Cross-references: -- Agent prompts: `studio-bridge/plans/execution/agent-prompts/05-mcp-server.md` -- Validation: `studio-bridge/plans/execution/validation/05-mcp-server.md` -- Depends on Phase 3 (all command handlers) and Task 1.7 -- see `01-bridge-network.md` and `03-commands.md` - ---- - -### Dependency Changes - -Phase 5 introduces a new runtime dependency: - -| Package | Version constraint | Type | Notes | -|---------|-------------------|------|-------| -| `@modelcontextprotocol/sdk` | `^1` | `dependency` | Required at runtime for the MCP server (stdio transport, tool registration, JSON-RPC framing). This is a `dependency`, NOT a `devDependency`, because the MCP server runs as `studio-bridge mcp` in production. No peer dependencies required. | - -Add this to `tools/studio-bridge/package.json` in Task 5.1 when creating the MCP server scaffold. - ---- - -### Task 5.1: MCP server scaffold and mcp command - -**Description**: Create an MCP server that runs as a long-lived process, registers with MCP-compatible clients, and shares session state with the CLI. The `studio-bridge mcp` command follows the `CommandDefinition` pattern (with `mcpEnabled: false` and `requiresSession: false`) and starts the MCP server via `startMcpServerAsync()`. See `06-mcp-server.md` section 5 for the server lifecycle. - -**Files to create**: -- `src/mcp/mcp-server.ts` -- MCP server lifecycle (`startMcpServerAsync`), tool registration from `allCommands`, stdio transport setup. See `06-mcp-server.md` section 5.2. -- `src/mcp/index.ts` -- public exports. -- `src/commands/mcp.ts` -- `mcpCommand: CommandDefinition` with `requiresSession: false` and `mcpEnabled: false`. Calls `startMcpServerAsync()`. - -**Files to modify**: -- `src/commands/index.ts` -- add `mcpCommand` to exports and `allCommands`. -- `package.json` -- add `@modelcontextprotocol/sdk` dependency. - -**Dependencies**: Task 1.7 (command handler infrastructure), Phase 3 complete (all action handlers available). - -**Complexity**: M - -**Acceptance criteria**: -- `studio-bridge mcp` starts an MCP server communicating via stdio transport. -- The server connects to the bridge network via `BridgeConnection.connectAsync({ keepAlive: true })`. -- The server advertises tool definitions for all MCP-eligible commands (sessions, state, screenshot, logs, query, exec). -- The `mcp` command itself is NOT exposed as an MCP tool (`mcpEnabled: false`). -- The server stays alive as long as the MCP client is connected. -- Diagnostic logs go to stderr, not stdout (to avoid interfering with stdio transport). - -### Task 5.2: MCP adapter (tool generation from CommandDefinitions) - -**Description**: Implement the `createMcpTool` adapter that generates MCP tool definitions from `CommandDefinition` handlers. This is the third adapter alongside `createCliCommand` and `createDotCommandHandler`. Each MCP tool is generated -- NOT hand-written. See `06-mcp-server.md` section 4 and `02-command-system.md` section 10. - -**Tools generated** (all from existing handlers via the adapter loop): -- `studio_sessions` -- from `sessionsCommand` in `src/commands/sessions.ts` -- `studio_state` -- from `stateCommand` in `src/commands/state.ts` -- `studio_screenshot` -- from `screenshotCommand` in `src/commands/screenshot.ts` -- `studio_logs` -- from `logsCommand` in `src/commands/logs.ts` -- `studio_query` -- from `queryCommand` in `src/commands/query.ts` -- `studio_exec` -- from `execCommand` in `src/commands/exec.ts` - -**Files to create**: -- `src/mcp/adapters/mcp-adapter.ts` -- `createMcpTool(definition, connection)` that generates an MCP tool from a `CommandDefinition`. Handles session resolution via `resolveSessionAsync` with `interactive: false`, returns `data` as JSON in text content blocks, returns base64 image in image content blocks for screenshots, maps errors to `isError: true` tool results. See `06-mcp-server.md` section 4. - -There are NO per-tool files. No `src/mcp/tools/studio-state-tool.ts`. No `src/mcp/tools/index.ts`. Tools are registered in the loop in `mcp-server.ts`. - -**Dependencies**: Task 5.1, Task 1.7 (CommandDefinition types and adapters). - -**Complexity**: M - -**Acceptance criteria**: -- Each tool is generated from the same `CommandDefinition` handler used by the CLI and terminal -- no separate handler implementations exist. -- `createMcpTool` uses `mcpName` and `mcpDescription` from the definition when available, falling back to `studio_${name}` and `description`. -- Each tool has a JSON Schema for input and output (auto-generated from the `ArgSpec` array, with `sessionId` injected for session-requiring commands). -- Session resolution uses `resolveSessionAsync` with `interactive: false`. -- Script execution errors are returned as normal tool results with `success: false` (not `isError: true`). Infrastructure errors (no session, timeout, connection failure) use `isError: true`. -- `studio_screenshot` returns base64 image data in an MCP image content block (`type: 'image'`). -- All other tools return structured JSON in text content blocks. - -### Task 5.3: MCP transport and configuration - -**Description**: Support the stdio MCP transport (for Claude Code integration) via the `@modelcontextprotocol/sdk` library. Write a configuration example showing how to register studio-bridge as an MCP tool provider. See `06-mcp-server.md` section 8 for configuration details. - -**Files to modify**: -- `src/mcp/mcp-server.ts` -- wire the `StdioServerTransport` from the MCP SDK. - -**Dependencies**: Tasks 5.1, 5.2. - -**Complexity**: S - -**Acceptance criteria**: -- The MCP server communicates correctly over stdio (JSON-RPC) using `StdioServerTransport`. -- A Claude Code MCP configuration entry (`{ "command": "studio-bridge", "args": ["mcp"] }`) can discover all tools. -- The `--remote` flag on the `mcp` command connects to a remote bridge host (for devcontainer use). -- The `--log-level` flag controls diagnostic output on stderr. - -### Parallelization within Phase 5 - -Task 5.1 must complete first. Tasks 5.2 and 5.3 depend on 5.1 but can be done in parallel. - -``` -5.1 (scaffold) --> 5.2 (tool definitions) - --> 5.3 (transport) -``` - ---- - -## Testing Strategy (Phase 5) - -See `06-mcp-server.md` section 11 for the full testing strategy. - -**Unit tests**: -- `createMcpTool` generates correct tool name, description, input schema from a `CommandDefinition`. -- `createMcpTool` uses `mcpName`/`mcpDescription` overrides when set. -- Each MCP tool produces correct output for valid input (structured JSON, not formatted text). -- Each MCP tool returns `isError: true` for infrastructure failures (no session, timeout). -- Script execution errors return `success: false` in data (NOT `isError: true`). -- `studio_screenshot` returns an image content block (not text). -- Session auto-selection works within MCP context (`interactive: false`). -- Commands with `mcpEnabled: false` are not registered as MCP tools. - -**Integration tests**: -- Start MCP server in subprocess, send `tools/list` via stdio, verify all expected tools listed. -- Send `tools/call` for each tool with mock bridge connection, verify structured JSON response. -- Send `tools/call` for unknown tool, verify JSON-RPC error response. - -**Manual validation**: -- Register in Claude Code MCP configuration, verify tools appear. -- Call `studio_sessions`, `studio_exec`, `studio_screenshot` from Claude Code. - ---- - -## Failure Modes - -Default policy: **escalate integration issues to review agent, self-fix isolated issues.** - -| Task | Likely Failure | Recovery Action | -|------|---------------|-----------------| -| 5.1 (MCP scaffold) | `@modelcontextprotocol/sdk` API has changed since the tech spec was written | Self-fix: check the SDK version pinned in `package.json`, consult SDK docs, and adapt. The SDK is stable but method names may differ. | -| 5.1 (MCP scaffold) | MCP server's `BridgeConnection` conflicts with the CLI's `BridgeConnection` when both run in the same process | Escalate: this is an architecture issue. The MCP server should use `BridgeConnection.connectAsync({ keepAlive: true })` and share the connection. If the connection model does not support this, review with the bridge module owner. | -| 5.1 (MCP scaffold) | Diagnostic logs on stderr interfere with MCP stdio transport | Self-fix: ensure all `console.log` calls go to stderr, not stdout. Add a `--silent` mode that suppresses all stderr output. | -| 5.2 (MCP adapter) | `createMcpTool` cannot generate JSON Schema from `ArgSpec` because the type information is insufficient | Self-fix: add explicit `jsonSchema` field to `ArgSpec` entries. Each command defines its own schema inline. | -| 5.2 (MCP adapter) | Session resolution with `interactive: false` throws instead of returning an error result | Self-fix: catch the resolution error and return it as an `isError: true` tool result with a descriptive message. | -| 5.2 (MCP adapter) | Screenshot base64 data is too large for an MCP response | Self-fix: check MCP response size limits. If exceeded, write to temp file and return the file path instead. | -| 5.3 (transport) | Claude Code MCP client does not discover tools because the `tools/list` response format is wrong | Self-fix: test with `echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | studio-bridge mcp` and verify the response matches the MCP spec. | -| 5.3 (transport) | `--remote` flag on `mcp` command does not work because `BridgeConnection` initialization happens before the flag is parsed | Self-fix: ensure connection is created lazily (on first tool call) or that the `--remote` flag is passed through `BridgeConnectionOptions`. | diff --git a/studio-bridge/plans/execution/phases/06-integration.md b/studio-bridge/plans/execution/phases/06-integration.md deleted file mode 100644 index 42387f48d5..0000000000 --- a/studio-bridge/plans/execution/phases/06-integration.md +++ /dev/null @@ -1,315 +0,0 @@ -# Phase 6: Polish - -Goal: Documentation, migration guide, end-to-end testing, and cleanup. - -References: -- Overview: `studio-bridge/plans/tech-specs/00-overview.md` - -Base path for all file references: `/workspaces/NevermoreEngine/tools/studio-bridge/` - -Cross-references: -- Agent prompts: `studio-bridge/plans/execution/agent-prompts/06-integration.md` -- Validation: `studio-bridge/plans/execution/validation/06-integration.md` - ---- - -### Task 6.1: Update existing tests - -**Description**: Ensure all existing tests pass with the refactored code. Add integration tests that exercise the full lifecycle with both temporary and persistent plugin modes. - -**Files to modify**: -- `src/server/studio-bridge-server.test.ts` -- add tests for v2 handshake, registry integration, persistent plugin detection. -- `src/server/web-socket-protocol.test.ts` -- already updated in Task 1.1, but verify coverage. - -**Dependencies**: All of Phases 1-3. - -**Complexity**: M - -### Task 6.2: End-to-end test suite - -**Description**: Create a test harness that simulates a complete persistent session workflow: install plugin, start server, simulate plugin connection (mock WebSocket client), execute script, query state, capture screenshot (mock), query DataModel (mock), stream logs, disconnect, reconnect. - -**Files to create**: -- `src/test/e2e/persistent-session.test.ts` -- full lifecycle test. -- `src/test/e2e/split-server.test.ts` -- bridge host + remote client relay test. -- `src/test/e2e/hand-off.test.ts` -- bridge host transfer and crash recovery test (complements the focused failover tests from Task 1.9 with full-stack e2e scenarios including real commands and session management). -- `src/test/helpers/mock-plugin-client.ts` -- simulates a v2 plugin for testing. - -**Dependencies**: All of Phases 1-4. - -**Complexity**: L - -### Task 6.3: Migration guide - -**Description**: Write user-facing documentation covering: how to install the persistent plugin, how to use new commands, how to set up split-server mode for devcontainers, how to configure MCP for AI agents. - -**Files to create**: -- Documentation content for the migration guide (exact location determined by docs structure). - -**Dependencies**: All phases complete. - -**Complexity**: S - -### Task 6.4: Update index.ts exports - -**Description**: Ensure all new public types and classes are exported from `src/index.ts` for library consumers. - -**Files to modify**: -- `src/index.ts` -- export `BridgeConnection`, `BridgeSession`, `BridgeConnectionOptions`, `SessionInfo`, action types, new protocol types, MCP types. - -**Dependencies**: All phases complete. - -**Complexity**: S - -### Task 6.5: CI integration - -**Description**: Ensure the bridge host pattern works in CI environments. In CI, the persistent plugin is never installed, so the system must fall back to temporary injection. The bridge host pattern has no disk state, so no temp directory override is needed. - -**Files to modify**: -- `src/bridge/bridge-connection.ts` -- respect `CI=true` to set `preferPersistentPlugin: false` by default. - -**Dependencies**: Task 4.3 (sequential chain: 4.2 -> 4.3 -> 6.5). All three tasks modify `bridge-connection.ts` and MUST be sequenced to avoid merge conflicts. Do NOT start 6.5 until 4.3 is complete and merged. - -**Complexity**: S - -**Acceptance criteria**: -- In CI (`CI=true` env var), `BridgeConnection` defaults to `preferPersistentPlugin: false`, forcing temporary injection fallback. -- Persistent plugin detection returns `false` in CI. -- All existing CI workflows pass without modification. - ---- - -## Phase 6 Release Gate -- REVIEW CHECKPOINT - -> This is the final review checkpoint before any public release. All automated tests must pass AND all items on this checklist must be verified. A review agent can verify code quality, test results, and export correctness. Items requiring Roblox Studio (manual E2E) require Studio validation -- no agent can run Studio. - -**Release gate reviewer checklist**: -- [ ] All automated test suites pass (`cd tools/studio-bridge && npm run test`) including e2e tests from Task 6.2 -- [ ] Manual Studio E2E validation passes: plugin installs, discovers server, connects, survives Play/Stop transitions (items 1-9 from `validation/06-integration.md` section 4) -- [ ] All six action commands work against a real Studio instance: `exec`, `state`, `screenshot`, `logs`, `query`, `sessions` (items 10-17 from `validation/06-integration.md` section 4) -- [ ] Context-aware commands verified in real Play mode: `--context server` and `--context client` target the correct DataModel (items 18-23 from `validation/06-integration.md` section 4) -- [ ] `index.ts` exports all v1 types unchanged AND all new v2 types (`BridgeConnection`, `BridgeSession`, `SessionInfo`, etc.) -- verified by import assertion tests in Task 6.4 - ---- - -## Critical Path - -The longest dependency chain determines the minimum number of sequential steps to reach a fully functional system: - -``` -1.1 (protocol v2) - -> 1.5 (v2 handshake) - -> 1.6 (action dispatch) - -> 2.1 (persistent plugin core) - -> 2.2 (execute action in plugin) - -> 2.5 (detection + fallback) - -> 3.1-3.4 (new actions, parallel) <- also needs 1.7 - -> 3.5 (wire terminal adapter) - -> 5.1 (MCP scaffold) - -> 5.2 (MCP tools via adapter) - -> 6.2 (e2e tests) -``` - -The command handler infrastructure (1.7) feeds into the critical path at 3.1-3.4 but is not on the critical path itself, because it depends only on 1.3 and can be completed well before 1.6 -> 2.1 -> 2.2 -> 2.5 finishes. However, if 1.7 is delayed past 2.5, it becomes a bottleneck for all Phase 3 work. - -**Failover tasks (1.8, 1.9, 1.10) are NOT on the critical path** but are a hard Phase 1 gate. They depend only on 1.3 and can proceed in parallel with 1.4-1.7. However, they MUST complete before Phase 2 begins. Commands built in Phases 2-3 assume the bridge network recovers from host death. If failover is broken, every downstream command will have intermittent failures that are extremely hard to diagnose because the symptoms (silent timeouts, missing sessions, duplicate hosts) look like bugs in the command layer, not the networking layer. - -**Phase 0 (output modes) is NOT on the critical path.** Tasks 0.1-0.4 modify `tools/cli-output-helpers/`, not `tools/studio-bridge/`, and can be completed at any time before Phase 2. Task 1.7 (command handler infrastructure) is where the output modes are integrated into the CLI adapter. Commands can also use `formatTable` directly in their handler's `summary` composition without the CLI adapter integration, so Phase 0 does not strictly gate Phase 2 work. - -**Critical path length**: 12 sequential steps (unchanged -- Phase 0 and failover tasks run in parallel with the critical path). - -**Tasks that block the most downstream work**: -1. **Task 1.1 (protocol v2)** -- blocks everything in Phases 2, 3, and 5. -2. **Task 1.3 (bridge module)** -- blocks Task 1.4 (StudioBridge wrapper), Task 1.7 (command handler infra), Tasks 1.8-1.10 (failover), all of Phase 4 (split server), Task 2.3 (health endpoint), Task 2.6 (sessions command), and Task 2.7 (exec/run refactor + session selection). This is the largest foundation task. -3. **Task 1.7 (command handler infra)** -- blocks all command implementations in Phases 2-3 (2.6, 2.7, 3.1-3.4) and the MCP adapter (5.2). Must be completed before any action command task starts. Start immediately after 1.3. Integrates the output mode utilities from Phase 0 into the CLI adapter. -4. **Task 1.6 (action dispatch)** -- blocks all action implementations in Phase 3. -5. **Task 2.1 (persistent plugin core)** -- blocks all plugin-side action handlers. -6. **Task 1.8 (failover implementation)** -- gates Phase 2. If failover is deferred, all commands built on the bridge network will have undiagnosed intermittent failures when hosts restart. - -Tasks 1.1, 1.3, and 0.1-0.4 should be prioritized above all others and can all proceed in parallel. Task 1.7 should start as soon as 1.3 is complete -- it is a prerequisite for all command work in Phases 2-3. Tasks 1.8-1.10 should start as soon as 1.3 is complete and must finish before any Phase 2 work begins. - ---- - -## Risk Mitigation - -### Risk 1: Roblox CaptureService runtime failures - -**Threat**: The screenshot call chain (`CaptureService:CaptureScreenshot` -> `EditableImage` -> pixel read -> base64 encode) is confirmed to work in Studio plugins, but individual steps may fail at runtime in certain conditions (e.g., Studio is minimized, rendering errors, resource constraints, or `EditableImage` API unavailability). - -**Mitigation**: -- Each step in the call chain is wrapped in `pcall`: the `CaptureScreenshot` call, `EditableImage` creation via `AssetService:CreateEditableImageAsync`, and pixel read via `ReadPixels` (or similar). Each failure returns a clear `SCREENSHOT_FAILED` error with details about which step failed. -- The `captureScreenshot` capability is always advertised (CaptureService is available in plugin context). -- If a capture fails at runtime, the error message describes the specific failure so the user can take action (e.g., un-minimize Studio). - -**Contingency**: Runtime capture failures return actionable error messages. All other features are independent of screenshots. - -### Risk 2: WebSocket reliability in Studio - -**Threat**: Roblox's `HttpService:CreateWebStreamClient` has been unreliable in some Studio builds -- connections drop silently, large frames are truncated, or the API is missing entirely. - -**Mitigation**: -- The persistent plugin implements aggressive reconnection with exponential backoff (Task 2.1). -- Heartbeat messages (every 15 seconds) detect stale connections quickly. -- The server configures generous frame size limits (16MB) and enables per-message compression. -- Large payloads (screenshots) are base64-encoded to avoid binary frame issues. -- If WebSocket creation fails, the plugin logs a clear error and retries after 5 seconds. - -**Contingency**: If WebSocket issues are systemic in a particular Studio build, users can fall back to the temporary plugin (which uses the same WebSocket API but for shorter durations). - -### Risk 3: Cross-platform plugin path differences - -**Threat**: The Studio plugins folder is in different locations on macOS (`~/Library/Application Support/Roblox/Plugins/`) vs Windows (`%LOCALAPPDATA%/Roblox/Plugins/`). Linux (wine) may have yet another path. - -**Mitigation**: -- `findPluginsFolder()` in `studio-process-manager.ts` already handles macOS and Windows. Verify it works for all currently supported platforms during Task 2.4. -- The `install-plugin` command prints the exact path it writes to, so users can verify. -- If the plugins folder cannot be detected, the command fails with instructions for manual installation. - -### Risk 4: Port forwarding in devcontainers - -**Threat**: Split-server mode requires the bridge host port to be forwarded from the host into the devcontainer. VS Code's devcontainer port forwarding is automatic for detected ports, but the bridge host port (38741) may not be auto-detected. - -**Mitigation**: -- Document the port forwarding requirement explicitly in the devcontainer setup guide (Task 6.3). -- Recommend adding the port to `.devcontainer/devcontainer.json`'s `forwardPorts` array. -- The auto-detection logic (Task 4.3) tries the port and falls back gracefully with a clear error message. -- The `--remote` flag allows users to specify an arbitrary host:port, bypassing auto-detection. - -### Risk 5: Port contention on 38741 - -**Threat**: The well-known port 38741 may already be in use by another process on the developer's machine, preventing the bridge host from starting. - -**Mitigation**: -- `BridgeConnection.connectAsync()` detects `EADDRINUSE` and attempts to connect as a client. If the existing process on that port is not a bridge host (e.g., different application), the connection will fail with a clear error. -- The `--port` flag on `studio-bridge serve` allows using an alternate port. -- If another studio-bridge host is already running, that is the correct behavior -- the new CLI becomes a client. -- Document the port in README so users can avoid conflicts. - -### Risk 6: Bridge host crash leaves orphaned plugins - -**Threat**: If the bridge host crashes and no clients are connected to take over, plugins enter a polling loop until the next CLI invocation starts a new host. - -**Mitigation**: -- Plugins use exponential backoff (1s, 2s, 4s, 8s, max 30s) when polling, so they do not spam the port. -- The next CLI invocation automatically becomes the new host and plugins reconnect within ~2 seconds. -- The hand-off protocol (Tasks 1.3 and 1.8) ensures that if clients ARE connected, one of them takes over immediately. -- The 5-second idle grace period on the host prevents premature exit between rapid CLI invocations. -- `SO_REUSEADDR` on the server socket (Task 1.8) prevents TIME_WAIT from blocking rapid port rebind. -- Structured debug logging (Task 1.10) makes failover issues diagnosable. - -### Risk 7: Failover timing races and debugging difficulty - -**Threat**: The bridge host is the single point of failure. When it dies, multiple processes (clients, plugins) must coordinate to recover. Timing races during failover can cause duplicate hosts, lost sessions, or silent request failures. Debugging failover issues is extremely difficult because symptoms (silent timeouts, missing sessions) look like application-layer bugs. - -**Mitigation**: -- Dedicated failover implementation task (Task 1.8) with hardened state machine and deterministic transitions. -- Dedicated failover integration test suite (Task 1.9) with timing assertions and multi-client scenarios. -- Structured debug logging for every state transition during failover (Task 1.10). -- Random jitter (0-500ms) prevents thundering herd when multiple clients race to become host. -- Inflight requests are rejected with `SessionDisconnectedError` immediately on host death (not left to timeout silently). -- The `health` endpoint includes `lastFailoverAt` timestamp for post-mortem diagnostics. -- The `sessions` command detects failover-in-progress and prints actionable guidance instead of a confusing error. - -**Contingency**: If failover proves too complex for the initial release, the fallback is to make hosts non-transferable: when the host dies, clients simply reconnect from scratch. This is worse for UX but eliminates timing races entirely. The test suite (Task 1.9) will reveal whether the full hand-off protocol is stable enough for production. - ---- - -## Testing Strategy (Phase 6) - -**End-to-end**: -- Full test suite exercising every feature in both single-process and split-server modes. -- CI pipeline passes with no persistent plugin installed. -- Verify migration guide instructions work on a fresh setup. - ---- - -## Sub-Agent Assignment - -### Suitable for sub-agent execution - -These tasks are self-contained, have clear inputs/outputs, and do not require human judgment or manual testing with Roblox Studio: - -| Task | Rationale | -|------|-----------| -| 0.1-0.4 (output modes) | Pure utility modules in cli-output-helpers. Well-specified in output-modes-plan.md. No studio-bridge dependencies. | -| 1.1 (protocol v2 types) | Pure TypeScript type definitions and decode logic. Well-specified in `01-protocol.md`. | -| 1.2 (pending request map) | Small standalone utility with clear interface. | -| 1.4 (StudioBridge wrapper) | Small modification to existing class, wrapping BridgeConnection. | -| 1.5 (v2 handshake) | Modification to existing handshake handler. Protocol spec is precise. | -| 1.6 (action dispatch) | Standalone dispatch layer. Depends on 1.1 and 1.2 which provide precise types. | -| 2.3 (health endpoint) | Small addition to bridge-host.ts HTTP handler. | -| 2.4 (plugin manager + install commands) | Universal PluginManager API with well-specified interface from `03-persistent-plugin.md` section 2. Pure TypeScript utility with clear types. | -| 1.7 (command handler infra) | Well-specified interfaces and adapters. Spec is precise in `02-command-system.md`. Integrates output modes from Phase 0. | -| 2.6 (sessions command) | Single handler file calling `BridgeConnection.listSessionsAsync()`. | -| 3.1 (state query) | End-to-end but each layer is simple. Single handler in `src/commands/state.ts`. | -| 3.3 (log query) | Moderate complexity, well-specified. | -| 4.1 (serve command) | Thin wrapper around `BridgeConnection.connectAsync({ keepAlive: true })`. | -| 4.2 (remote client) | Small addition to `BridgeConnectionOptions` for remote host. | -| 1.10 (failover observability) | Structured logging additions and health endpoint fields. Small, well-scoped modifications to existing files. | -| 4.3 (devcontainer detection) | Small environment-detection utility. | -| 5.2 (MCP adapter) | Generic adapter from CommandDefinition to MCP tool. Well-specified in `06-mcp-server.md` section 4 and `02-command-system.md` section 10. | -| 5.3 (MCP transport) | Standard MCP SDK integration. Configuration documented in `06-mcp-server.md` section 8. | -| 6.4 (index.ts exports) | Trivial. | -| 6.5 (CI integration) | Small environment-aware changes. | - -### Requires review agent, orchestrator coordination, or Studio validation - -| Task | Rationale | Review approach | -|------|-----------|----------------| -| 1.3 (bridge module) | Core networking module with host/client detection, hand-off protocol. | Skilled agent implements; review agent verifies design against tech spec and test coverage. | -| 1.8 (failover impl) | Multi-process coordination with timing races. State machine must be deterministic. | Skilled agent implements with real socket tests; review agent verifies state machine correctness. | -| 1.9 (failover tests) | Integration tests involving multiple concurrent processes, port binding races, and timing assertions. | Skilled agent implements; review agent verifies timing assertions and teardown patterns. | -| 2.1 (persistent plugin core) | Complex Luau code with Roblox service wiring. | Agent implements code + Lune tests; Studio validation deferred to Phase 6 E2E. | -| 2.2 (execute action in plugin) | Luau in Studio context, must test with real execute flow. | Agent implements; review agent checks code quality. Requires Studio validation. | -| 2.5 (detection + fallback) | Integration between persistent plugin detection and fallback to temporary injection. | Agent implements with thorough tests; review agent verifies edge case coverage. | -| 2.7 (session selection) | Session resolution UX, handler pattern consistency. | Agent implements; review agent verifies pattern consistency and test coverage. | -| 3.2 (screenshot) | CaptureService confirmed working; runtime edge cases need Studio validation. | Agent implements code + mock tests; Requires Studio validation for edge cases. | -| 3.4 (DataModel query) | Complex Roblox type serialization. | Agent implements code + mock tests; Requires Studio validation for real type serialization. | -| 3.5 (terminal dot-commands) | Interactive REPL wiring to adapter registry. | Agent implements; review agent verifies dispatch pattern and dot-command coverage. | -| 5.1 (MCP scaffold + mcp command) | MCP server lifecycle, BridgeConnection integration. Uses `@modelcontextprotocol/sdk` (decided in `06-mcp-server.md`). | Agent implements; Claude Code validation is a separate step. | -| 6.2 (e2e tests) | Orchestrating multi-process integration tests. | Skilled agent implements with full codebase context. | -| 6.3 (migration guide) | Technical writing requiring understanding of user workflows. | Agent writes; review agent verifies completeness and accuracy against implementation. | - -### Recommended execution order for a single developer - -If only one developer is available, the recommended sequence is: - -1. Tasks 0.1-0.4, 1.1, 1.2 (output modes + protocol + pending requests, can interleave -- Phase 0 touches a different package so there are no conflicts) -2. Task 1.3 (bridge module -- largest foundation task, start early) -3. Tasks 1.4, 1.5, 1.8 (integrate foundation + failover -- 1.8 can proceed in parallel with 1.4/1.5) -4. Tasks 1.6, 1.9, 1.10 (action dispatch + failover tests + observability) -5. Task 1.7 (command handler infra -- integrates output modes from Phase 0) -6. Task 2.1 (persistent plugin -- start early, it is the longest single task) -7. Tasks 2.2, 2.3, 2.4 (plugin manager + install commands), 2.5 (complete Phase 2) -8. Tasks 2.6, 2.7 (CLI session support) -9. Tasks 3.1, 3.2, 3.3, 3.4 (actions) -10. Task 3.5 (terminal integration) -11. Tasks 4.1, 4.2, 4.3 (split server -- now much simpler, mostly wiring) -12. Tasks 5.1, 5.2, 5.3 (MCP) -13. Tasks 6.1-6.5 (polish) - -### Recommended assignment for two agents working in parallel - -**Agent A** (TypeScript server-side + bridge module + output modes + failover): -0.1-0.4 (output modes) -> 1.1 -> 1.2 -> 1.3 (bridge module) -> 1.8 (failover impl) -> 1.9 (failover tests) -> 1.5 -> 1.6 -> 2.3 -> 3.1 -> 3.3 -> 4.1 -> 4.2 -> 4.3 -> 5.1 -> 5.2 -> 5.3 - -**Agent B** (Luau plugin + CLI focus + observability): -1.10 (failover observability, after A completes 1.8) -> 1.4 (StudioBridge wrapper, after A completes 1.3) -> 1.7 (command handler infra, after A completes 0.1-0.4 and 1.3) -> 2.1 -> 2.2 -> 2.4 (plugin manager + install commands) -> 2.5 -> 2.6 -> 2.7 -> 3.2 -> 3.4 -> 3.5 -> 6.1 -> 6.2 - -Sync points: Agent B waits for Agent A to complete 1.1 before starting 2.1. Agent B waits for Agent A to complete 1.3 before starting 1.4. Agent B waits for Agent A to complete 1.8 before starting 1.10. Agent B waits for Agent A to complete 0.1-0.4 and 1.3 before starting 1.7. Agent B waits for Agent A to complete 1.6 before starting 3.2/3.4. Agent A must complete 1.9 (failover tests passing) before either agent starts Phase 2. - ---- - -## Failure Modes - -Default policy: **escalate integration issues to review agent, self-fix isolated issues.** - -| Task | Likely Failure | Recovery Action | -|------|---------------|-----------------| -| 6.1 (update tests) | Refactored code breaks existing tests in ways that are not obvious (e.g., timing changes, different error messages) | Self-fix: fix the tests to match the new behavior. If the new behavior is wrong, fix the implementation. | -| 6.1 (update tests) | v2 handshake tests conflict with existing v1 handshake tests | Self-fix: ensure both v1 and v2 paths are tested independently. Do not remove v1 tests. | -| 6.2 (e2e tests) | Mock plugin client does not accurately simulate real plugin behavior | Escalate: the mock should be reviewed against real Studio plugin behavior. If the mock diverges significantly, it will produce false confidence. | -| 6.2 (e2e tests) | E2e tests are too slow (> 60 seconds per test) due to real timeouts | Self-fix: use shorter timeouts in test configuration. Add `testTimeoutMs` override for e2e test suites. | -| 6.3 (migration guide) | Documentation references APIs that changed during implementation | Self-fix: review all code samples in the guide against the actual implementation before publishing. | -| 6.4 (index.ts exports) | Exporting internal types accidentally creates a public API commitment | Escalate: review the export list against the tech spec. Only export types listed in `07-bridge-network.md` section 2.1. | -| 6.5 (CI integration) | `CI=true` detection interferes with non-CI environments that happen to set this variable | Self-fix: use `CI=true` (the standard convention). Document that `CI=true` forces ephemeral plugin mode. Add `--prefer-persistent-plugin` flag to override. | diff --git a/studio-bridge/plans/execution/validation/00.5-plugin-modules.md b/studio-bridge/plans/execution/validation/00.5-plugin-modules.md deleted file mode 100644 index 1e2a92af1d..0000000000 --- a/studio-bridge/plans/execution/validation/00.5-plugin-modules.md +++ /dev/null @@ -1,666 +0,0 @@ -# Validation: Phase 0.5 -- Lune-Testable Plugin Modules - -Test specifications for the pure Luau plugin modules: Protocol, DiscoveryStateMachine, ActionRouter, MessageBuffer, and cross-language integration. - -**Phase**: 0.5 (Lune-Testable Plugin Modules) - -**References**: -- Phase plan: `studio-bridge/plans/execution/phases/00.5-plugin-modules.md` -- Agent prompts: `studio-bridge/plans/execution/agent-prompts/00.5-plugin-modules.md` -- Tech specs: `studio-bridge/plans/tech-specs/01-protocol.md`, `studio-bridge/plans/tech-specs/03-persistent-plugin.md`, `studio-bridge/plans/tech-specs/04-action-specs.md` - -Base path for source files: `/workspaces/NevermoreEngine/tools/studio-bridge/templates/studio-bridge-plugin/` - ---- - -## Required Test Files - -| Test file | Task | Module(s) under test | -|-----------|------|---------------------| -| `test/protocol.test.luau` | 0.5.1 | Protocol | -| `test/discovery.test.luau` | 0.5.2 | DiscoveryStateMachine | -| `test/actions.test.luau` | 0.5.3 | ActionRouter, MessageBuffer | -| `test/integration/lune-bridge.test.luau` | 0.5.4 | Protocol, DiscoveryStateMachine, ActionRouter (end-to-end) | - -## Test Harness (Prerequisite) - -Task 0.5.1 creates the shared test harness before any module tests can run. These files live in `test/`: - -| File | Purpose | -|------|---------| -| `test/roblox-mocks.luau` | Minimal stubs for HttpService, RunService, LogService, and a basic Signal mock | -| `test/test-runner.luau` | Simple test runner that discovers and runs test files, prints pass/fail, exits 0 or 1 | - -All test commands use: `lune run test/test-runner.luau` (runs all test files) or `lune run test/.luau` (runs a single test file). - ---- - -## 1. Unit Test Plans - -### 1.1 Protocol Module (`test/protocol.test.luau`) - -**Expected test count**: ~20 tests - -#### 1.1.1 Encode -- all message types - -- **Test name**: `Protocol.encode produces valid JSON for register message` -- **Priority**: P0 -- **Type**: unit -- **Steps**: - 1. Construct a `register` message table with `type`, `sessionId`, `payload` (containing `pluginVersion`, `instanceId`, `placeName`, `state`, `capabilities`), and `protocolVersion`. - 2. Call `Protocol.encode(message)`. - 3. Decode the resulting JSON string back into a table using `net.jsonDecode`. - 4. Verify all fields are present and match. -- **Expected result**: Valid JSON string containing all fields. -- **Automation**: Lune test. - ---- - -- **Test name**: `Protocol.encode produces valid JSON for each plugin-to-server message type` -- **Priority**: P0 -- **Type**: unit -- **Steps**: - 1. For each plugin-to-server type (`register`, `hello`, `scriptComplete`, `output`, `stateResult`, `screenshotResult`, `dataModelResult`, `logsResult`, `stateChange`, `heartbeat`, `subscribeResult`, `unsubscribeResult`, `error`), construct a valid message table. - 2. Call `Protocol.encode` for each. - 3. Verify each produces parseable JSON with the correct `type` field. -- **Expected result**: All 13 plugin-to-server types produce valid JSON. -- **Automation**: Lune test, loop over message types. - ---- - -- **Test name**: `Protocol.encode omits requestId and protocolVersion when nil` -- **Priority**: P0 -- **Type**: unit -- **Steps**: - 1. Construct a message with `requestId = nil` and `protocolVersion = nil`. - 2. Encode it. - 3. Parse the JSON and verify neither key is present. -- **Expected result**: JSON output has no `requestId` or `protocolVersion` keys. -- **Automation**: Lune test. - ---- - -- **Test name**: `Protocol.encode includes requestId and protocolVersion when present` -- **Priority**: P0 -- **Type**: unit -- **Steps**: - 1. Construct a message with `requestId = "req-001"` and `protocolVersion = 2`. - 2. Encode and parse back. -- **Expected result**: Both fields present in output. -- **Automation**: Lune test. - -#### 1.1.2 Decode -- valid messages - -- **Test name**: `Protocol.decode round-trips correctly for every message type` -- **Priority**: P0 -- **Type**: unit -- **Steps**: - 1. For each of the ~23 message types (plugin-to-server + server-to-plugin), construct a message, encode it, then decode the result. - 2. Compare decoded table to original. -- **Expected result**: Decoded message matches original for every type. -- **Automation**: Lune test, parameterized loop. - ---- - -- **Test name**: `Protocol.decode passes through requestId when present` -- **Priority**: P0 -- **Type**: unit -- **Steps**: - 1. Encode a message with `requestId = "req-abc"`. - 2. Decode it. - 3. Verify `requestId` is `"req-abc"` in the decoded result. -- **Expected result**: `requestId` preserved. -- **Automation**: Lune test. - ---- - -- **Test name**: `Protocol.decode passes through protocolVersion when present` -- **Priority**: P0 -- **Type**: unit -- **Steps**: - 1. Encode a message with `protocolVersion = 2`. - 2. Decode it. -- **Expected result**: `protocolVersion` is `2` in decoded result. -- **Automation**: Lune test. - -#### 1.1.3 Decode -- error handling - -- **Test name**: `Protocol.decode returns nil and error for invalid JSON` -- **Priority**: P0 -- **Type**: unit -- **Steps**: - 1. Call `Protocol.decode("not valid json")`. -- **Expected result**: Returns `nil, `. -- **Automation**: Lune test. - ---- - -- **Test name**: `Protocol.decode returns nil and error for missing type field` -- **Priority**: P0 -- **Type**: unit -- **Steps**: - 1. Call `Protocol.decode('{"sessionId":"x","payload":{}}')`. -- **Expected result**: Returns `nil, `. -- **Automation**: Lune test. - ---- - -- **Test name**: `Protocol.decode returns nil and error for missing sessionId` -- **Priority**: P0 -- **Type**: unit -- **Steps**: - 1. Call `Protocol.decode('{"type":"hello","payload":{}}')`. -- **Expected result**: Returns `nil, `. -- **Automation**: Lune test. - ---- - -- **Test name**: `Protocol.decode returns nil and error for missing payload` -- **Priority**: P0 -- **Type**: unit -- **Steps**: - 1. Call `Protocol.decode('{"type":"hello","sessionId":"x"}')`. -- **Expected result**: Returns `nil, `. -- **Automation**: Lune test. - ---- - -- **Test name**: `Protocol.decode returns nil and error for unknown message type` -- **Priority**: P0 -- **Type**: unit -- **Steps**: - 1. Call `Protocol.decode('{"type":"unknownType","sessionId":"x","payload":{}}')`. -- **Expected result**: Returns `nil, "unknown message type: unknownType"`. -- **Automation**: Lune test. - ---- - -- **Test name**: `Protocol.decode returns nil and error for empty string` -- **Priority**: P1 -- **Type**: unit -- **Steps**: - 1. Call `Protocol.decode("")`. -- **Expected result**: Returns `nil, `. -- **Automation**: Lune test. - -### 1.2 Discovery State Machine (`test/discovery.test.luau`) - -**Expected test count**: ~15 tests - -#### 1.2.1 State transitions - -- **Test name**: `State starts as idle` -- **Priority**: P0 -- **Type**: unit -- **Steps**: - 1. Create a `DiscoveryStateMachine` with default config and mock callbacks. - 2. Call `getState()`. -- **Expected result**: Returns `"idle"`. -- **Automation**: Lune test. - ---- - -- **Test name**: `start() transitions from idle to searching` -- **Priority**: P0 -- **Type**: unit -- **Steps**: - 1. Create state machine. Call `start()`. - 2. Call `getState()`. -- **Expected result**: Returns `"searching"`. `onStateChange` was called with `("idle", "searching")`. -- **Automation**: Lune test. - ---- - -- **Test name**: `Successful httpGet transitions from searching to connecting` -- **Priority**: P0 -- **Type**: unit -- **Steps**: - 1. Create state machine with mock `httpGet` that returns `(true, '{"wsUrl":"ws://localhost:38740/plugin"}')`. - 2. Call `start()`. - 3. Call `tick(pollIntervalMs)` to trigger a poll. -- **Expected result**: State is `"connecting"`. `onStateChange` called with `("searching", "connecting")`. -- **Automation**: Lune test. - ---- - -- **Test name**: `Failed httpGet stays in searching, tries next port` -- **Priority**: P0 -- **Type**: unit -- **Steps**: - 1. Create state machine with mock `httpGet` that always returns `(false, nil)`. - 2. Call `start()`. - 3. Call `tick(pollIntervalMs)` multiple times. -- **Expected result**: State remains `"searching"`. `httpGet` is called with successive port URLs. -- **Automation**: Lune test, track call arguments. - ---- - -- **Test name**: `Successful connectWebSocket transitions from connecting to connected` -- **Priority**: P0 -- **Type**: unit -- **Steps**: - 1. Create state machine with mock `httpGet` (success) and mock `connectWebSocket` returning `(true, mockConnection)`. - 2. Advance through searching to connecting. - 3. Call `tick(0)` to trigger the WebSocket attempt. -- **Expected result**: State is `"connected"`. `onConnected` callback was called with `mockConnection`. -- **Automation**: Lune test. - ---- - -- **Test name**: `Failed connectWebSocket transitions from connecting back to searching` -- **Priority**: P0 -- **Type**: unit -- **Steps**: - 1. Create state machine with mock `connectWebSocket` returning `(false, nil)`. - 2. Advance to connecting state. - 3. Call `tick(0)`. -- **Expected result**: State is `"searching"`. -- **Automation**: Lune test. - ---- - -- **Test name**: `onDisconnect while connected transitions to reconnecting` -- **Priority**: P0 -- **Type**: unit -- **Steps**: - 1. Advance state machine to `"connected"`. - 2. Call `onDisconnect("server closed")`. -- **Expected result**: State is `"reconnecting"`. `onDisconnected` callback called with `"server closed"`. -- **Automation**: Lune test. - ---- - -- **Test name**: `Reconnecting transitions to connecting after backoff` -- **Priority**: P0 -- **Type**: unit -- **Steps**: - 1. Advance to `"reconnecting"`. - 2. Call `tick(initialBackoffMs)`. -- **Expected result**: State is `"connecting"` (attempting reconnection). -- **Automation**: Lune test. - ---- - -- **Test name**: `Reconnect attempts exhaust and return to idle` -- **Priority**: P0 -- **Type**: unit -- **Steps**: - 1. Advance to `"reconnecting"`. - 2. Loop: for each reconnect attempt, tick enough time for backoff, then fail `connectWebSocket`. - 3. Repeat until `maxReconnectAttempts` is reached. -- **Expected result**: After all attempts exhausted, state is `"idle"`. -- **Automation**: Lune test. - -#### 1.2.2 Backoff behavior - -- **Test name**: `Exponential backoff doubles each attempt, capped at maxBackoffMs` -- **Priority**: P0 -- **Type**: unit -- **Steps**: - 1. Set `initialBackoffMs = 1000`, `maxBackoffMs = 8000`. - 2. Force repeated reconnection failures. - 3. Track the backoff delay before each attempt. -- **Expected result**: Delays are 1000, 2000, 4000, 8000, 8000, ... (capped). -- **Automation**: Lune test. - -#### 1.2.3 Stop behavior - -- **Test name**: `stop() transitions to idle from any state` -- **Priority**: P0 -- **Type**: unit -- **Steps**: - 1. For each of `searching`, `connecting`, `connected`, `reconnecting`: advance to that state, then call `stop()`. -- **Expected result**: State is `"idle"` in every case. `onStateChange` called with `(previousState, "idle")`. -- **Automation**: Lune test, parameterized. - ---- - -- **Test name**: `stop() cancels pending timers` -- **Priority**: P1 -- **Type**: unit -- **Steps**: - 1. Advance to `"reconnecting"` with a pending backoff. - 2. Call `stop()`. - 3. Call `tick(maxBackoffMs)`. -- **Expected result**: No state transition occurs after `stop()`. State remains `"idle"`. -- **Automation**: Lune test. - -#### 1.2.4 Port scanning - -- **Test name**: `Port scanning iterates from portRange.min to portRange.max and wraps` -- **Priority**: P0 -- **Type**: unit -- **Steps**: - 1. Set `portRange = { min = 38740, max = 38742 }` (3 ports). - 2. Mock `httpGet` to always fail. - 3. Call `start()`, then `tick()` enough times to scan all ports. - 4. Track the URLs passed to `httpGet`. -- **Expected result**: URLs include ports 38740, 38741, 38742, then wrap back to 38740. -- **Automation**: Lune test. - -### 1.3 Action Router and Message Buffer (`test/actions.test.luau`) - -**Expected test count**: ~18 tests - -#### 1.3.1 ActionRouter -- dispatch - -- **Test name**: `Registered handler is called on dispatch` -- **Priority**: P0 -- **Type**: unit -- **Steps**: - 1. Create `ActionRouter`. Register a handler for `"execute"`. - 2. Dispatch a message with `type = "execute"`. -- **Expected result**: Handler called with `(payload, requestId, sessionId)`. Response message returned with `type = "scriptComplete"`. -- **Automation**: Lune test. - ---- - -- **Test name**: `Dispatch returns correct response type for each action` -- **Priority**: P0 -- **Type**: unit -- **Steps**: - 1. For each entry in `RESPONSE_TYPES` (`execute -> scriptComplete`, `queryState -> stateResult`, etc.), register a handler and dispatch. -- **Expected result**: Response `type` matches the mapping for every action. -- **Automation**: Lune test, loop over RESPONSE_TYPES. - ---- - -- **Test name**: `Response preserves sessionId and requestId from the original message` -- **Priority**: P0 -- **Type**: unit -- **Steps**: - 1. Dispatch a message with `sessionId = "sess-1"` and `requestId = "req-001"`. -- **Expected result**: Response has `sessionId = "sess-1"` and `requestId = "req-001"`. -- **Automation**: Lune test. - ---- - -- **Test name**: `Handler returning nil produces no response` -- **Priority**: P1 -- **Type**: unit -- **Steps**: - 1. Register a handler that returns `nil`. - 2. Dispatch a message. -- **Expected result**: `dispatch` returns `nil`. -- **Automation**: Lune test. - ---- - -- **Test name**: `Unknown message type returns UNKNOWN_REQUEST error response` -- **Priority**: P0 -- **Type**: unit -- **Steps**: - 1. Create `ActionRouter` with no registered handlers. - 2. Dispatch a message with `type = "unknownAction"`. -- **Expected result**: Response has `type = "error"` and payload containing `code = "UNKNOWN_REQUEST"`. -- **Automation**: Lune test. - ---- - -- **Test name**: `Handler that throws returns INTERNAL_ERROR error response` -- **Priority**: P0 -- **Type**: unit -- **Steps**: - 1. Register a handler that calls `error("boom")`. - 2. Dispatch a message. -- **Expected result**: Response has `type = "error"` and payload containing `code = "INTERNAL_ERROR"`. -- **Automation**: Lune test. - ---- - -- **Test name**: `Multiple handlers can be registered for different types` -- **Priority**: P0 -- **Type**: unit -- **Steps**: - 1. Register handlers for `"execute"`, `"queryState"`, and `"queryLogs"`. - 2. Dispatch one message of each type. -- **Expected result**: Each handler is called for its type. Correct response types returned. -- **Automation**: Lune test. - ---- - -- **Test name**: `Re-registering a handler for the same type replaces the previous one` -- **Priority**: P1 -- **Type**: unit -- **Steps**: - 1. Register handler A for `"execute"`. - 2. Register handler B for `"execute"`. - 3. Dispatch an `"execute"` message. -- **Expected result**: Handler B is called, not handler A. -- **Automation**: Lune test. - -#### 1.3.2 MessageBuffer -- basic operations - -- **Test name**: `MessageBuffer stores entries up to capacity` -- **Priority**: P0 -- **Type**: unit -- **Steps**: - 1. Create `MessageBuffer.new(5)`. - 2. Push 5 entries. - 3. Call `size()`. -- **Expected result**: `size()` returns 5. -- **Automation**: Lune test. - ---- - -- **Test name**: `Pushing beyond capacity overwrites the oldest entry` -- **Priority**: P0 -- **Type**: unit -- **Steps**: - 1. Create `MessageBuffer.new(3)`. - 2. Push entries A, B, C, D. - 3. Call `get("head", 3)`. -- **Expected result**: Returns B, C, D (A was overwritten). `size()` is 3. -- **Automation**: Lune test. - ---- - -- **Test name**: `get("tail", N) returns the N most recent entries` -- **Priority**: P0 -- **Type**: unit -- **Steps**: - 1. Create `MessageBuffer.new(10)`. Push 7 entries. - 2. Call `get("tail", 3)`. -- **Expected result**: Returns the 3 most recently pushed entries, newest first. -- **Automation**: Lune test. - ---- - -- **Test name**: `get("head", N) returns the N oldest entries` -- **Priority**: P0 -- **Type**: unit -- **Steps**: - 1. Create `MessageBuffer.new(10)`. Push 7 entries. - 2. Call `get("head", 3)`. -- **Expected result**: Returns the 3 oldest entries in insertion order. -- **Automation**: Lune test. - ---- - -- **Test name**: `get returns total count and bufferCapacity` -- **Priority**: P0 -- **Type**: unit -- **Steps**: - 1. Create `MessageBuffer.new(100)`. Push 25 entries. - 2. Call `get()`. -- **Expected result**: Result has `total = 25` and `bufferCapacity = 100`. -- **Automation**: Lune test. - ---- - -- **Test name**: `clear() empties the buffer` -- **Priority**: P0 -- **Type**: unit -- **Steps**: - 1. Create `MessageBuffer.new(10)`. Push 5 entries. - 2. Call `clear()`. - 3. Call `size()`. -- **Expected result**: `size()` returns 0. `get()` returns empty entries. -- **Automation**: Lune test. - ---- - -- **Test name**: `get with count larger than buffer size returns all entries` -- **Priority**: P1 -- **Type**: unit -- **Steps**: - 1. Create `MessageBuffer.new(10)`. Push 3 entries. - 2. Call `get("head", 100)`. -- **Expected result**: Returns all 3 entries (not an error). -- **Automation**: Lune test. - ---- - -- **Test name**: `Ring buffer wrap-around preserves correct ordering` -- **Priority**: P0 -- **Type**: unit -- **Steps**: - 1. Create `MessageBuffer.new(3)`. - 2. Push entries 1 through 7 (wraps around multiple times). - 3. Call `get("head", 3)`. -- **Expected result**: Returns entries 5, 6, 7 in order. -- **Automation**: Lune test. - ---- - -- **Test name**: `Entries contain level, body, and timestamp` -- **Priority**: P0 -- **Type**: unit -- **Steps**: - 1. Push an entry with `level = "Warning"`, `body = "test msg"`, `timestamp = 12345`. - 2. Retrieve it with `get("tail", 1)`. -- **Expected result**: Entry has all three fields with correct values. -- **Automation**: Lune test. - -### 1.4 Integration Tests (`test/integration/lune-bridge.test.luau`) - -**Expected test count**: ~6 tests - -#### 1.4.1 Full round-trip - -- **Test name**: `Register -> welcome handshake completes over real WebSocket` -- **Priority**: P0 -- **Type**: integration -- **Steps**: - 1. Start TypeScript bridge host as subprocess. - 2. Wait for `/health` to respond. - 3. Connect WebSocket via Lune `net.socket`. - 4. Send `register` via `Protocol.encode`. - 5. Receive and decode `welcome` via `Protocol.decode`. -- **Expected result**: Decoded welcome contains `protocolVersion` and `capabilities`. -- **Automation**: Lune test. - ---- - -- **Test name**: `Execute action round-trip produces output and scriptComplete` -- **Priority**: P0 -- **Type**: integration -- **Steps**: - 1. Complete handshake (register/welcome). - 2. Receive `execute` message from server. - 3. Send `output` and `scriptComplete` responses via Protocol. - 4. Verify server receives and processes them. -- **Expected result**: Full execute lifecycle completes without error. -- **Automation**: Lune test. - ---- - -- **Test name**: `Heartbeat message is accepted by server` -- **Priority**: P1 -- **Type**: integration -- **Steps**: - 1. Complete handshake. - 2. Send a `heartbeat` message. - 3. Verify no error response received. -- **Expected result**: Server accepts heartbeat without error. -- **Automation**: Lune test. - ---- - -- **Test name**: `Server subprocess is cleaned up on test completion` -- **Priority**: P0 -- **Type**: integration -- **Steps**: - 1. Run any integration test. - 2. Verify the bridge host process is no longer running after test teardown. -- **Expected result**: No leaked processes. -- **Automation**: Lune test, check process status in teardown. - ---- - -- **Test name**: `Test fails with clear message when server is unavailable` -- **Priority**: P1 -- **Type**: integration -- **Steps**: - 1. Attempt to connect without starting the server. - 2. Verify the test produces a descriptive failure message within 10 seconds. -- **Expected result**: Clear error message, not a hang. -- **Automation**: Lune test. - ---- - -- **Test name**: `Reconnection after intentional disconnect` -- **Priority**: P1 -- **Type**: integration -- **Steps**: - 1. Complete handshake. - 2. Close WebSocket from client side. - 3. Use DiscoveryStateMachine to reconnect. - 4. Re-register and verify welcome received. -- **Expected result**: Reconnection succeeds. Second handshake completes. -- **Automation**: Lune test. - ---- - -## Expected Test Counts Summary - -| Test file | Expected tests | -|-----------|---------------| -| `test/protocol.test.luau` | ~20 | -| `test/discovery.test.luau` | ~15 | -| `test/actions.test.luau` | ~18 | -| `test/integration/lune-bridge.test.luau` | ~6 | -| **Total** | **~59** | - ---- - -## Pass Criteria - -Each test file must pass independently: -```bash -lune run test/protocol.test.luau # exit code 0 -lune run test/discovery.test.luau # exit code 0 -lune run test/actions.test.luau # exit code 0 -lune run test/integration/lune-bridge.test.luau # exit code 0 -``` - -Or run all via the test runner: -```bash -lune run test/test-runner.luau # exit code 0 -``` - ---- - -## Phase 0.5 Gate Criteria - -**All of the following must be true before Phase 0.5 is considered complete:** - -1. **Test harness exists**: `test/roblox-mocks.luau` and `test/test-runner.luau` are present and functional. -2. **All unit tests pass**: `lune run test/test-runner.luau` exits with code 0, running all four test files. -3. **No Roblox API usage**: None of the `src/Shared/*.luau` modules reference `game`, `HttpService`, `RunService`, `LogService`, `task`, or any other Roblox global. Verified by grep. -4. **No Nevermore imports**: None of the modules use `require(script.Parent.loader)` or import any Nevermore package. -5. **Protocol coverage**: Every message type listed in `studio-bridge/plans/tech-specs/01-protocol.md` has at least one encode/decode round-trip test. -6. **Discovery coverage**: All 5 states (`idle`, `searching`, `connecting`, `connected`, `reconnecting`) have tests exercising their entry and exit transitions. -7. **ActionRouter coverage**: All 7 `RESPONSE_TYPES` mappings are tested. Error cases (unknown type, handler error) are tested. -8. **MessageBuffer coverage**: Ring buffer wrap-around, head/tail retrieval, and clear are all tested. -9. **Integration test**: Task 0.5.4 completes a full register -> welcome -> execute -> scriptComplete round-trip against the real TypeScript bridge host (requires Task 1.3a). - -**Gate command** (unit tests only, no external dependency): -```bash -cd /workspaces/NevermoreEngine/tools/studio-bridge/templates/studio-bridge-plugin && lune run test/test-runner.luau -``` - -**Gate command** (full, including integration): -```bash -cd /workspaces/NevermoreEngine/tools/studio-bridge/templates/studio-bridge-plugin && lune run test/test-runner.luau && lune run test/integration/lune-bridge.test.luau -``` diff --git a/studio-bridge/plans/execution/validation/01-bridge-network.md b/studio-bridge/plans/execution/validation/01-bridge-network.md deleted file mode 100644 index a341da6943..0000000000 --- a/studio-bridge/plans/execution/validation/01-bridge-network.md +++ /dev/null @@ -1,1516 +0,0 @@ -# Validation: Phase 1 -- Bridge Network Foundation - -> **Shared test infrastructure**: All tests that connect a mock plugin MUST use the standardized `MockPluginClient` from `shared-test-utilities.md`. Do not create ad-hoc WebSocket mocks. See [shared-test-utilities.md](./shared-test-utilities.md) for the full specification, usage examples, and design decisions. - -Test specifications for the bridge network layer: protocol v2, session tracking, pending request map, and host failover. - -**Phase**: 1 (Bridge Network Foundation) - -**References**: -- Phase plan: `studio-bridge/plans/execution/phases/01-bridge-network.md` -- Agent prompts: `studio-bridge/plans/execution/agent-prompts/01-bridge-network.md` -- Tech specs: `studio-bridge/plans/tech-specs/01-protocol.md`, `studio-bridge/plans/tech-specs/02-command-system.md`, `studio-bridge/plans/tech-specs/07-bridge-network.md`, `studio-bridge/plans/tech-specs/08-host-failover.md` -- Existing tests: `tools/studio-bridge/src/server/web-socket-protocol.test.ts`, `studio-bridge-server.test.ts` - -Base path for source files: `/workspaces/NevermoreEngine/tools/studio-bridge/` - ---- - -## 1. Unit Test Plans - -### 1.1 Protocol Layer - -Tests for `src/server/web-socket-protocol.ts`. All tests go in `src/server/web-socket-protocol.test.ts` (extend existing file). - -#### 1.1.1 decodePluginMessage -- register message - -- **Test name**: `decodePluginMessage decodes a valid register message with all fields` -- **Priority**: P0 -- **Type**: unit -- **Setup**: None. -- **Steps**: - 1. Call `decodePluginMessage` with a JSON string containing `type: 'register'`, `sessionId: 'abc'`, `protocolVersion: 2`, and a full payload (`pluginVersion`, `instanceId`, `placeName`, `placeFile`, `state: 'Edit'`, `pid: 12345`, `capabilities: [...]`). - 2. Verify the returned object matches the `RegisterMessage` shape. -- **Expected result**: Returns a `RegisterMessage` with all fields populated, including `protocolVersion: 2` and `capabilities` array. -- **Automation**: vitest, inline JSON construction. - ---- - -- **Test name**: `decodePluginMessage decodes register with optional placeFile omitted` -- **Priority**: P1 -- **Type**: unit -- **Setup**: None. -- **Steps**: - 1. Call `decodePluginMessage` with a register message where `placeFile` is absent. - 2. Verify the returned object has `placeFile: undefined`. -- **Expected result**: Returns `RegisterMessage` with `placeFile` undefined. -- **Automation**: vitest. - ---- - -- **Test name**: `decodePluginMessage returns null for register with missing required fields` -- **Priority**: P0 -- **Type**: unit -- **Setup**: None. -- **Steps**: - 1. Call `decodePluginMessage` with register messages missing each required field in turn: `pluginVersion`, `instanceId`, `placeName`, `state`, `capabilities`. - 2. Verify each returns `null`. -- **Expected result**: Returns `null` for every variant with a missing required field. -- **Automation**: vitest, parameterized test or loop. - ---- - -- **Test name**: `decodePluginMessage returns null for register with invalid state value` -- **Priority**: P1 -- **Type**: unit -- **Setup**: None. -- **Steps**: - 1. Call `decodePluginMessage` with register where `state` is `"InvalidState"`. -- **Expected result**: Returns `null`. -- **Automation**: vitest. - -#### 1.1.2 decodePluginMessage -- stateResult message - -- **Test name**: `decodePluginMessage decodes a valid stateResult` -- **Priority**: P0 -- **Type**: unit -- **Setup**: None. -- **Steps**: - 1. Call `decodePluginMessage` with `type: 'stateResult'`, `requestId: 'req-001'`, payload with `state: 'Edit'`, `placeId: 123`, `placeName: 'Test'`, `gameId: 456`. -- **Expected result**: Returns a typed `StateResultMessage` with all fields matching. -- **Automation**: vitest. - ---- - -- **Test name**: `decodePluginMessage returns null for stateResult without requestId` -- **Priority**: P0 -- **Type**: unit -- **Setup**: None. -- **Steps**: - 1. Call `decodePluginMessage` with a `stateResult` message that has no `requestId`. -- **Expected result**: Returns `null`. -- **Automation**: vitest. - ---- - -- **Test name**: `decodePluginMessage returns null for stateResult with invalid state enum` -- **Priority**: P1 -- **Type**: unit -- **Setup**: None. -- **Steps**: - 1. Call `decodePluginMessage` with `stateResult` where `state` is `"Bogus"`. -- **Expected result**: Returns `null`. -- **Automation**: vitest. - -#### 1.1.3 decodePluginMessage -- screenshotResult message - -- **Test name**: `decodePluginMessage decodes a valid screenshotResult` -- **Priority**: P0 -- **Type**: unit -- **Setup**: None. -- **Steps**: - 1. Call `decodePluginMessage` with `type: 'screenshotResult'`, `requestId: 'req-002'`, payload with `data: 'iVBOR...'`, `format: 'png'`, `width: 1920`, `height: 1080`. -- **Expected result**: Returns a typed `ScreenshotResultMessage`. -- **Automation**: vitest. - ---- - -- **Test name**: `decodePluginMessage returns null for screenshotResult with missing data field` -- **Priority**: P0 -- **Type**: unit -- **Setup**: None. -- **Steps**: - 1. Omit `data` from the payload. -- **Expected result**: Returns `null`. -- **Automation**: vitest. - ---- - -- **Test name**: `decodePluginMessage returns null for screenshotResult with non-string data` -- **Priority**: P1 -- **Type**: unit -- **Setup**: None. -- **Steps**: - 1. Set `data` to a number in the payload. -- **Expected result**: Returns `null`. -- **Automation**: vitest. - -#### 1.1.4 decodePluginMessage -- dataModelResult message - -- **Test name**: `decodePluginMessage decodes a valid dataModelResult with nested children` -- **Priority**: P0 -- **Type**: unit -- **Setup**: None. -- **Steps**: - 1. Construct a `dataModelResult` with `instance` containing `name`, `className`, `path`, `properties` (including a `Vector3` serialized value), `attributes`, `childCount: 1`, and `children` array with one child. -- **Expected result**: Returns a typed `DataModelResultMessage` with the full instance tree. -- **Automation**: vitest. - ---- - -- **Test name**: `decodePluginMessage returns null for dataModelResult without instance field` -- **Priority**: P0 -- **Type**: unit -- **Setup**: None. -- **Steps**: - 1. Send `dataModelResult` with empty payload. -- **Expected result**: Returns `null`. -- **Automation**: vitest. - -#### 1.1.5 decodePluginMessage -- logsResult message - -- **Test name**: `decodePluginMessage decodes a valid logsResult` -- **Priority**: P0 -- **Type**: unit -- **Setup**: None. -- **Steps**: - 1. Construct `logsResult` with `entries` array (3 entries with `level`, `body`, `timestamp`), `total: 100`, `bufferCapacity: 1000`. -- **Expected result**: Returns a typed `LogsResultMessage`. -- **Automation**: vitest. - ---- - -- **Test name**: `decodePluginMessage decodes logsResult with empty entries array` -- **Priority**: P1 -- **Type**: unit -- **Setup**: None. -- **Steps**: - 1. Send `logsResult` with `entries: []`, `total: 0`, `bufferCapacity: 1000`. -- **Expected result**: Returns valid `LogsResultMessage` with empty entries. -- **Automation**: vitest. - ---- - -- **Test name**: `decodePluginMessage returns null for logsResult without total field` -- **Priority**: P1 -- **Type**: unit -- **Setup**: None. -- **Steps**: - 1. Omit `total` from payload. -- **Expected result**: Returns `null`. -- **Automation**: vitest. - -#### 1.1.6 decodePluginMessage -- stateChange message - -- **Test name**: `decodePluginMessage decodes a valid stateChange push message` -- **Priority**: P0 -- **Type**: unit -- **Setup**: None. -- **Steps**: - 1. Construct `stateChange` with `previousState: 'Edit'`, `newState: 'Play'`, `timestamp: 47230`. No `requestId`. -- **Expected result**: Returns a typed `StateChangeMessage` with no `requestId`. -- **Automation**: vitest. - ---- - -- **Test name**: `decodePluginMessage returns null for stateChange with missing previousState` -- **Priority**: P1 -- **Type**: unit -- **Setup**: None. -- **Steps**: - 1. Omit `previousState` from payload. -- **Expected result**: Returns `null`. -- **Automation**: vitest. - -#### 1.1.7 decodePluginMessage -- heartbeat message - -- **Test name**: `decodePluginMessage decodes a valid heartbeat` -- **Priority**: P0 -- **Type**: unit -- **Setup**: None. -- **Steps**: - 1. Construct `heartbeat` with `uptimeMs: 45000`, `state: 'Edit'`, `pendingRequests: 0`. No `requestId`. -- **Expected result**: Returns a typed `HeartbeatMessage`. -- **Automation**: vitest. - ---- - -- **Test name**: `decodePluginMessage returns null for heartbeat with missing uptimeMs` -- **Priority**: P1 -- **Type**: unit -- **Setup**: None. -- **Steps**: - 1. Omit `uptimeMs` from heartbeat payload. -- **Expected result**: Returns `null`. -- **Automation**: vitest. - -#### 1.1.8 decodePluginMessage -- subscribeResult and unsubscribeResult - -- **Test name**: `decodePluginMessage decodes a valid subscribeResult` -- **Priority**: P0 -- **Type**: unit -- **Setup**: None. -- **Steps**: - 1. Construct `subscribeResult` with `requestId: 'sub-001'`, `events: ['stateChange', 'logPush']`. -- **Expected result**: Returns a typed `SubscribeResultMessage`. -- **Automation**: vitest. - ---- - -- **Test name**: `decodePluginMessage decodes a valid unsubscribeResult` -- **Priority**: P0 -- **Type**: unit -- **Setup**: None. -- **Steps**: - 1. Construct `unsubscribeResult` with `requestId: 'unsub-001'`, `events: ['logPush']`. -- **Expected result**: Returns a typed `UnsubscribeResultMessage`. -- **Automation**: vitest. - ---- - -- **Test name**: `decodePluginMessage returns null for subscribeResult without events array` -- **Priority**: P1 -- **Type**: unit -- **Setup**: None. -- **Steps**: - 1. Omit `events` from subscribeResult payload. -- **Expected result**: Returns `null`. -- **Automation**: vitest. - -#### 1.1.9 decodePluginMessage -- error message (plugin-originated) - -- **Test name**: `decodePluginMessage decodes a plugin error with requestId` -- **Priority**: P0 -- **Type**: unit -- **Setup**: None. -- **Steps**: - 1. Construct `error` with `requestId: 'req-005'`, `code: 'INSTANCE_NOT_FOUND'`, `message: 'No instance...'`, `details: { resolvedTo: 'game.Workspace' }`. -- **Expected result**: Returns a typed `PluginErrorMessage` with code, message, and details. -- **Automation**: vitest. - ---- - -- **Test name**: `decodePluginMessage decodes a plugin error without requestId` -- **Priority**: P0 -- **Type**: unit -- **Setup**: None. -- **Steps**: - 1. Construct `error` without `requestId`, with `code: 'INTERNAL_ERROR'`, `message: 'Something broke'`. -- **Expected result**: Returns `PluginErrorMessage` with `requestId: undefined`. -- **Automation**: vitest. - ---- - -- **Test name**: `decodePluginMessage returns null for error without code` -- **Priority**: P1 -- **Type**: unit -- **Setup**: None. -- **Steps**: - 1. Omit `code` from error payload. -- **Expected result**: Returns `null`. -- **Automation**: vitest. - -#### 1.1.10 decodePluginMessage -- v1 messages preserved - -- **Test name**: `decodePluginMessage still decodes v1 hello without protocolVersion` -- **Priority**: P0 -- **Type**: unit -- **Setup**: None. -- **Steps**: - 1. Send the exact same hello message as the existing test (no `protocolVersion`, no `capabilities`). -- **Expected result**: Returns `HelloMessage` identical to current behavior. -- **Automation**: vitest. This is a regression check -- existing test still passes. - ---- - -- **Test name**: `decodePluginMessage decodes extended hello with protocolVersion and capabilities` -- **Priority**: P0 -- **Type**: unit -- **Setup**: None. -- **Steps**: - 1. Send hello with `protocolVersion: 2`, `capabilities: ['execute', 'queryState']`, `pluginVersion: '1.0.0'`. -- **Expected result**: Returns `HelloMessage` with the additional fields populated. -- **Automation**: vitest. - ---- - -- **Test name**: `decodePluginMessage still returns null for unknown message types` -- **Priority**: P0 -- **Type**: unit -- **Setup**: None. -- **Steps**: - 1. Send `{ type: 'futureMessage', sessionId: 'x', payload: {} }`. -- **Expected result**: Returns `null`. This is the forward-compatibility behavior. -- **Automation**: vitest. - -#### 1.1.11 decodeServerMessage (new function) - -- **Test name**: `decodeServerMessage decodes welcome with v2 fields` -- **Priority**: P0 -- **Type**: unit -- **Setup**: None. -- **Steps**: - 1. Call `decodeServerMessage` with a welcome message containing `protocolVersion: 2`, `capabilities`, `serverVersion`. -- **Expected result**: Returns a typed `WelcomeMessage`. -- **Automation**: vitest. - ---- - -- **Test name**: `decodeServerMessage decodes queryState` -- **Priority**: P0 -- **Type**: unit -- **Setup**: None. -- **Steps**: - 1. Call `decodeServerMessage` with `{ type: 'queryState', sessionId: 'abc', requestId: 'req-001', payload: {} }`. -- **Expected result**: Returns a typed `QueryStateMessage`. -- **Automation**: vitest. - ---- - -- **Test name**: `decodeServerMessage decodes captureScreenshot` -- **Priority**: P0 -- **Type**: unit -- **Setup**: None. -- **Steps**: - 1. Call `decodeServerMessage` with `captureScreenshot` including `requestId` and `payload: { format: 'png' }`. -- **Expected result**: Returns a typed `CaptureScreenshotMessage`. -- **Automation**: vitest. - ---- - -- **Test name**: `decodeServerMessage decodes queryDataModel with all payload fields` -- **Priority**: P0 -- **Type**: unit -- **Setup**: None. -- **Steps**: - 1. Call `decodeServerMessage` with `queryDataModel` including `path`, `depth`, `properties`, `includeAttributes`, `find`, `listServices`. -- **Expected result**: Returns a typed `QueryDataModelMessage` with all optional fields. -- **Automation**: vitest. - ---- - -- **Test name**: `decodeServerMessage decodes queryLogs` -- **Priority**: P0 -- **Type**: unit -- **Setup**: None. -- **Steps**: - 1. Call `decodeServerMessage` with `queryLogs` including `count`, `direction`, `levels`, `includeInternal`. -- **Expected result**: Returns a typed `QueryLogsMessage`. -- **Automation**: vitest. - ---- - -- **Test name**: `decodeServerMessage decodes subscribe and unsubscribe` -- **Priority**: P0 -- **Type**: unit -- **Setup**: None. -- **Steps**: - 1. Call `decodeServerMessage` with `subscribe` and `unsubscribe` messages. -- **Expected result**: Returns typed `SubscribeMessage` and `UnsubscribeMessage`. -- **Automation**: vitest. - ---- - -- **Test name**: `decodeServerMessage decodes server error` -- **Priority**: P0 -- **Type**: unit -- **Setup**: None. -- **Steps**: - 1. Call `decodeServerMessage` with `{ type: 'error', sessionId: 'x', requestId: 'r', payload: { code: 'TIMEOUT', message: 'Timed out' } }`. -- **Expected result**: Returns a typed `ServerErrorMessage`. -- **Automation**: vitest. - ---- - -- **Test name**: `decodeServerMessage returns null for unknown type` -- **Priority**: P0 -- **Type**: unit -- **Setup**: None. -- **Steps**: - 1. Call `decodeServerMessage` with `{ type: 'unknownServer', sessionId: 'x', payload: {} }`. -- **Expected result**: Returns `null`. -- **Automation**: vitest. - ---- - -- **Test name**: `decodeServerMessage returns null for malformed JSON` -- **Priority**: P0 -- **Type**: unit -- **Setup**: None. -- **Steps**: - 1. Call `decodeServerMessage('not valid json')`. -- **Expected result**: Returns `null`. -- **Automation**: vitest. - -#### 1.1.12 encodeMessage -- v2 messages - -- **Test name**: `encodeMessage round-trips all v2 server message types` -- **Priority**: P0 -- **Type**: unit -- **Setup**: None. -- **Steps**: - 1. For each v2 `ServerMessage` type (`queryState`, `captureScreenshot`, `queryDataModel`, `queryLogs`, `subscribe`, `unsubscribe`, server `error`), construct a valid message object. - 2. Call `encodeMessage(msg)`. - 3. Parse the resulting JSON string. - 4. Verify the parsed object matches the original. -- **Expected result**: JSON round-trip preserves all fields including `requestId`. -- **Automation**: vitest, parameterized test. - ---- - -- **Test name**: `encodeMessage preserves v1 message format unchanged` -- **Priority**: P0 -- **Type**: unit -- **Setup**: None. -- **Steps**: - 1. Encode `welcome`, `execute`, `shutdown` messages identical to existing tests. - 2. Verify output matches the existing test expectations exactly. -- **Expected result**: Output is byte-identical to current behavior. -- **Automation**: vitest. Regression check against existing test data. - -#### 1.1.13 Encode/decode round-trip for all message types - -- **Test name**: `encode then decode round-trip for every v2 server message type` -- **Priority**: P0 -- **Type**: unit -- **Setup**: None. -- **Steps**: - 1. For each `ServerMessage` type, construct a message, encode it with `encodeMessage`, decode it with `decodeServerMessage`. - 2. Compare the decoded result to the original. -- **Expected result**: Decoded message matches the original for every type. -- **Automation**: vitest, parameterized. - ---- - -- **Test name**: `decode then encode round-trip for every v2 plugin message type` -- **Priority**: P0 -- **Type**: unit -- **Setup**: None. -- **Steps**: - 1. For each `PluginMessage` type, construct a JSON string, decode with `decodePluginMessage`, re-encode as JSON, parse, and compare. -- **Expected result**: All fields are preserved through the round-trip. -- **Automation**: vitest, parameterized. - -### 1.2 Session Tracking - -Tests for in-memory session tracking. There is no `SessionFile` or `SessionRegistry` class -- session tracking is done in-memory by the bridge host. New test file `src/registry/session-tracker.test.ts`. - -#### 1.2.1 SessionTracker - -- **Test name**: `SessionTracker.addSession adds a session with the correct (instanceId, context) key` -- **Priority**: P0 -- **Type**: unit -- **Setup**: Create a `SessionTracker` instance. -- **Steps**: - 1. Call `tracker.addSession({ sessionId: 'sess-1', instanceId: 'inst-1', context: 'edit', placeName: 'TestPlace', state: 'Edit' })`. - 2. Call `tracker.getSession('sess-1')`. -- **Expected result**: Returns the session object with matching `sessionId`, `instanceId`, `context`, `placeName`, and `state`. -- **Automation**: vitest. - ---- - -- **Test name**: `SessionTracker.removeSession removes a session and emits an event` -- **Priority**: P0 -- **Type**: unit -- **Setup**: Create a `SessionTracker` instance with one session added. Subscribe to the `onSessionRemoved` event. -- **Steps**: - 1. Call `tracker.removeSession('sess-1')`. - 2. Call `tracker.getSession('sess-1')`. - 3. Check the event listener. -- **Expected result**: `getSession` returns `undefined`. The `onSessionRemoved` event was emitted with the removed session's info. -- **Automation**: vitest. - ---- - -- **Test name**: `SessionTracker.getSession returns undefined for unknown sessionId` -- **Priority**: P0 -- **Type**: unit -- **Setup**: Create a `SessionTracker` instance with no sessions. -- **Steps**: - 1. Call `tracker.getSession('nonexistent')`. -- **Expected result**: Returns `undefined`. -- **Automation**: vitest. - ---- - -- **Test name**: `SessionTracker.getSessionsByInstance groups sessions by instanceId` -- **Priority**: P0 -- **Type**: unit -- **Setup**: Create a `SessionTracker` instance. Add 3 sessions sharing `instanceId: 'inst-1'` with contexts `edit`, `client`, `server`. Add 1 session with `instanceId: 'inst-2'`, context `edit`. -- **Steps**: - 1. Call `tracker.getSessionsByInstance('inst-1')`. - 2. Call `tracker.getSessionsByInstance('inst-2')`. -- **Expected result**: First call returns 3 sessions (edit, client, server). Second call returns 1 session (edit). -- **Automation**: vitest. - ---- - -- **Test name**: `SessionTracker.listInstances returns unique instance IDs` -- **Priority**: P0 -- **Type**: unit -- **Setup**: Create a `SessionTracker` instance. Add sessions for `inst-1` (3 contexts) and `inst-2` (1 context). -- **Steps**: - 1. Call `tracker.listInstances()`. -- **Expected result**: Returns `['inst-1', 'inst-2']` (or equivalent unordered set). No duplicates. -- **Automation**: vitest. - ---- - -- **Test name**: `SessionTracker removes instance group when last context is removed` -- **Priority**: P0 -- **Type**: unit -- **Setup**: Create a `SessionTracker` instance. Add 2 sessions for `instanceId: 'inst-1'` with contexts `edit` and `server`. -- **Steps**: - 1. Call `tracker.removeSession` for the `edit` session. - 2. Call `tracker.listInstances()` -- verify `inst-1` is still listed. - 3. Call `tracker.removeSession` for the `server` session. - 4. Call `tracker.listInstances()` -- verify `inst-1` is no longer listed. - 5. Call `tracker.getSessionsByInstance('inst-1')`. -- **Expected result**: After removing the last context, `listInstances` no longer includes `inst-1`. `getSessionsByInstance` returns an empty array (or undefined). -- **Automation**: vitest. - -#### 1.2.2 BridgeConnection session tracking - -> **Note**: There is no `SessionRegistry` class. Session tracking is done in-memory by the bridge host via `BridgeConnection`. Plugin connections on the `/plugin` WebSocket path register sessions; disconnections remove them. Each session carries an `origin` field (`'user'` or `'managed'`). - -- **Test name**: `BridgeConnection.listSessionsAsync returns connected plugin sessions` -- **Priority**: P0 -- **Type**: unit -- **Setup**: Create a `BridgeConnection` (host mode) on a test port. Connect a mock plugin WebSocket that sends a `register` message. -- **Steps**: - 1. Wait for the plugin to register. - 2. Call `connection.listSessionsAsync()`. -- **Expected result**: List contains one entry with matching `sessionId`, `placeName`, `placeFile`, `state`, `pluginVersion`, `capabilities`, `connectedAt`, and `origin` fields. -- **Automation**: vitest. - ---- - -- **Test name**: `BridgeConnection tracks session origin as 'user' for self-connecting plugins` -- **Priority**: P0 -- **Type**: unit -- **Setup**: Create a `BridgeConnection` (host mode). Connect a mock plugin that discovers the host on its own (no prior launch request). -- **Steps**: - 1. Wait for plugin registration. - 2. Call `connection.listSessionsAsync()`. -- **Expected result**: The session's `origin` field is `'user'`. -- **Automation**: vitest. - ---- - -- **Test name**: `BridgeConnection tracks session origin as 'managed' for launched sessions` -- **Priority**: P0 -- **Type**: unit -- **Setup**: Create a `BridgeConnection` (host mode). Launch Studio via the connection (temporary plugin injection path), then connect the mock plugin. -- **Steps**: - 1. Trigger a Studio launch through the connection. - 2. Connect a mock plugin with the expected session ID. - 3. Call `connection.listSessionsAsync()`. -- **Expected result**: The session's `origin` field is `'managed'`. -- **Automation**: vitest. - ---- - -- **Test name**: `BridgeConnection.getSession returns a session by ID` -- **Priority**: P0 -- **Type**: unit -- **Setup**: Connect a mock plugin. -- **Steps**: - 1. Call `connection.getSession('abc')`. -- **Expected result**: Returns the `BridgeSession`. -- **Automation**: vitest. - ---- - -- **Test name**: `BridgeConnection.getSession returns undefined for unknown ID` -- **Priority**: P0 -- **Type**: unit -- **Setup**: No plugins connected. -- **Steps**: - 1. Call `connection.getSession('nonexistent')`. -- **Expected result**: Returns `undefined`. -- **Automation**: vitest. - ---- - -- **Test name**: `BridgeConnection removes session when plugin disconnects` -- **Priority**: P0 -- **Type**: unit -- **Setup**: Connect a mock plugin, then close its WebSocket. -- **Steps**: - 1. Call `connection.listSessionsAsync()` after disconnect. -- **Expected result**: List is empty. -- **Automation**: vitest. - ---- - -- **Test name**: `BridgeConnection handles multiple concurrent sessions` -- **Priority**: P1 -- **Type**: unit -- **Setup**: Connect three mock plugins with different session IDs. -- **Steps**: - 1. Call `connection.listSessionsAsync()`. - 2. Disconnect one plugin. - 3. Call `connection.listSessionsAsync()` again. -- **Expected result**: First call returns 3 sessions. Second call returns 2 sessions. -- **Automation**: vitest. - ---- - -#### 1.2.3 Multi-context session tracking (instance grouping) - -- **Test name**: `BridgeConnection groups sessions by instanceId across contexts` -- **Priority**: P0 -- **Type**: unit -- **Setup**: Create a `BridgeConnection` (host mode) on a test port. Connect 3 mock plugins sharing the same `instanceId` but with different `context` values (`edit`, `client`, `server`). -- **Steps**: - 1. Wait for all 3 plugins to register. - 2. Call `connection.listSessionsAsync()`. -- **Expected result**: List contains 3 entries, all with the same `instanceId` but different `context` values. Each session has a unique `sessionId`. -- **Automation**: vitest. - ---- - -- **Test name**: `BridgeConnection treats (instanceId, context) as the unique session key` -- **Priority**: P0 -- **Type**: unit -- **Setup**: Create a `BridgeConnection` (host mode). Connect a mock plugin with `instanceId: 'inst-1'` and `context: 'edit'`. -- **Steps**: - 1. Connect a second mock plugin with the same `instanceId: 'inst-1'` and `context: 'edit'` (duplicate). - 2. Call `connection.listSessionsAsync()`. -- **Expected result**: The second registration replaces the first. List contains 1 session (not 2) for that `(instanceId, context)` pair. -- **Automation**: vitest. - ---- - -- **Test name**: `BridgeConnection register message includes context, placeId, and gameId` -- **Priority**: P0 -- **Type**: unit -- **Setup**: Create a `BridgeConnection` (host mode). Connect a mock plugin sending a `register` message with `instanceId: 'inst-1'`, `context: 'server'`, `placeId: 123`, `gameId: 456`. -- **Steps**: - 1. Wait for plugin to register. - 2. Call `connection.listSessionsAsync()`. -- **Expected result**: The session has `context: 'server'`, `placeId: 123`, `gameId: 456`. -- **Automation**: vitest. - ---- - -- **Test name**: `BridgeConnection removes only the disconnected context when one plugin in an instance group disconnects` -- **Priority**: P0 -- **Type**: unit -- **Setup**: Connect 3 mock plugins sharing `instanceId: 'inst-1'` with contexts `edit`, `client`, `server`. -- **Steps**: - 1. Disconnect the `client` context plugin. - 2. Call `connection.listSessionsAsync()`. -- **Expected result**: List contains 2 sessions (`edit` and `server` for `inst-1`). The `client` session is gone. -- **Automation**: vitest. - ---- - -- **Test name**: `BridgeConnection emits session events for each context independently during Play mode` -- **Priority**: P1 -- **Type**: unit -- **Setup**: Create `BridgeConnection` (host mode). Subscribe to `onSessionConnected` and `onSessionDisconnected` events. -- **Steps**: - 1. Connect 3 mock plugins with the same `instanceId` but different contexts (simulating Play mode). - 2. Count `onSessionConnected` events. - 3. Disconnect all 3. - 4. Count `onSessionDisconnected` events. -- **Expected result**: 3 `onSessionConnected` events fired (one per context). 3 `onSessionDisconnected` events fired. -- **Automation**: vitest. - ---- - -- **Test name**: `BridgeConnection handles Play mode enter/exit lifecycle` -- **Priority**: P1 -- **Type**: unit -- **Setup**: Create `BridgeConnection` (host mode). Connect a mock plugin with `instanceId: 'inst-1'`, `context: 'edit'`. -- **Steps**: - 1. Verify 1 session (edit). - 2. Connect 2 additional mock plugins with `instanceId: 'inst-1'`, contexts `client` and `server` (simulating entering Play mode). - 3. Verify 3 sessions. - 4. Disconnect the `client` and `server` mock plugins (simulating exiting Play mode). - 5. Call `connection.listSessionsAsync()`. -- **Expected result**: After step 5, 1 session remains (the `edit` context). -- **Automation**: vitest. - -### 1.2.4 BridgeConnection subtask tests (Tasks 1.3d1-1.3d4) - -> **Note**: Task 1.3d has been split into 5 subtasks. The first 4 are agent-assignable with concrete test specifications. Task 1.3d5 (barrel export review) is a review checkpoint with no automated tests beyond import verification -- a review agent verifies the export surface against the tech spec. - -#### 1.2.4.1 Role detection (Task 1.3d1) - -- **Test name**: `BridgeConnection.connectAsync becomes host on unused port` -- **Priority**: P0 -- **Type**: unit -- **Setup**: Choose an ephemeral port known to be free. -- **Steps**: - 1. Call `BridgeConnection.connectAsync({ port })`. - 2. Check `connection.role`. - 3. Check `connection.isConnected`. -- **Expected result**: `role === 'host'`, `isConnected === true`. -- **Automation**: vitest. - ---- - -- **Test name**: `Two concurrent connectAsync calls: first becomes host, second becomes client` -- **Priority**: P0 -- **Type**: integration -- **Setup**: Choose an ephemeral port. -- **Steps**: - 1. Call `BridgeConnection.connectAsync({ port })` twice concurrently (start both, await both). - 2. Check roles of both connections. -- **Expected result**: Exactly one has `role === 'host'`, the other has `role === 'client'`. Both have `isConnected === true`. -- **Automation**: vitest. - ---- - -- **Test name**: `disconnectAsync sets isConnected to false` -- **Priority**: P0 -- **Type**: unit -- **Setup**: Create a `BridgeConnection`. -- **Steps**: - 1. Call `disconnectAsync()`. - 2. Check `isConnected`. -- **Expected result**: `isConnected === false`. -- **Automation**: vitest. - ---- - -- **Test name**: `Environment detection: bind success returns host role` -- **Priority**: P0 -- **Type**: unit -- **Setup**: Mock port bind to succeed. -- **Steps**: - 1. Call `detectRoleAsync({ port })`. -- **Expected result**: Returns `{ role: 'host' }`. -- **Automation**: vitest. - ---- - -- **Test name**: `Environment detection: EADDRINUSE with healthy host returns client role` -- **Priority**: P0 -- **Type**: unit -- **Setup**: Mock port bind to fail with EADDRINUSE. Mock health check to succeed. -- **Steps**: - 1. Call `detectRoleAsync({ port })`. -- **Expected result**: Returns `{ role: 'client' }`. -- **Automation**: vitest. - ---- - -- **Test name**: `Environment detection: EADDRINUSE with stale host retries and becomes host` -- **Priority**: P0 -- **Type**: unit -- **Setup**: Mock port bind to fail with EADDRINUSE. Mock health check to fail. Mock second bind attempt to succeed. -- **Steps**: - 1. Call `detectRoleAsync({ port })`. -- **Expected result**: Returns `{ role: 'host' }` after retry. -- **Automation**: vitest. - ---- - -- **Test name**: `Environment detection: remoteHost option skips bind and returns client` -- **Priority**: P0 -- **Type**: unit -- **Setup**: None. -- **Steps**: - 1. Call `detectRoleAsync({ port: 38741, remoteHost: 'localhost:38741' })`. -- **Expected result**: Returns `{ role: 'client' }` without attempting port bind. -- **Automation**: vitest. - -#### 1.2.4.2 Session query methods (Task 1.3d2) - -- **Test name**: `BridgeConnection.listSessions returns connected plugin sessions (host mode)` -- **Priority**: P0 -- **Type**: integration -- **Setup**: Create `BridgeConnection` (host mode) on ephemeral port. Connect a mock plugin that sends `register`. -- **Steps**: - 1. Wait for plugin to register. - 2. Call `connection.listSessions()`. -- **Expected result**: List contains one entry with matching session metadata. -- **Automation**: vitest. - ---- - -- **Test name**: `BridgeConnection.listInstances groups sessions by instanceId` -- **Priority**: P0 -- **Type**: integration -- **Setup**: Create `BridgeConnection` (host mode). Connect 3 mock plugins sharing `instanceId` with different contexts (edit, client, server). -- **Steps**: - 1. Wait for all plugins to register. - 2. Call `connection.listInstances()`. -- **Expected result**: Returns 1 instance with 3 contexts. -- **Automation**: vitest. - ---- - -- **Test name**: `BridgeConnection.listSessions works through client mode (forwarded)` -- **Priority**: P0 -- **Type**: integration -- **Setup**: Create host + client connections on same port. Connect a mock plugin to the host. -- **Steps**: - 1. Call `listSessions()` on the client connection. -- **Expected result**: Client sees the same session as the host. -- **Automation**: vitest. - ---- - -- **Test name**: `BridgeConnection.getSession returns BridgeSession or undefined` -- **Priority**: P0 -- **Type**: unit -- **Setup**: Create `BridgeConnection` with one mock plugin connected. -- **Steps**: - 1. Call `getSession(knownId)` -> returns `BridgeSession`. - 2. Call `getSession('unknown')` -> returns `undefined`. -- **Expected result**: Known ID returns session, unknown returns `undefined`. -- **Automation**: vitest. - -#### 1.2.4.3 Session resolution (Task 1.3d3) - -- **Test name**: `resolveSession with 0 sessions throws error` -- **Priority**: P0 -- **Type**: unit -- **Setup**: Create `BridgeConnection` with no plugins connected. -- **Steps**: - 1. Call `resolveSession()`. -- **Expected result**: Throws with message containing "No sessions connected". -- **Automation**: vitest. - ---- - -- **Test name**: `resolveSession with 1 session returns it automatically` -- **Priority**: P0 -- **Type**: unit -- **Setup**: Create `BridgeConnection` with one mock plugin. -- **Steps**: - 1. Call `resolveSession()` with no arguments. -- **Expected result**: Returns the single session. -- **Automation**: vitest. - ---- - -- **Test name**: `resolveSession with N instances from different instanceIds throws with list` -- **Priority**: P0 -- **Type**: unit -- **Setup**: Create `BridgeConnection` with 2 mock plugins from different `instanceId` values. -- **Steps**: - 1. Call `resolveSession()` with no arguments. -- **Expected result**: Throws with message containing both instance IDs and instructions to use `--session` or `--instance`. -- **Automation**: vitest. - ---- - -- **Test name**: `resolveSession with explicit sessionId returns that session` -- **Priority**: P0 -- **Type**: unit -- **Setup**: Create `BridgeConnection` with multiple mock plugins. -- **Steps**: - 1. Call `resolveSession('known-id')`. -- **Expected result**: Returns the session with matching ID. -- **Automation**: vitest. - ---- - -- **Test name**: `resolveSession with unknown sessionId throws` -- **Priority**: P0 -- **Type**: unit -- **Setup**: Create `BridgeConnection` with one mock plugin. -- **Steps**: - 1. Call `resolveSession('unknown-id')`. -- **Expected result**: Throws with "Session 'unknown-id' not found". -- **Automation**: vitest. - ---- - -- **Test name**: `resolveSession with 1 instance and 3 contexts returns Edit by default` -- **Priority**: P0 -- **Type**: unit -- **Setup**: Create `BridgeConnection` with 3 mock plugins sharing `instanceId`, contexts: edit, client, server. -- **Steps**: - 1. Call `resolveSession()` with no arguments. -- **Expected result**: Returns the Edit context session. -- **Automation**: vitest. - ---- - -- **Test name**: `resolveSession with 1 instance and context arg returns matching context` -- **Priority**: P0 -- **Type**: unit -- **Setup**: Same as above (3 contexts). -- **Steps**: - 1. Call `resolveSession(undefined, 'server')`. -- **Expected result**: Returns the server context session. -- **Automation**: vitest. - ---- - -- **Test name**: `resolveSession with instanceId and context returns matching session` -- **Priority**: P0 -- **Type**: unit -- **Setup**: Create `BridgeConnection` with 2 instances, each with 2 contexts. -- **Steps**: - 1. Call `resolveSession(undefined, 'server', 'inst-1')`. -- **Expected result**: Returns the server context for `inst-1`. -- **Automation**: vitest. - -#### 1.2.4.4 Async wait and events (Task 1.3d4) - -- **Test name**: `waitForSession resolves when plugin connects after call` -- **Priority**: P0 -- **Type**: integration -- **Setup**: Create `BridgeConnection` (host mode) with no plugins. -- **Steps**: - 1. Call `waitForSession()` (do not await yet). - 2. Connect a mock plugin. - 3. Await the promise. -- **Expected result**: Promise resolves with the session. -- **Automation**: vitest. - ---- - -- **Test name**: `waitForSession resolves immediately when session already exists` -- **Priority**: P0 -- **Type**: integration -- **Setup**: Create `BridgeConnection` with one mock plugin already connected. -- **Steps**: - 1. Call `await waitForSession()`. -- **Expected result**: Resolves immediately with the session. -- **Automation**: vitest. - ---- - -- **Test name**: `waitForSession rejects on timeout` -- **Priority**: P0 -- **Type**: unit -- **Setup**: Create `BridgeConnection` with no plugins. Use `vi.useFakeTimers()` or a short real timeout. -- **Steps**: - 1. Call `waitForSession(500)`. - 2. Advance timers or wait 500ms. -- **Expected result**: Rejects with timeout error containing "timed out". -- **Automation**: vitest. - ---- - -- **Test name**: `session-connected event fires when plugin registers` -- **Priority**: P0 -- **Type**: integration -- **Setup**: Create `BridgeConnection` (host mode). Subscribe to `session-connected` event. -- **Steps**: - 1. Connect a mock plugin. - 2. Check event listener. -- **Expected result**: Event fires with `BridgeSession` argument. -- **Automation**: vitest. - ---- - -- **Test name**: `session-disconnected event fires when plugin disconnects` -- **Priority**: P0 -- **Type**: integration -- **Setup**: Create `BridgeConnection` (host mode). Connect a mock plugin. Subscribe to `session-disconnected` event. -- **Steps**: - 1. Disconnect the mock plugin. - 2. Check event listener. -- **Expected result**: Event fires with the session ID. -- **Automation**: vitest. - ---- - -- **Test name**: `instance-connected event fires when first context of an instance connects` -- **Priority**: P1 -- **Type**: integration -- **Setup**: Create `BridgeConnection` (host mode). Subscribe to `instance-connected` event. -- **Steps**: - 1. Connect a mock plugin with `instanceId: 'inst-1'`, `context: 'edit'`. - 2. Check event listener. -- **Expected result**: Event fires with `InstanceInfo` containing `instanceId: 'inst-1'`. -- **Automation**: vitest. - ---- - -- **Test name**: `instance-disconnected event fires when last context of an instance disconnects` -- **Priority**: P1 -- **Type**: integration -- **Setup**: Create `BridgeConnection` (host mode). Connect 2 mock plugins with same `instanceId`, different contexts. Subscribe to `instance-disconnected` event. -- **Steps**: - 1. Disconnect both mock plugins. - 2. Check event listener. -- **Expected result**: Event fires once (after the last context disconnects), with the `instanceId`. -- **Automation**: vitest. - ---- - -### 1.3 Pending Request Map - -Tests for `src/server/pending-request-map.ts`. New file `src/server/pending-request-map.test.ts`. - -- **Test name**: `PendingRequestMap resolves a request on resolveRequest` -- **Priority**: P0 -- **Type**: unit -- **Setup**: None. -- **Steps**: - 1. Call `map.addRequest('req-001', 5000)` to get a promise. - 2. Call `map.resolveRequest('req-001', { state: 'Edit' })`. - 3. Await the promise. -- **Expected result**: Promise resolves with `{ state: 'Edit' }`. -- **Automation**: vitest. - ---- - -- **Test name**: `PendingRequestMap rejects a request on rejectRequest` -- **Priority**: P0 -- **Type**: unit -- **Setup**: None. -- **Steps**: - 1. Call `map.addRequest('req-002', 5000)`. - 2. Call `map.rejectRequest('req-002', new Error('Plugin error'))`. - 3. Await the promise. -- **Expected result**: Promise rejects with `Error('Plugin error')`. -- **Automation**: vitest. - ---- - -- **Test name**: `PendingRequestMap rejects on timeout` -- **Priority**: P0 -- **Type**: unit -- **Setup**: Use `vi.useFakeTimers()`. -- **Steps**: - 1. Call `map.addRequest('req-003', 100)`. - 2. Advance timers by 100ms. - 3. Await the promise. -- **Expected result**: Promise rejects with a timeout error containing the request ID. -- **Automation**: vitest with fake timers. - ---- - -- **Test name**: `PendingRequestMap cancelAll rejects all pending requests` -- **Priority**: P0 -- **Type**: unit -- **Setup**: None. -- **Steps**: - 1. Add three requests. - 2. Call `map.cancelAll()`. - 3. Await all three promises. -- **Expected result**: All three reject with a cancellation error. -- **Automation**: vitest. - ---- - -- **Test name**: `PendingRequestMap resolveRequest for unknown ID is a no-op` -- **Priority**: P0 -- **Type**: unit -- **Setup**: None. -- **Steps**: - 1. Call `map.resolveRequest('nonexistent', {})` with no pending requests. -- **Expected result**: No error thrown. No side effects. -- **Automation**: vitest. - ---- - -- **Test name**: `PendingRequestMap resolveRequest after timeout is a no-op` -- **Priority**: P1 -- **Type**: unit -- **Setup**: Use `vi.useFakeTimers()`. -- **Steps**: - 1. Add a request with 100ms timeout. - 2. Advance timers by 100ms (triggers timeout). - 3. Catch the rejection. - 4. Call `resolveRequest` with the same ID. -- **Expected result**: No error thrown on the late resolve. The original rejection stands. -- **Automation**: vitest with fake timers. - ---- - -- **Test name**: `PendingRequestMap handles duplicate request ID by replacing` -- **Priority**: P1 -- **Type**: unit -- **Setup**: None. -- **Steps**: - 1. Add a request with ID `'req-dup'`. - 2. Add a second request with the same ID `'req-dup'`. - 3. Resolve `'req-dup'`. - 4. Verify only the second promise resolves. -- **Expected result**: The first request's promise is rejected (or replaced). The second is resolved. -- **Automation**: vitest. - ---- - -- **Test name**: `PendingRequestMap handles concurrent requests with different IDs` -- **Priority**: P0 -- **Type**: unit -- **Setup**: None. -- **Steps**: - 1. Add `req-a`, `req-b`, `req-c` simultaneously. - 2. Resolve `req-b` first, then `req-a`, then `req-c`. - 3. Verify each promise resolves with its own value. -- **Expected result**: Each promise resolves independently with its corresponding value. -- **Automation**: vitest. - -### 1.4 Bridge Host Failover - -Tests for `src/bridge/internal/hand-off.ts`, `bridge-host.ts`, and `bridge-client.ts` failover behavior. Full spec: `studio-bridge/plans/tech-specs/08-host-failover.md`. - -#### 1.4.1 Hand-off state machine unit tests - -- **Test name**: `HandOff state machine transitions from connected to taking-over on host disconnect` -- **Priority**: P0 -- **Type**: unit -- **Setup**: Create a `HandOff` instance in `connected` state. -- **Steps**: - 1. Emit `host-disconnected` event. - 2. Check state. -- **Expected result**: State transitions to `taking-over`. -- **Automation**: vitest. - ---- - -- **Test name**: `HandOff state machine transitions from taking-over to promoted on successful port bind` -- **Priority**: P0 -- **Type**: unit -- **Setup**: Create a `HandOff` instance in `taking-over` state. -- **Steps**: - 1. Simulate successful port bind. - 2. Check state. -- **Expected result**: State transitions to `promoted`. The instance emits `promoted` event. -- **Automation**: vitest with mocked port bind. - ---- - -- **Test name**: `HandOff state machine transitions from taking-over to reconnected-as-client on failed port bind` -- **Priority**: P0 -- **Type**: unit -- **Setup**: Create a `HandOff` instance in `taking-over` state. -- **Steps**: - 1. Simulate failed port bind (EADDRINUSE -- another client won). - 2. Check state. -- **Expected result**: State transitions to `reconnected-as-client`. The instance connects to the new host. -- **Automation**: vitest with mocked port bind. - ---- - -- **Test name**: `HandOff applies random jitter between 0-500ms before attempting port bind` -- **Priority**: P0 -- **Type**: unit -- **Setup**: Use `vi.useFakeTimers()`. Create `HandOff` instance. -- **Steps**: - 1. Emit `host-disconnected`. - 2. Check that port bind is NOT attempted immediately. - 3. Advance timers by 500ms. - 4. Check that port bind has been attempted. -- **Expected result**: Bind attempt occurs after the jitter delay, not immediately. -- **Automation**: vitest with fake timers. - ---- - -- **Test name**: `HandOff graceful path: host sends hostTransfer before disconnecting` -- **Priority**: P0 -- **Type**: unit -- **Setup**: Mock host connection. -- **Steps**: - 1. Receive `hostTransfer` message from host. - 2. Host WebSocket closes. - 3. Check state transitions. -- **Expected result**: State goes directly to `taking-over` (no jitter on graceful path -- the host explicitly told us to take over). -- **Automation**: vitest. - ---- - -- **Test name**: `HandOff rejects invalid state transitions` -- **Priority**: P1 -- **Type**: unit -- **Setup**: None. -- **Steps**: - 1. Attempt to transition from `promoted` to `taking-over`. - 2. Attempt to transition from `connected` to `promoted` (skipping `taking-over`). -- **Expected result**: Both transitions throw or are no-ops. State machine is strictly sequential. -- **Automation**: vitest. - -#### 1.4.2 Inflight request handling during failover - -- **Test name**: `Inflight action rejects with SessionDisconnectedError when host dies` -- **Priority**: P0 -- **Type**: integration -- **Setup**: Start bridge host + bridge client + mock plugin. Use `vi.useFakeTimers()`. Client sends an action through the host. -- **Steps**: - 1. Client calls `session.queryStateAsync()` (action is in-flight, mock plugin does not respond). - 2. Kill the host (close transport server). - 3. Await the action promise. -- **Expected result**: Promise rejects with `SessionDisconnectedError`, NOT `ActionTimeoutError`. The rejection occurs on the next microtask flush after host death detection. -- **Automation**: vitest with `vi.useFakeTimers()`. Restore with `vi.useRealTimers()` in `afterEach`. - ---- - -- **Test name**: `Inflight action rejects with SessionDisconnectedError when plugin disconnects during host death` -- **Priority**: P0 -- **Type**: integration -- **Setup**: Start bridge host (process is the host), connect mock plugin, start an action. -- **Steps**: - 1. Call `session.execAsync(...)` (mock plugin delays response). - 2. Close the mock plugin's WebSocket. - 3. Await the action promise. -- **Expected result**: Promise rejects with `SessionDisconnectedError`. -- **Automation**: vitest. - ---- - -- **Test name**: `PendingRequestMap.cancelAll is called on host disconnect, rejecting all inflight requests` -- **Priority**: P0 -- **Type**: unit -- **Setup**: Create `PendingRequestMap` with 3 pending requests. -- **Steps**: - 1. Call `cancelAll()`. - 2. Await all 3 promises. -- **Expected result**: All 3 reject with cancellation error. No requests are left pending. -- **Automation**: vitest. - -#### 1.4.3 Failover integration tests - -- **Test name**: `Graceful shutdown: host disconnects, client takes over` -- **Priority**: P0 -- **Type**: integration -- **Setup**: Start bridge host on ephemeral port. Connect bridge client. Connect mock plugin. Use `vi.useFakeTimers()`. -- **Steps**: - 1. Host calls `disconnectAsync()`. - 2. `vi.advanceTimersByTime(2000)` to advance past the takeover window. - 3. Verify client `role === 'host'`. - 4. `vi.advanceTimersByTime(5000)` to advance past plugin reconnection window. - 5. Verify mock plugin reconnects to new host. - 6. Send an action through the new host. -- **Expected result**: Client becomes host after advancing timers. Plugin reconnects. Action succeeds through the new host. -- **Automation**: vitest with `vi.useFakeTimers()` and `vi.advanceTimersByTime()`. Restore with `vi.useRealTimers()` in `afterEach`. - ---- - -- **Test name**: `Hard kill: host dies without notification, client takes over` -- **Priority**: P0 -- **Type**: integration -- **Setup**: Start bridge host. Connect bridge client. Connect mock plugin. Use `vi.useFakeTimers()`. -- **Steps**: - 1. Close the host's transport server directly (simulate kill -9). - 2. `vi.advanceTimersByTime(5000)` to advance past the detection + takeover + jitter window. - 3. Verify client `role === 'host'`. - 4. `vi.advanceTimersByTime(5000)` to advance past plugin reconnection window. - 5. Verify mock plugin reconnects. - 6. Send an action through the new host. -- **Expected result**: Client becomes host after advancing timers. Plugin reconnects. Action succeeds. -- **Automation**: vitest with `vi.useFakeTimers()` and `vi.advanceTimersByTime()`. Restore with `vi.useRealTimers()` in `afterEach`. - ---- - -- **Test name**: `Plugin reconnects to new host after graceful failover` -- **Priority**: P0 -- **Type**: integration -- **Setup**: Start host. Connect mock plugin. Connect client. Use `vi.useFakeTimers()`. -- **Steps**: - 1. Host calls `disconnectAsync()`. - 2. `vi.advanceTimersByTime(2000)` -- client becomes new host. - 3. `vi.advanceTimersByTime(5000)` -- advance past plugin reconnection window. - 4. Verify mock plugin has sent `register` to new host. -- **Expected result**: Plugin sends `register` to new host after timers are advanced. -- **Automation**: vitest with `vi.useFakeTimers()` and `vi.advanceTimersByTime()`. Restore with `vi.useRealTimers()` in `afterEach`. - ---- - -- **Test name**: `TIME_WAIT recovery: port rebind succeeds with SO_REUSEADDR` -- **Priority**: P0 -- **Type**: integration -- **Setup**: Use `vi.useFakeTimers()`. -- **Steps**: - 1. Start a transport server on ephemeral port P with `reuseAddr: true`. - 2. Close the server. - 3. Immediately start a new transport server on the same port P. - 4. `vi.advanceTimersByTime(1000)` to advance past any internal retry delays. -- **Expected result**: Bind succeeds. No `EADDRINUSE` error. -- **Automation**: vitest with `vi.useFakeTimers()`. Restore with `vi.useRealTimers()` in `afterEach`. - ---- - -- **Test name**: `Rapid restart: kill host + new command works after timer advance` -- **Priority**: P0 -- **Type**: integration -- **Setup**: Start host. Connect mock plugin. Use `vi.useFakeTimers()`. -- **Steps**: - 1. Close the host. - 2. `vi.advanceTimersByTime(1000)` -- advance past any internal retry delay. - 3. Create a new `BridgeConnection` (which should become the new host). - 4. `vi.advanceTimersByTime(5000)` -- advance past plugin reconnection window. - 5. Verify mock plugin reconnects. - 6. Send an action through the new host. -- **Expected result**: New host accepts connections. Plugin reconnects. Action succeeds. -- **Automation**: vitest with `vi.useFakeTimers()` and `vi.advanceTimersByTime()`. Restore with `vi.useRealTimers()` in `afterEach`. - ---- - -- **Test name**: `Multiple clients: exactly one becomes host, others reconnect as clients` -- **Priority**: P0 -- **Type**: integration -- **Setup**: Start host. Connect 3 bridge clients. Connect mock plugin. -- **Steps**: - 1. Kill the host. - 2. Wait for all clients to complete failover. - 3. Count how many clients have `role === 'host'`. - 4. Count how many clients have `role === 'client'`. -- **Expected result**: Exactly 1 host. Exactly 2 clients. All 3 are connected. Plugin reconnects to the host. -- **Automation**: vitest. - ---- - -- **Test name**: `No clients connected: next CLI invocation becomes host, plugin reconnects` -- **Priority**: P1 -- **Type**: integration -- **Setup**: Start host. Connect mock plugin. No bridge clients. -- **Steps**: - 1. Stop the host. - 2. Start a new `BridgeConnection`. - 3. Verify it becomes host. - 4. Wait for mock plugin to reconnect. -- **Expected result**: New connection becomes host. Plugin reconnects. -- **Automation**: vitest. - ---- - -- **Test name**: `Jitter prevents thundering herd: bind attempts are spread over 0-500ms` -- **Priority**: P1 -- **Type**: integration -- **Setup**: Start host. Connect 5 clients. Use `vi.useFakeTimers()`. Spy on the port-bind function to record when each client attempts to bind. -- **Steps**: - 1. Kill the host. - 2. `vi.advanceTimersByTime(100)` -- verify not all clients have attempted bind yet. - 3. `vi.advanceTimersByTime(500)` -- verify all clients have attempted bind. - 4. Check that bind attempts were spread across different timer ticks (not all at tick 0). -- **Expected result**: Bind attempts are spread across multiple timer advances (indicating jitter is working). No more than 1 client succeeds in binding. -- **Automation**: vitest with `vi.useFakeTimers()` and bind-function spy. Restore with `vi.useRealTimers()` in `afterEach`. - -#### 1.4.3.1 Multi-context failover integration tests - -- **Test name**: `Multi-context failover: all 3 contexts re-register after host death` -- **Priority**: P0 -- **Type**: integration -- **Setup**: Start bridge host on ephemeral port. Connect 3 mock plugins sharing `instanceId: 'inst-1'` with contexts `edit`, `client`, `server`. Connect a bridge client. -- **Steps**: - 1. Kill the host. - 2. Wait for client to become new host. - 3. Wait for all 3 mock plugins to reconnect and re-register with the new host. - 4. Call `listSessions()` on the new host. -- **Expected result**: 3 sessions returned, all with `instanceId: 'inst-1'` and distinct contexts. All sessions are functional (actions can be dispatched to each). -- **Automation**: vitest with ephemeral ports. - ---- - -- **Test name**: `Multi-context failover: partial recovery is visible during reconnection window` -- **Priority**: P0 -- **Type**: integration -- **Setup**: Start host. Connect 3 mock plugins (edit/client/server) sharing an instanceId. Connect a client. Configure the `client` context mock plugin with a delayed reconnection (5s extra). -- **Steps**: - 1. Kill the host. - 2. Client becomes new host. - 3. Wait 3 seconds (edit and server reconnect, client has not yet). - 4. Call `listSessions()`. - 5. Wait for client context to reconnect. - 6. Call `listSessions()` again. -- **Expected result**: Step 4 returns 2 sessions (edit, server). Step 6 returns 3 sessions (edit, client, server). -- **Automation**: vitest with configurable reconnection delays. - ---- - -- **Test name**: `Multi-context failover: (instanceId, context) correlation is correct across host death` -- **Priority**: P0 -- **Type**: integration -- **Setup**: Start host. Connect 3 mock plugins with `instanceId: 'inst-1'` (edit/client/server) and 1 mock plugin with `instanceId: 'inst-2'` (edit only). Connect a client. -- **Steps**: - 1. Kill the host. - 2. Client becomes new host. - 3. Wait for all 4 plugins to reconnect. - 4. Call `listSessions()`. -- **Expected result**: 4 sessions total. 3 sessions with `instanceId: 'inst-1'` (contexts edit, client, server). 1 session with `instanceId: 'inst-2'` (context edit). No sessions are cross-matched between instances. -- **Automation**: vitest. - -#### 1.4.4 Failover observability tests - -- **Test name**: `Hand-off state transitions produce debug log messages` -- **Priority**: P1 -- **Type**: unit -- **Setup**: Create a `HandOff` instance with a mock logger. -- **Steps**: - 1. Trigger a host disconnect. - 2. Trigger takeover. - 3. Inspect logged messages. -- **Expected result**: Log messages include: component (`bridge:handoff`), state transition (e.g., `connected -> taking-over`), context (port, jitter value, elapsed time). -- **Automation**: vitest with mock logger. - ---- - -- **Test name**: `Health endpoint includes hostUptime and lastFailoverAt fields` -- **Priority**: P1 -- **Type**: integration -- **Setup**: Start bridge host. Trigger a failover (kill host, client takes over). -- **Steps**: - 1. Query `/health` on the new host. - 2. Inspect response body. -- **Expected result**: Response includes `hostUptime` (number, milliseconds) and `lastFailoverAt` (ISO 8601 string, not null after failover). -- **Automation**: vitest. - ---- - -- **Test name**: `sessions command during failover prints recovery message` -- **Priority**: P1 -- **Type**: unit -- **Setup**: Mock `BridgeConnection` to simulate host-unavailable during recovery. -- **Steps**: - 1. Invoke the sessions command handler. - 2. Capture output. -- **Expected result**: Output contains "Bridge host is recovering. Retry in a few seconds." instead of a generic connection error. -- **Automation**: vitest, capture stdout. - ---- - -- **Test name**: `BridgeConnection.role updates from 'client' to 'host' on promotion` -- **Priority**: P0 -- **Type**: integration -- **Setup**: Start host. Connect client. -- **Steps**: - 1. Verify client `role === 'client'`. - 2. Kill the host. - 3. Wait for client to take over. - 4. Verify client `role === 'host'`. -- **Expected result**: Role transitions from `'client'` to `'host'`. -- **Automation**: vitest. - ---- - -## Phase 1 Gate - -**Criteria**: All existing tests pass unchanged. v2 protocol fully tested. In-memory session tracking tested. PendingRequestMap tested. Server integrates session tracker. **Bridge host failover tested and passing** -- this is a hard gate because all commands in Phases 2-3 depend on the bridge network recovering from host death. - -**Required passing tests**: -1. All existing tests in `web-socket-protocol.test.ts` (unchanged, regression). -2. All existing tests in `web-socket-protocol.smoke.test.ts` (unchanged, regression). -3. All existing tests in `studio-bridge-server.test.ts` (unchanged, regression). -4. All existing tests in `plugin-injector.test.ts` (unchanged, regression). -5. `decodePluginMessage` round-trip for all v2 plugin message types (1.1.1 through 1.1.10). -6. `decodeServerMessage` for all v2 server message types (1.1.11). -7. `encodeMessage` round-trip for all v2 server types (1.1.12). -8. All `PendingRequestMap` tests (1.3). -9. All `SessionTracker` tests (1.2.1). -10. All `BridgeConnection` session tracking tests (1.2.2). -11. In-memory session appears after `startAsync`, is removed after `stopAsync` (2.2 -- see `studio-bridge/plans/execution/validation/02-plugin.md`). -12. v2 handshake via `register` (2.1.1 -- see `studio-bridge/plans/execution/validation/02-plugin.md`). -13. v2 handshake via extended `hello` (2.1.2 -- see `studio-bridge/plans/execution/validation/02-plugin.md`). -14. v1 `hello` still works on v2 server (2.1.3 -- see `studio-bridge/plans/execution/validation/02-plugin.md`). -15. `performActionAsync` sends and resolves (2.1.5 -- see `studio-bridge/plans/execution/validation/02-plugin.md`). -16. `performActionAsync` rejects on timeout (2.1.5 -- see `studio-bridge/plans/execution/validation/02-plugin.md`). -17. `performActionAsync` throws for v1 plugin (2.1.5 -- see `studio-bridge/plans/execution/validation/02-plugin.md`). -18. `performActionAsync` throws for missing capability (2.1.5 -- see `studio-bridge/plans/execution/validation/02-plugin.md`). -19. Concurrent request handling (2.1.6 -- see `studio-bridge/plans/execution/validation/02-plugin.md`). -20. Hand-off state machine transitions -- all unit tests (1.4.1). -21. Inflight request rejection with `SessionDisconnectedError` on host death (1.4.2). -22. Graceful shutdown: client takes over after host disconnect (1.4.3). -23. Hard kill: client takes over after host death (1.4.3). -24. TIME_WAIT recovery: port rebind within 1 second (1.4.3). -25. Rapid restart: kill + new command works after timer advance (1.4.3). -26. Multiple clients: exactly one becomes host (1.4.3). -27. `BridgeConnection.role` updates on promotion (1.4.4). -28. Multi-context session grouping by `(instanceId, context)` (1.2.3). -29. Play mode enter/exit lifecycle -- contexts appear and disappear correctly (1.2.3). -30. Multi-context failover: all 3 contexts re-register after host death (1.4.3.1). -31. Multi-context failover: `(instanceId, context)` correlation correct across host death (1.4.3.1). - -32. **Backward compatibility**: v1-only client (sends `hello` without `protocolVersion`) receives a v1-style `welcome` and can `execute` scripts. v2 server does not send any v2-only message types to a v1 session (1.5). -33. **Backward compatibility**: v2 plugin connecting to a future v3 server receives a clamped `protocolVersion: 2` in `welcome` and continues working with v2 features only (3.4 in `01-protocol.md`). -34. **Plugin version warning**: When `pluginVersion` in `hello`/`register` is older than the server's minimum-supported version, the server logs a warning and includes `pluginUpdateAvailable: true` in `welcome`. The handshake still completes (1.5). - -**Manual verification**: None required for Phase 1. - -**Gate command**: -```bash -cd tools/studio-bridge && npm run test -``` diff --git a/studio-bridge/plans/execution/validation/02-plugin.md b/studio-bridge/plans/execution/validation/02-plugin.md deleted file mode 100644 index b4d5140e44..0000000000 --- a/studio-bridge/plans/execution/validation/02-plugin.md +++ /dev/null @@ -1,460 +0,0 @@ -# Validation: Phase 2 -- Persistent Plugin - -> **Shared test infrastructure**: All tests that connect a mock plugin MUST use the standardized `MockPluginClient` from `shared-test-utilities.md`. Do not create ad-hoc WebSocket mocks. See [shared-test-utilities.md](./shared-test-utilities.md) for the full specification, usage examples, and design decisions. - -Test specifications for persistent plugin integration: server + mock plugin handshake, session lifecycle, health endpoint, plugin discovery, and launch flow. - -**Phase**: 2 (Persistent Plugin) - -**References**: -- Phase plan: `studio-bridge/plans/execution/phases/02-plugin.md` -- Agent prompts: `studio-bridge/plans/execution/agent-prompts/02-plugin.md` -- Tech specs: `studio-bridge/plans/tech-specs/03-persistent-plugin.md`, `studio-bridge/plans/tech-specs/04-action-specs.md` -- Sibling validation: `01-bridge-network.md` (Phase 1), `03-commands.md` (Phase 3) -- Existing tests: `tools/studio-bridge/src/server/web-socket-protocol.test.ts`, `studio-bridge-server.test.ts` - -Base path for source files: `/workspaces/NevermoreEngine/tools/studio-bridge/` - ---- - -## 2. Integration Test Plans - -### 2.1 Server + Mock Plugin - -These tests start a real `StudioBridgeServer` and connect a mock WebSocket client that simulates a v2 plugin. - -#### 2.1.1 v2 handshake via register message - -- **Test name**: `server accepts register message and responds with v2 welcome` -- **Priority**: P0 -- **Type**: integration -- **Setup**: Create `StudioBridgeServer` with mocked external deps. Start server. -- **Steps**: - 1. Connect a WebSocket client to `ws://localhost:{port}/{sessionId}`. - 2. Send `register` message with `protocolVersion: 2`, capabilities, and full payload. - 3. Wait for response. -- **Expected result**: Server sends `welcome` with `protocolVersion: 2` and `capabilities` matching the intersection of plugin and server capabilities. -- **Automation**: vitest, real WebSocket connection, mocked Studio launch. - -#### 2.1.2 v2 handshake via extended hello - -- **Test name**: `server accepts extended hello with protocolVersion and capabilities` -- **Priority**: P0 -- **Type**: integration -- **Setup**: Same as 2.1.1. -- **Steps**: - 1. Connect a WebSocket client. - 2. Send `hello` with `protocolVersion: 2`, `capabilities: ['execute', 'queryState']`, `pluginVersion: '1.0.0'`. - 3. Wait for response. -- **Expected result**: Server sends `welcome` with `protocolVersion: 2` and `capabilities: ['execute', 'queryState']`. -- **Automation**: vitest. - -#### 2.1.3 v1 hello still works on v2 server - -- **Test name**: `server responds with v1 welcome when plugin sends hello without protocolVersion` -- **Priority**: P0 -- **Type**: integration -- **Setup**: Same as 2.1.1. -- **Steps**: - 1. Connect a WebSocket client. - 2. Send v1-style `hello` (no `protocolVersion`, no `capabilities`). - 3. Wait for response. -- **Expected result**: Server sends v1-style `welcome` (no `protocolVersion` field, no `capabilities` field). -- **Automation**: vitest. - -#### 2.1.3.1 Multi-context register messages - -- **Test name**: `server accepts register messages with context field and groups by instanceId` -- **Priority**: P0 -- **Type**: integration -- **Setup**: Create `StudioBridgeServer`. Start server. -- **Steps**: - 1. Connect 3 WebSocket clients, each sending `register` with `instanceId: 'inst-1'` and different `context` values (`edit`, `client`, `server`), plus `placeId: 123` and `gameId: 456`. - 2. Query the server's session list. -- **Expected result**: Server has 3 sessions, all with `instanceId: 'inst-1'`, each with a distinct `context`. All share the same `placeId` and `gameId`. -- **Automation**: vitest, real WebSocket connections. - ---- - -- **Test name**: `server handles Play mode lifecycle: edit session exists, then client/server join, then client/server leave` -- **Priority**: P0 -- **Type**: integration -- **Setup**: Create server. Connect one mock plugin with `context: 'edit'`. -- **Steps**: - 1. Verify 1 session (edit). - 2. Connect 2 more mock plugins with same `instanceId`, contexts `client` and `server`. - 3. Verify 3 sessions. - 4. Disconnect the `client` and `server` plugins (simulating Stop Play). - 5. Verify 1 session remains (edit). -- **Expected result**: Session count tracks Play mode enter/exit correctly. -- **Automation**: vitest. - ---- - -- **Test name**: `server sends welcome with context acknowledgment to each register` -- **Priority**: P1 -- **Type**: integration -- **Setup**: Start server. Connect a mock plugin sending `register` with `context: 'server'`. -- **Steps**: - 1. Wait for `welcome` response. -- **Expected result**: `welcome` message is sent. Server internally records the session's context as `'server'`. -- **Automation**: vitest. - ---- - -- **Test name**: `register message without context field defaults to 'edit'` -- **Priority**: P1 -- **Type**: integration -- **Setup**: Start server. Connect a mock plugin sending `register` without the `context` field (backwards compatibility). -- **Steps**: - 1. Wait for registration. - 2. Query server session list. -- **Expected result**: Session has `context: 'edit'` (default). -- **Automation**: vitest. - -#### 2.1.4 Heartbeat tracking - -- **Test name**: `server updates heartbeat timestamp when heartbeat message arrives` -- **Priority**: P1 -- **Type**: integration -- **Setup**: Establish v2 connection. -- **Steps**: - 1. Send `heartbeat` message with `uptimeMs: 15000`, `state: 'Edit'`, `pendingRequests: 0`. - 2. Check the server's internal heartbeat timestamp (access via test helper or exposed method). -- **Expected result**: Last heartbeat timestamp is updated. -- **Automation**: vitest, access private field or add a test-only getter. - -#### 2.1.5 performActionAsync end-to-end with mock plugin - -- **Test name**: `performActionAsync sends queryState and resolves when mock plugin responds` -- **Priority**: P0 -- **Type**: integration -- **Setup**: Start server, connect v2 mock client. -- **Steps**: - 1. Call `server.performActionAsync({ type: 'queryState', ... })`. - 2. On the mock client, receive the `queryState` message with a `requestId`. - 3. Mock client sends `stateResult` with the same `requestId`. -- **Expected result**: `performActionAsync` resolves with the stateResult payload. -- **Automation**: vitest, real WebSocket. - ---- - -- **Test name**: `performActionAsync rejects when mock plugin sends error` -- **Priority**: P0 -- **Type**: integration -- **Setup**: Start server, connect v2 mock client. -- **Steps**: - 1. Call `server.performActionAsync({ type: 'queryDataModel', ... })`. - 2. Mock client sends `error` with matching `requestId` and code `INSTANCE_NOT_FOUND`. -- **Expected result**: `performActionAsync` rejects with error containing the code and message. -- **Automation**: vitest. - ---- - -- **Test name**: `performActionAsync rejects on timeout when mock plugin does not respond` -- **Priority**: P0 -- **Type**: integration -- **Setup**: Start server, connect v2 mock client. Use `vi.useFakeTimers()`. -- **Steps**: - 1. Call `server.performActionAsync({ type: 'queryState', ... })` with a timeout of 200ms. - 2. Do not respond from mock client. - 3. `vi.advanceTimersByTime(200)` to trigger the timeout. -- **Expected result**: Promise rejects with timeout error. -- **Automation**: vitest with `vi.useFakeTimers()` and `vi.advanceTimersByTime()`. Restore with `vi.useRealTimers()` in `afterEach`. - ---- - -- **Test name**: `performActionAsync throws when v1 plugin is connected` -- **Priority**: P0 -- **Type**: integration -- **Setup**: Start server, connect v1 client (no capabilities). -- **Steps**: - 1. Call `server.performActionAsync({ type: 'queryState', ... })`. -- **Expected result**: Throws immediately with "Plugin does not support v2 actions". -- **Automation**: vitest. - ---- - -- **Test name**: `performActionAsync throws for unsupported capability` -- **Priority**: P0 -- **Type**: integration -- **Setup**: Start server, connect v2 client with `capabilities: ['execute', 'queryState']` (no `captureScreenshot`). -- **Steps**: - 1. Call `server.performActionAsync({ type: 'captureScreenshot', ... })`. -- **Expected result**: Throws immediately with "Plugin does not support capability: captureScreenshot". -- **Automation**: vitest. - -#### 2.1.6 Concurrent requests - -- **Test name**: `server handles concurrent queryState and queryLogs requests` -- **Priority**: P0 -- **Type**: integration -- **Setup**: Start server, connect v2 mock client. -- **Steps**: - 1. Call `server.performActionAsync(queryState)` and `server.performActionAsync(queryLogs)` simultaneously. - 2. From mock client, receive both messages (they will have different `requestId` values). - 3. Respond to `queryLogs` first, then `queryState`. -- **Expected result**: Both promises resolve with their correct responses, regardless of response order. -- **Automation**: vitest. - ---- - -- **Test name**: `server handles execute + queryState concurrent (query returns before execute completes)` -- **Priority**: P1 -- **Type**: integration -- **Setup**: Start server, connect v2 mock client. -- **Steps**: - 1. Send `execute` request. - 2. Send `queryState` request. - 3. Mock client responds with `stateResult` first, then `output` + `scriptComplete`. -- **Expected result**: `queryState` resolves first. `execute` resolves after `scriptComplete`. -- **Automation**: vitest. - -### 2.2 Session Registry + Server Lifecycle - -- **Test name**: `session file appears after startAsync and disappears after stopAsync` -- **Priority**: P0 -- **Type**: integration -- **Setup**: Create `StudioBridgeServer` with custom registry base path (temp dir). -- **Steps**: - 1. Call `server.startAsync()` (with mock plugin connecting). - 2. List the temp registry directory. - 3. Call `server.stopAsync()`. - 4. List the temp registry directory again. -- **Expected result**: Step 2 shows one `.json` file. Step 4 shows zero files. -- **Automation**: vitest, `fs.readdirSync`. - ---- - -- **Test name**: `bridge host removes session when plugin process crashes` -- **Priority**: P0 -- **Type**: integration -- **Setup**: Start a `BridgeConnection` (host mode). Connect a mock plugin, then abruptly close its WebSocket. -- **Steps**: - 1. Call `connection.listSessionsAsync()` after the plugin disconnects. -- **Expected result**: Returns empty (crashed plugin's session is removed from in-memory tracking). -- **Automation**: vitest. - ---- - -- **Test name**: `health endpoint returns correct JSON after startAsync` -- **Priority**: P0 -- **Type**: integration -- **Setup**: Start a `StudioBridgeServer`. -- **Steps**: - 1. Send `GET http://localhost:{port}/health` via `fetch` or `http.get`. - 2. Parse response. -- **Expected result**: Status 200. Body contains `{ status: 'ready', sessionId, port, protocolVersion: 2, serverVersion }`. -- **Automation**: vitest, `node:http` or `fetch`. - ---- - -- **Test name**: `health endpoint returns 404 for non-matching paths` -- **Priority**: P1 -- **Type**: integration -- **Setup**: Start a `StudioBridgeServer`. -- **Steps**: - 1. Send `GET http://localhost:{port}/nonexistent`. -- **Expected result**: Status 404. -- **Automation**: vitest. - -### 2.3 CLI to Server to Mock Plugin to Result - -- **Test name**: `exec command sends script through server to mock plugin and returns output` -- **Priority**: P0 -- **Type**: integration -- **Setup**: Start server, connect mock v2 plugin. Mock plugin auto-responds to `execute` messages. -- **Steps**: - 1. Call the exec command handler with `{ code: 'print("hello")' }`. - 2. Mock plugin receives `execute`, sends `output` with `[{ level: 'Print', body: 'hello' }]`, then `scriptComplete { success: true }`. -- **Expected result**: Exec handler returns `{ success: true }` with logs containing "hello". -- **Automation**: vitest, mock plugin client. - ---- - -- **Test name**: `state command queries mock plugin and formats result` -- **Priority**: P1 -- **Type**: integration -- **Setup**: Start server with connected mock v2 plugin. Mock plugin auto-responds to `queryState`. -- **Steps**: - 1. Call the state command handler. - 2. Mock plugin sends `stateResult { state: 'Play', placeId: 123, placeName: 'Game', gameId: 456 }`. -- **Expected result**: Handler returns structured state result. -- **Automation**: vitest. - ---- - -## 3. End-to-End Test Plans - -### 3.1 Full Launch Flow - -- **Test name**: `launch command starts server, mock plugin discovers and connects via health endpoint` -- **Priority**: P0 -- **Type**: e2e -- **Setup**: Mock Studio launch (no real Studio). Implement a mock plugin client that polls `/health`, then connects via WebSocket and performs v2 handshake. -- **Steps**: - 1. Start `studio-bridge launch` (programmatically). - 2. Mock plugin polls `localhost:{port}/health` and discovers the server. - 3. Mock plugin connects via WebSocket and sends `register`. - 4. Server sends `welcome`. - 5. Verify launch command resolves with session info. -- **Expected result**: Full discovery and handshake cycle completes. Session appears in registry. -- **Automation**: vitest, mock plugin process simulated in same test process. - ---- - -- **Test name**: `full lifecycle: launch, execute, query state, query logs, stop` -- **Priority**: P0 -- **Type**: e2e -- **Setup**: Same as above. -- **Steps**: - 1. Start server. - 2. Mock plugin connects. - 3. Execute `print("hello")` via server API. - 4. Mock plugin responds. - 5. Query state via server API. - 6. Mock plugin responds. - 7. Query logs via server API. - 8. Mock plugin responds. - 9. Stop server. - 10. Verify session removed from registry. -- **Expected result**: Each action resolves correctly. Cleanup is complete. -- **Automation**: vitest. - -### 3.2 Persistent Plugin Discovery - -- **Test name**: `persistent plugin discovery via port scanning` -- **Priority**: P0 -- **Type**: e2e -- **Setup**: Start a WebSocket server with a health endpoint on a random port in the scan range (38741-38760). Simulate the plugin's discovery algorithm. -- **Steps**: - 1. Start the server on a port within the scan range. - 2. Run the discovery algorithm (TypeScript reimplementation of the Luau logic). - 3. Verify it finds the server. -- **Expected result**: Discovery returns the server's session info. -- **Automation**: vitest. TypeScript port of the discovery logic for testing. - ---- - -- **Test name**: `persistent plugin falls back to hello when register gets no response` -- **Priority**: P0 -- **Type**: e2e -- **Setup**: Start a v1-only WebSocket server (ignores `register`, responds to `hello`). Use `vi.useFakeTimers()`. -- **Steps**: - 1. Mock plugin connects and sends `register`. - 2. `vi.advanceTimersByTime(3000)` to trigger the fallback timeout. - 3. Verify mock plugin sends `hello`. - 4. v1 server responds with v1 `welcome`. -- **Expected result**: Plugin detects v1 mode and disables extended features. -- **Automation**: vitest with `vi.useFakeTimers()` and `vi.advanceTimersByTime()`. Restore with `vi.useRealTimers()` in `afterEach`. - ---- - -- **Test name**: `persistent plugin reconnects after WebSocket drops` -- **Priority**: P0 -- **Type**: e2e -- **Setup**: Start server, connect mock plugin. -- **Steps**: - 1. Establish connection. - 2. Server forcibly closes WebSocket. - 3. Mock plugin enters reconnecting state. - 4. After backoff (1s), mock plugin re-discovers server via health endpoint. - 5. Mock plugin reconnects and performs handshake. -- **Expected result**: Second handshake completes. Server accepts the reconnection. -- **Automation**: vitest, simulate disconnect, verify reconnection. - ---- - -- **Test name**: `persistent plugin returns to searching (no backoff) on shutdown message` -- **Priority**: P1 -- **Type**: e2e -- **Setup**: Start server, connect mock plugin. -- **Steps**: - 1. Server sends `shutdown` to plugin. - 2. Plugin disconnects. - 3. Verify plugin enters `searching` state with zero backoff delay. -- **Expected result**: No backoff wait before polling resumes. -- **Automation**: vitest, mock the state machine. - ---- - -- **Test name**: `persistent plugin handles server restart with new session ID` -- **Priority**: P1 -- **Type**: e2e -- **Setup**: Start server A, connect mock plugin. Stop server A. Start server B on same port but different session ID. -- **Steps**: - 1. Establish connection with server A. - 2. Server A stops (WebSocket drops). - 3. Mock plugin enters reconnecting state. - 4. Server B starts on same port. - 5. Mock plugin discovers server B via health check (different session ID). - 6. Mock plugin sends fresh `register` to server B. -- **Expected result**: Plugin connects to new server with new session ID. Old session state is cleared. -- **Automation**: vitest. - -### 3.3 Multi-context Plugin Behavior - -- **Test name**: `server and client plugin instances join existing edit session during Play mode` -- **Priority**: P0 -- **Type**: e2e -- **Setup**: Start server. Connect an edit-context mock plugin (simulating the always-running edit instance). Then simulate entering Play mode by connecting 2 additional mock plugin clients (server, client) sharing the same `instanceId`. -- **Steps**: - 1. Verify edit-context plugin is already connected. - 2. Connect server-context plugin (simulating Play mode creating a new server instance). - 3. Connect client-context plugin (simulating Play mode creating a new client instance). - 4. Each new plugin sends an independent `register` message with its own `context`. - 5. Query server session list. -- **Expected result**: 3 distinct sessions grouped under one `instanceId`. Each session can independently handle actions. The edit session was never interrupted. -- **Automation**: vitest, 3 mock plugin WebSocket clients. - ---- - -- **Test name**: `client and server contexts disconnect when Play mode ends, edit context persists` -- **Priority**: P0 -- **Type**: e2e -- **Setup**: Start server with 3 connected mock plugins (edit/client/server, same instanceId). -- **Steps**: - 1. Close the `client` and `server` plugin WebSockets (simulating Stop Play). - 2. Query server session list. - 3. Verify the `edit` plugin is still connected and functional. -- **Expected result**: Only edit session remains. Actions sent to edit session succeed. -- **Automation**: vitest. - ---- - -- **Test name**: `plugin reconnection after failover preserves context identity` -- **Priority**: P0 -- **Type**: e2e -- **Setup**: Start server. Connect 3 mock plugins (edit/client/server, same instanceId). -- **Steps**: - 1. Force-close the server. - 2. Start a new server on the same port. - 3. Each mock plugin reconnects and re-registers with the same `(instanceId, context)`. - 4. Query the new server's session list. -- **Expected result**: All 3 sessions re-appear with the correct `(instanceId, context)` pairs. New session IDs are assigned. -- **Automation**: vitest. - ---- - -## Phase 2 Gate - -**Criteria**: Persistent plugin installs successfully. Health endpoint works. Plugin discovery and handshake complete. Session management commands work. Existing commands work with `--session` flag. - -**Required passing tests**: -1. All Phase 1 gate tests (see `01-bridge-network.md`). -2. Health endpoint returns correct JSON (2.2). -3. Health endpoint returns 404 for bad paths (2.2). -4. Full launch flow with mock plugin discovery (3.1). -5. Persistent plugin fallback to hello (3.2). -6. Plugin reconnection after disconnect (3.2). -7. `install-plugin` command writes to correct path (1.6 -- see `03-commands.md`). -8. `sessions` command lists sessions (Phase 1: 1.7b). -9. `exec` command session resolution -- all three scenarios (1.6 -- see `03-commands.md`). -10. `exec` command end-to-end with mock plugin (2.3). -11. Multi-context register messages: 3 contexts grouped by instanceId (2.1.3.1). -12. Play mode lifecycle: contexts appear/disappear correctly (2.1.3.1). -13. Register without context defaults to 'edit' (2.1.3.1). -14. Server and client plugin instances join existing edit session during Play mode (3.3). -15. Client/server contexts disconnect on Play mode exit, edit persists (3.3). - -> **Manual Studio testing deferred to Phase 6 E2E validation.** See `06-integration.md` for the consolidated Studio verification checklist. All automated test criteria above remain required for the Phase 2 gate. diff --git a/studio-bridge/plans/execution/validation/03-commands.md b/studio-bridge/plans/execution/validation/03-commands.md deleted file mode 100644 index ecb85b86da..0000000000 --- a/studio-bridge/plans/execution/validation/03-commands.md +++ /dev/null @@ -1,400 +0,0 @@ -# Validation: Phase 3 -- Action Commands - -> **Shared test infrastructure**: All tests that connect a mock plugin MUST use the standardized `MockPluginClient` from `shared-test-utilities.md`. Do not create ad-hoc WebSocket mocks. See [shared-test-utilities.md](./shared-test-utilities.md) for the full specification, usage examples, and design decisions. - -Test specifications for action handlers (query state, capture screenshot, query logs, query data model), CLI command handlers, and command-layer integration tests. - -**Phase**: 3 (New Action Commands) - -**References**: -- Phase plan: `studio-bridge/plans/execution/phases/03-commands.md` -- Agent prompts: `studio-bridge/plans/execution/agent-prompts/03-commands.md` -- Tech specs: `studio-bridge/plans/tech-specs/02-command-system.md`, `studio-bridge/plans/tech-specs/04-action-specs.md` -- Sibling validation: `01-bridge-network.md` (Phase 1), `02-plugin.md` (Phase 2) -- Existing tests: `tools/studio-bridge/src/server/web-socket-protocol.test.ts`, `studio-bridge-server.test.ts` - -Base path for source files: `/workspaces/NevermoreEngine/tools/studio-bridge/` - ---- - -## 1. Unit Test Plans (continued) - -### 1.5 Action Handlers (server-side) - -Tests for `src/server/actions/query-state.ts`, `capture-screenshot.ts`, `query-logs.ts`, `query-datamodel.ts`. Each gets its own test file alongside the source. - -#### 1.5.1 query-state action - -- **Test name**: `queryStateAsync sends queryState message and returns stateResult payload` -- **Priority**: P0 -- **Type**: unit -- **Setup**: Mock `performActionAsync` to resolve with a `stateResult` payload. -- **Steps**: - 1. Call `queryStateAsync(server)`. - 2. Verify `performActionAsync` was called with `type: 'queryState'`, an auto-generated `requestId`, and empty payload. -- **Expected result**: Returns `{ state: 'Edit', placeId: 123, placeName: 'Test', gameId: 456 }`. -- **Automation**: vitest with mock. - ---- - -- **Test name**: `queryStateAsync rejects with timeout error when plugin does not respond` -- **Priority**: P0 -- **Type**: unit -- **Setup**: Mock `performActionAsync` to reject with timeout error. -- **Steps**: - 1. Call `queryStateAsync(server)`. -- **Expected result**: Rejects with error message containing "timed out" and "5 seconds". -- **Automation**: vitest with mock. - ---- - -- **Test name**: `queryStateAsync rejects when plugin lacks queryState capability` -- **Priority**: P0 -- **Type**: unit -- **Setup**: Mock `performActionAsync` to throw "Plugin does not support capability: queryState". -- **Steps**: - 1. Call `queryStateAsync(server)`. -- **Expected result**: Rejects with error about missing capability. -- **Automation**: vitest with mock. - -#### 1.5.2 capture-screenshot action - -- **Test name**: `captureScreenshotAsync returns base64 data from screenshotResult` -- **Priority**: P0 -- **Type**: unit -- **Setup**: Mock `performActionAsync` to resolve with screenshotResult payload. -- **Steps**: - 1. Call `captureScreenshotAsync(server)`. -- **Expected result**: Returns `{ data: 'iVBOR...', format: 'png', width: 1920, height: 1080 }`. -- **Automation**: vitest with mock. - ---- - -- **Test name**: `captureScreenshotAsync rejects with SCREENSHOT_FAILED error` -- **Priority**: P0 -- **Type**: unit -- **Setup**: Mock `performActionAsync` to reject with `SCREENSHOT_FAILED` code. -- **Steps**: - 1. Call `captureScreenshotAsync(server)`. -- **Expected result**: Rejects with error about screenshot capture failure. -- **Automation**: vitest with mock. - -#### 1.5.3 query-logs action - -- **Test name**: `queryLogsAsync sends queryLogs with correct payload fields` -- **Priority**: P0 -- **Type**: unit -- **Setup**: Mock `performActionAsync`. -- **Steps**: - 1. Call `queryLogsAsync(server, { count: 100, direction: 'tail', levels: ['Error', 'Warning'], includeInternal: false })`. - 2. Verify the message payload matches. -- **Expected result**: `performActionAsync` called with correct payload. Returns the mocked `logsResult`. -- **Automation**: vitest with mock. - -#### 1.5.4 query-datamodel action - -- **Test name**: `queryDataModelAsync prepends 'game.' to paths that don't start with it` -- **Priority**: P0 -- **Type**: unit -- **Setup**: Mock `performActionAsync`. -- **Steps**: - 1. Call `queryDataModelAsync(server, { path: 'Workspace.SpawnLocation' })`. - 2. Verify the message payload has `path: 'game.Workspace.SpawnLocation'`. -- **Expected result**: Path is correctly prefixed. -- **Automation**: vitest with mock. - ---- - -- **Test name**: `queryDataModelAsync does not double-prefix paths starting with 'game.'` -- **Priority**: P0 -- **Type**: unit -- **Setup**: Mock `performActionAsync`. -- **Steps**: - 1. Call `queryDataModelAsync(server, { path: 'game.Workspace.SpawnLocation' })`. - 2. Verify the message payload has `path: 'game.Workspace.SpawnLocation'` (not `game.game.Workspace...`). -- **Expected result**: No double prefix. -- **Automation**: vitest with mock. - ---- - -- **Test name**: `queryDataModelAsync rejects with INSTANCE_NOT_FOUND error` -- **Priority**: P0 -- **Type**: unit -- **Setup**: Mock `performActionAsync` to reject with an error carrying `code: 'INSTANCE_NOT_FOUND'`. -- **Steps**: - 1. Call `queryDataModelAsync(server, { path: 'Workspace.NonExistent' })`. -- **Expected result**: Rejects with error containing "No instance found at path". -- **Automation**: vitest with mock. - -### 1.6 CLI Commands - -Tests for CLI command handlers. These tests verify argument parsing and handler logic in isolation, mocking the underlying server interactions. - -- **Test name**: `sessions command outputs table format by default` -- **Priority**: P1 -- **Type**: unit -- **Setup**: Mock `BridgeConnection.listSessionsAsync` to return two sessions (one with `origin: 'user'`, one with `origin: 'managed'`). -- **Steps**: - 1. Invoke the sessions command handler with default args. - 2. Capture stdout. -- **Expected result**: Output contains session IDs, place names, states, origin values (`user`/`managed`), and connection duration. The Origin column is present. Ends with "2 sessions connected." -- **Automation**: vitest, capture stdout. - ---- - -- **Test name**: `sessions command with --json outputs JSON array` -- **Priority**: P1 -- **Type**: unit -- **Setup**: Mock `BridgeConnection.listSessionsAsync` to return sessions. -- **Steps**: - 1. Invoke handler with `{ json: true }`. - 2. Capture stdout. - 3. Parse as JSON. -- **Expected result**: Valid JSON array with session objects. -- **Automation**: vitest. - ---- - -- **Test name**: `sessions command prints message when no sessions exist` -- **Priority**: P1 -- **Type**: unit -- **Setup**: Mock `BridgeConnection.listSessionsAsync` to return empty array. -- **Steps**: - 1. Invoke handler. - 2. Capture stdout. -- **Expected result**: Output contains "No active sessions." -- **Automation**: vitest. - ---- - -- **Test name**: `install-plugin command calls rojo build and writes to plugins folder` -- **Priority**: P1 -- **Type**: unit -- **Setup**: Mock `rojoBuildAsync`, mock `findPluginsFolder`, mock `fs.copyFile`. -- **Steps**: - 1. Invoke install-plugin handler. -- **Expected result**: Rojo build was called with the persistent plugin template. File was copied to the plugins folder path. -- **Automation**: vitest with mocks. - ---- - -- **Test name**: `state command outputs human-readable format by default` -- **Priority**: P1 -- **Type**: unit -- **Setup**: Mock `queryStateAsync` to return `{ state: 'Edit', placeName: 'TestPlace', placeId: 123, gameId: 456 }`. -- **Steps**: - 1. Invoke state command handler. - 2. Capture stdout. -- **Expected result**: Output contains `Place: TestPlace`, `PlaceId: 123`, `GameId: 456`, `Mode: Edit`. -- **Automation**: vitest. - ---- - -- **Test name**: `screenshot command writes file and prints path` -- **Priority**: P1 -- **Type**: unit -- **Setup**: Mock `captureScreenshotAsync`. Mock `fs.writeFile`. -- **Steps**: - 1. Invoke screenshot command handler with `{ output: '/tmp/test.png' }`. -- **Expected result**: `writeFile` called with `/tmp/test.png` and decoded base64 data. -- **Automation**: vitest with mocks. - ---- - -- **Test name**: `exec command session resolution: auto-selects single session` -- **Priority**: P0 -- **Type**: unit -- **Setup**: Mock registry with one session. Mock `StudioBridgeServer`. -- **Steps**: - 1. Invoke exec handler without `--session` flag. -- **Expected result**: Connects to the single available session. -- **Automation**: vitest. - ---- - -- **Test name**: `exec command session resolution: errors on multiple sessions without --session` -- **Priority**: P0 -- **Type**: unit -- **Setup**: Mock registry with two sessions. -- **Steps**: - 1. Invoke exec handler without `--session` flag. -- **Expected result**: Throws/prints error listing available sessions. -- **Automation**: vitest. - ---- - -- **Test name**: `exec command session resolution: falls back to launch when no sessions` -- **Priority**: P0 -- **Type**: unit -- **Setup**: Mock registry with zero sessions. -- **Steps**: - 1. Invoke exec handler without `--session` flag. -- **Expected result**: Falls through to launch flow (calls `startAsync`). -- **Automation**: vitest. - ---- - -#### 1.6.1 Context-aware session resolution - -- **Test name**: `exec command with --context server targets the server context of a Play mode instance` -- **Priority**: P0 -- **Type**: unit -- **Setup**: Mock `BridgeConnection.listSessionsAsync` to return 3 sessions with `instanceId: 'inst-1'` and contexts `edit`, `client`, `server`. -- **Steps**: - 1. Invoke exec handler with `{ context: 'server', code: 'print("hello")' }`. -- **Expected result**: Resolves to the session with `context: 'server'`. Executes against that session. -- **Automation**: vitest. - ---- - -- **Test name**: `exec command defaults to server context when no --context flag and instance has multiple contexts` -- **Priority**: P0 -- **Type**: unit -- **Setup**: Mock 3 sessions with same `instanceId`, contexts `edit`, `client`, `server`. No `--context` flag provided. -- **Steps**: - 1. Invoke exec handler without `--context`. -- **Expected result**: Auto-selects the `server` context session for exec (mutating command; see context default table in `tech-specs/04-action-specs.md`). -- **Automation**: vitest. - ---- - -- **Test name**: `exec command with --context errors when specified context does not exist` -- **Priority**: P0 -- **Type**: unit -- **Setup**: Mock 1 session with `instanceId: 'inst-1'`, `context: 'edit'` (Edit mode, no Play mode). -- **Steps**: - 1. Invoke exec handler with `{ context: 'server' }`. -- **Expected result**: Errors with "No session with context 'server' found for instance inst-1. Studio may not be in Play mode." -- **Automation**: vitest. - ---- - -- **Test name**: `state command with --context client queries the client context` -- **Priority**: P1 -- **Type**: unit -- **Setup**: Mock 3 sessions (edit/client/server) for one instance. -- **Steps**: - 1. Invoke state handler with `{ context: 'client' }`. -- **Expected result**: Queries the client-context session. Returns state from that context. -- **Automation**: vitest. - ---- - -- **Test name**: `logs command with --context server queries the server context log buffer` -- **Priority**: P1 -- **Type**: unit -- **Setup**: Mock 3 sessions for one instance. -- **Steps**: - 1. Invoke logs handler with `{ context: 'server' }`. -- **Expected result**: Queries the server-context session's log buffer. -- **Automation**: vitest. - ---- - -- **Test name**: `query command with --context server queries the server DataModel` -- **Priority**: P1 -- **Type**: unit -- **Setup**: Mock 3 sessions for one instance. -- **Steps**: - 1. Invoke query handler with `{ context: 'server', path: 'ServerStorage' }`. -- **Expected result**: Queries the server-context session. ServerStorage is only accessible from the server context. -- **Automation**: vitest. - ---- - -- **Test name**: `sessions command shows context column for each session` -- **Priority**: P0 -- **Type**: unit -- **Setup**: Mock `BridgeConnection.listSessionsAsync` to return sessions with different contexts. -- **Steps**: - 1. Invoke sessions handler. - 2. Capture stdout. -- **Expected result**: Output includes a Context column showing `edit`, `client`, or `server` for each session. Sessions from the same instance are visually grouped. -- **Automation**: vitest, capture stdout. - ---- - -- **Test name**: `sessions command with --json includes context, instanceId, placeId, gameId fields` -- **Priority**: P0 -- **Type**: unit -- **Setup**: Mock sessions with multi-context data. -- **Steps**: - 1. Invoke sessions handler with `{ json: true }`. - 2. Parse output. -- **Expected result**: Each session object includes `context`, `instanceId`, `placeId`, `gameId` fields. -- **Automation**: vitest. - ---- - -- **Test name**: `session resolution with --session flag and multiple contexts: selects the instance and applies --context` -- **Priority**: P0 -- **Type**: unit -- **Setup**: Mock sessions. Instance 'inst-1' has 3 contexts. -- **Steps**: - 1. Invoke exec handler with `{ session: 'inst-1', context: 'server' }`. -- **Expected result**: Resolves to the server-context session of instance inst-1. -- **Automation**: vitest. - ---- - -- **Test name**: `session resolution auto-selects single instance even with multiple contexts` -- **Priority**: P0 -- **Type**: unit -- **Setup**: Mock 3 sessions all from the same `instanceId` (Play mode). -- **Steps**: - 1. Invoke exec handler without `--session` flag. -- **Expected result**: Auto-selects the instance (only 1 instance exists) and picks the default context. -- **Automation**: vitest. - -## 2. Integration Test Plans (continued) - -### 2.3 CLI to Server to Mock Plugin to Result - -> **Note**: This section covers both the exec command (also relevant to Phase 2 -- see `02-plugin.md`) and the state command. The full section is included here because it primarily tests command-layer integration. - -- **Test name**: `exec command sends script through server to mock plugin and returns output` -- **Priority**: P0 -- **Type**: integration -- **Setup**: Start server, connect mock v2 plugin. Mock plugin auto-responds to `execute` messages. -- **Steps**: - 1. Call the exec command handler with `{ code: 'print("hello")' }`. - 2. Mock plugin receives `execute`, sends `output` with `[{ level: 'Print', body: 'hello' }]`, then `scriptComplete { success: true }`. -- **Expected result**: Exec handler returns `{ success: true }` with logs containing "hello". -- **Automation**: vitest, mock plugin client. - ---- - -- **Test name**: `state command queries mock plugin and formats result` -- **Priority**: P1 -- **Type**: integration -- **Setup**: Start server with connected mock v2 plugin. Mock plugin auto-responds to `queryState`. -- **Steps**: - 1. Call the state command handler. - 2. Mock plugin sends `stateResult { state: 'Play', placeId: 123, placeName: 'Game', gameId: 456 }`. -- **Expected result**: Handler returns structured state result. -- **Automation**: vitest. - ---- - -## Phase 3 Gate - -**Criteria**: All four new actions work end-to-end. Subscription mechanism works. Terminal dot-commands work. - -**Required passing tests**: -1. All Phase 2 gate tests (see `02-plugin.md`). -2. `queryStateAsync` action handler tests (1.5.1). -3. `captureScreenshotAsync` action handler tests (1.5.2). -4. `queryLogsAsync` action handler tests (1.5.3). -5. `queryDataModelAsync` action handler tests (1.5.4) including path prefixing. -6. State command outputs correct format (1.6). -7. Screenshot command writes file (1.6). -8. Full lifecycle e2e (3.1 -- see `02-plugin.md`) including all actions. -9. Concurrent execute + queryState (2.1.6 -- see `02-plugin.md`). -10. `state` command end-to-end with mock plugin (2.3). -11. `--context` flag targets the correct context in Play mode (1.6.1). -12. `--context` errors when specified context does not exist (1.6.1). -13. Session resolution auto-selects single instance even with multiple contexts (1.6.1). -14. Sessions command shows context column and instance grouping (1.6.1). -15. Sessions `--json` includes context, instanceId, placeId, gameId fields (1.6.1). - -> **Manual Studio testing deferred to Phase 6 E2E validation.** See `06-integration.md` for the consolidated Studio verification checklist. All automated test criteria above remain required for the Phase 3 gate. diff --git a/studio-bridge/plans/execution/validation/04-split-server.md b/studio-bridge/plans/execution/validation/04-split-server.md deleted file mode 100644 index 800299860a..0000000000 --- a/studio-bridge/plans/execution/validation/04-split-server.md +++ /dev/null @@ -1,639 +0,0 @@ -# Validation: Phase 4 -- Split Server Mode - -> **Shared test infrastructure**: All tests that connect a mock plugin MUST use the standardized `MockPluginClient` from `shared-test-utilities.md`. Do not create ad-hoc WebSocket mocks. See [shared-test-utilities.md](./shared-test-utilities.md) for the full specification, usage examples, and design decisions. - -Test specifications for split server (daemon) mode: serve command, remote client, devcontainer auto-detection. - -**Phase**: 4 (Split Server / Devcontainer Support) - -**References**: -- Phase plan: `studio-bridge/plans/execution/phases/04-split-server.md` -- Agent prompts: `studio-bridge/plans/execution/agent-prompts/04-split-server.md` -- Tech spec: `studio-bridge/plans/tech-specs/05-split-server.md` -- Sibling validation: `01-bridge-network.md` (Phase 1), `02-plugin.md` (Phase 2), `03-commands.md` (Phase 3) - -Base path for source files: `/workspaces/NevermoreEngine/tools/studio-bridge/` - ---- - -## 1. Unit Test Plans - -### 1.1 Serve Command Handler - -Tests for `src/commands/serve.ts`. - ---- - -- **Test name**: `serveAsync calls BridgeConnection.connectAsync with keepAlive: true` -- **Priority**: P0 -- **Type**: unit -- **Setup**: Mock `BridgeConnection.connectAsync` to return a mock connection object. -- **Steps**: - 1. Call `serveAsync({})`. - 2. Verify `BridgeConnection.connectAsync` was called with `{ keepAlive: true, port: 38741 }`. -- **Expected result**: `connectAsync` receives `keepAlive: true`. -- **Automation**: vitest with mock. - ---- - -- **Test name**: `serveAsync passes custom port to connectAsync` -- **Priority**: P0 -- **Type**: unit -- **Setup**: Mock `BridgeConnection.connectAsync`. -- **Steps**: - 1. Call `serveAsync({ port: 39000 })`. - 2. Verify `connectAsync` was called with `port: 39000`. -- **Expected result**: Custom port is forwarded. -- **Automation**: vitest with mock. - ---- - -- **Test name**: `serveAsync throws clear error on EADDRINUSE` -- **Priority**: P0 -- **Type**: unit -- **Setup**: Mock `BridgeConnection.connectAsync` to throw an error with `code: 'EADDRINUSE'`. -- **Steps**: - 1. Call `serveAsync({ port: 38741 })`. -- **Expected result**: Rejects with error message containing "already in use" and "--port". -- **Automation**: vitest with mock. - ---- - -### 1.2 Environment Detection - -Tests for `src/bridge/internal/environment-detection.ts`. - ---- - -- **Test name**: `isDevcontainer returns true when REMOTE_CONTAINERS is set` -- **Priority**: P0 -- **Type**: unit -- **Setup**: Set `process.env.REMOTE_CONTAINERS = 'true'`. -- **Steps**: - 1. Call `isDevcontainer()`. -- **Expected result**: Returns `true`. -- **Automation**: vitest. - ---- - -- **Test name**: `isDevcontainer returns true when CODESPACES is set` -- **Priority**: P0 -- **Type**: unit -- **Setup**: Set `process.env.CODESPACES = 'true'`. -- **Steps**: - 1. Call `isDevcontainer()`. -- **Expected result**: Returns `true`. -- **Automation**: vitest. - ---- - -- **Test name**: `isDevcontainer returns true when CONTAINER is set` -- **Priority**: P0 -- **Type**: unit -- **Setup**: Set `process.env.CONTAINER = 'podman'`. -- **Steps**: - 1. Call `isDevcontainer()`. -- **Expected result**: Returns `true`. -- **Automation**: vitest. - ---- - -- **Test name**: `isDevcontainer returns false when no signals are present` -- **Priority**: P0 -- **Type**: unit -- **Setup**: Clear all detection env vars, mock `existsSync('/.dockerenv')` to return false. -- **Steps**: - 1. Call `isDevcontainer()`. -- **Expected result**: Returns `false`. -- **Automation**: vitest. - ---- - -- **Test name**: `isDevcontainer treats empty string env var as falsy` -- **Priority**: P1 -- **Type**: unit -- **Setup**: Set `process.env.REMOTE_CONTAINERS = ''`. -- **Steps**: - 1. Call `isDevcontainer()`. -- **Expected result**: Returns `false`. -- **Automation**: vitest. - ---- - -- **Test name**: `getDefaultRemoteHost returns localhost:38741 in devcontainer` -- **Priority**: P0 -- **Type**: unit -- **Setup**: Set `process.env.REMOTE_CONTAINERS = 'true'`. -- **Steps**: - 1. Call `getDefaultRemoteHost()`. -- **Expected result**: Returns `'localhost:38741'`. -- **Automation**: vitest. - ---- - -- **Test name**: `getDefaultRemoteHost returns null outside devcontainer` -- **Priority**: P0 -- **Type**: unit -- **Setup**: Clear all detection env vars. -- **Steps**: - 1. Call `getDefaultRemoteHost()`. -- **Expected result**: Returns `null`. -- **Automation**: vitest. - ---- - -### 1.3 Remote Connection Argument Parsing - -Tests for `--remote` flag parsing in `src/cli/args/global-args.ts`. - ---- - -- **Test name**: `--remote with host:port passes through as-is` -- **Priority**: P0 -- **Type**: unit -- **Setup**: Parse `--remote localhost:38741` through yargs. -- **Steps**: - 1. Parse args `['exec', '--remote', 'localhost:38741', 'print("hi")']`. -- **Expected result**: `argv.remote === 'localhost:38741'`. -- **Automation**: vitest. - ---- - -- **Test name**: `--remote with host-only appends default port` -- **Priority**: P0 -- **Type**: unit -- **Setup**: Parse `--remote myhost` through yargs. -- **Steps**: - 1. Parse args `['exec', '--remote', 'myhost', 'print("hi")']`. -- **Expected result**: `argv.remote === 'myhost:38741'`. -- **Automation**: vitest. - ---- - -- **Test name**: `--remote with invalid port rejects` -- **Priority**: P1 -- **Type**: unit -- **Setup**: Parse `--remote localhost:abc` through yargs. -- **Steps**: - 1. Parse args `['exec', '--remote', 'localhost:abc', 'print("hi")']`. -- **Expected result**: Yargs throws validation error about invalid port. -- **Automation**: vitest. - ---- - -- **Test name**: `--remote and --local together produces conflict error` -- **Priority**: P1 -- **Type**: unit -- **Setup**: Parse both flags through yargs. -- **Steps**: - 1. Parse args `['exec', '--remote', 'localhost:38741', '--local', 'print("hi")']`. -- **Expected result**: Yargs throws conflict error (mutually exclusive). -- **Automation**: vitest. - ---- - -## 2. Integration Test Plans - -### 2.1 BridgeConnection Remote Path - -Tests for the remote connection path in `src/bridge/bridge-connection.ts`. - ---- - -- **Test name**: `connectAsync with remoteHost connects as client, not host` -- **Priority**: P0 -- **Type**: integration -- **Setup**: Start a mock WebSocket server on a test port that accepts `/client` connections. -- **Steps**: - 1. Call `BridgeConnection.connectAsync({ remoteHost: 'localhost:' })`. - 2. Verify the mock server received a WebSocket connection on `/client`. - 3. Verify the returned connection is in client mode (not host mode). -- **Expected result**: Connection established as client. -- **Automation**: vitest with real WebSocket server on ephemeral port. - ---- - -- **Test name**: `connectAsync with remoteHost throws on ECONNREFUSED` -- **Priority**: P0 -- **Type**: integration -- **Setup**: No server listening on the target port. -- **Steps**: - 1. Call `BridgeConnection.connectAsync({ remoteHost: 'localhost:19999' })`. -- **Expected result**: Rejects with error containing "Could not connect" and "studio-bridge serve". -- **Automation**: vitest. - ---- - -- **Test name**: `connectAsync with remoteHost times out after 5 seconds` -- **Priority**: P1 -- **Type**: integration -- **Setup**: Start a TCP server that accepts connections but never completes the WebSocket handshake. -- **Steps**: - 1. Call `BridgeConnection.connectAsync({ remoteHost: 'localhost:' })`. - 2. Measure time until rejection. -- **Expected result**: Rejects within 5-6 seconds with message containing "timed out". -- **Automation**: vitest with custom TCP server. - ---- - -- **Test name**: `connectAsync with local: true skips devcontainer auto-detection` -- **Priority**: P0 -- **Type**: integration -- **Setup**: Set `REMOTE_CONTAINERS=true`. Start a mock bridge host on 38741. -- **Steps**: - 1. Call `BridgeConnection.connectAsync({ local: true })`. - 2. Verify no connection attempt to 38741 as client. - 3. Verify it attempts local bind (or falls through to local behavior). -- **Expected result**: Auto-detection is skipped. -- **Automation**: vitest. - ---- - -- **Test name**: `connectAsync auto-detects devcontainer and connects remotely` -- **Priority**: P0 -- **Type**: integration -- **Setup**: Set `REMOTE_CONTAINERS=true`. Start a mock WebSocket server on port 38741 accepting `/client`. -- **Steps**: - 1. Call `BridgeConnection.connectAsync({})` (no remoteHost, no local). - 2. Verify it connects to the mock server as a client. -- **Expected result**: Auto-detection triggers, connection established to localhost:38741. -- **Automation**: vitest. - ---- - -- **Test name**: `connectAsync auto-detection falls back to local on timeout` -- **Priority**: P0 -- **Type**: integration -- **Setup**: Set `REMOTE_CONTAINERS=true`. No server on 38741. Ensure local bind is possible on another port. -- **Steps**: - 1. Call `BridgeConnection.connectAsync({})`. - 2. Measure time until it falls back. - 3. Verify a warning is logged. -- **Expected result**: Falls back to local mode within 3-4 seconds. Warning message mentions `studio-bridge serve`. -- **Automation**: vitest with console.warn spy. - ---- - -## 3. End-to-End Test Plans - -### 3.1 Serve Startup and Port Binding - -- **Test name**: `serve starts and binds port, health endpoint responds` -- **Priority**: P0 -- **Type**: e2e -- **Setup**: Start `studio-bridge serve --port ` as a subprocess. -- **Steps**: - 1. Wait for stdout to contain "listening on port". - 2. Send HTTP GET to `http://localhost:/health`. - 3. Verify 200 response with valid JSON body. -- **Expected result**: Health endpoint responds. Subprocess is running. -- **Automation**: vitest with `child_process.spawn`. - ---- - -### 3.2 Serve Graceful Shutdown (SIGTERM) - -- **Test name**: `serve shuts down cleanly on SIGTERM` -- **Priority**: P0 -- **Type**: e2e -- **Setup**: Start `studio-bridge serve --port ` as a subprocess. Connect a mock plugin via WebSocket using `MockPluginClient`. -- **Steps**: - 1. Wait for subprocess to be listening (stdout "listening on port"). - 2. Connect mock plugin to `ws://localhost:/plugin/`. - 3. Send SIGTERM to the subprocess. - 4. Wait for subprocess to exit. - 5. Verify exit code is 0. - 6. Verify the mock plugin's WebSocket received a close event or a `shutdown` message. - 7. Verify the port is no longer in use (can bind it from the test). -- **Expected result**: Exit code 0. Plugin notified. Port freed. -- **Automation**: vitest with `child_process.spawn` and `MockPluginClient`. - ---- - -### 3.3 Serve with Port Already in Use - -- **Test name**: `serve errors when port is already in use` -- **Priority**: P0 -- **Type**: e2e -- **Setup**: Bind a TCP server on port ``. -- **Steps**: - 1. Start `studio-bridge serve --port ` as a subprocess. - 2. Wait for the subprocess to exit. - 3. Capture stderr/stdout. -- **Expected result**: Subprocess exits with code 1. Output contains "already in use" and "--port". -- **Automation**: vitest with `net.createServer`. - ---- - -### 3.4 Remote Client Connection to Running Serve - -- **Test name**: `remote client connects to running serve and executes command` -- **Priority**: P0 -- **Type**: e2e -- **Setup**: Start `studio-bridge serve --port ` as a subprocess. Connect a mock plugin using `MockPluginClient` that responds to `execute` requests. -- **Steps**: - 1. Start serve subprocess, wait for "listening on port". - 2. Connect mock plugin via WebSocket to `ws://localhost:/plugin/`. Mock plugin responds to `execute` with `scriptComplete` containing output "hello from remote". - 3. Run `studio-bridge exec --remote localhost: 'print("hello")'` as a separate subprocess. - 4. Capture stdout from the exec subprocess. -- **Expected result**: Exec subprocess stdout contains "hello from remote". Exit code 0. -- **Automation**: vitest with multiple subprocesses and `MockPluginClient`. - ---- - -### 3.5 Remote Client with Unreachable Host (Timeout) - -- **Test name**: `remote client errors within 6 seconds when host is unreachable` -- **Priority**: P0 -- **Type**: e2e -- **Setup**: No server on port 19999. -- **Steps**: - 1. Record start time. - 2. Run `studio-bridge exec --remote localhost:19999 'print("hi")'` as a subprocess. - 3. Wait for subprocess to exit. - 4. Record end time. -- **Expected result**: Exit code 1. Duration less than 6 seconds. Stderr contains "Could not connect". -- **Automation**: vitest with `child_process.spawn` and timer. - ---- - -### 3.6 Remote Client with Wrong Port - -- **Test name**: `remote client errors when port is wrong but host exists` -- **Priority**: P1 -- **Type**: e2e -- **Setup**: Start `studio-bridge serve --port `. Attempt connection to `` (wrong port). -- **Steps**: - 1. Start serve on ``. - 2. Run `studio-bridge exec --remote localhost: 'print("hi")'`. - 3. Wait for subprocess to exit. -- **Expected result**: Exit code 1. Error message contains "Could not connect" and the wrong port number. -- **Automation**: vitest. - ---- - -### 3.7 Multiple Concurrent CLI Clients on One Daemon - -- **Test name**: `multiple CLI clients can connect to one serve instance concurrently` -- **Priority**: P1 -- **Type**: e2e -- **Setup**: Start `studio-bridge serve --port `. Connect a mock plugin using `MockPluginClient` that handles execute requests. The mock plugin should track request IDs to return distinct outputs. -- **Steps**: - 1. Start serve, wait for "listening on port". - 2. Connect mock plugin. - 3. Spawn CLI client A: `studio-bridge exec --remote localhost: 'print("clientA")'`. - 4. Spawn CLI client B: `studio-bridge exec --remote localhost: 'print("clientB")'` concurrently. - 5. Wait for both to complete. - 6. Capture stdout from each. -- **Expected result**: Client A receives output for client A's request. Client B receives output for client B's request. No cross-contamination. Both exit with code 0. -- **Automation**: vitest with concurrent subprocesses. - ---- - -### 3.8 Daemon Restart While CLI is Mid-Request - -- **Test name**: `CLI client gets error when daemon dies mid-request` -- **Priority**: P2 -- **Type**: e2e -- **Setup**: Start `studio-bridge serve --port `. Connect a mock plugin using `MockPluginClient` that delays its response by 5 seconds. -- **Steps**: - 1. Start serve, connect mock plugin (with delayed response). - 2. Spawn CLI client: `studio-bridge exec --remote localhost: 'long_running()'`. - 3. Wait 1 second, then kill the serve subprocess with SIGKILL (not SIGTERM -- simulate crash). - 4. Wait for the CLI client subprocess to exit. -- **Expected result**: CLI client exits with code 1. Error message indicates connection was lost or request failed. -- **Automation**: vitest. - ---- - -### 3.9 Devcontainer Auto-Detection with Env Vars - -- **Test name**: `CLI auto-detects devcontainer and connects to remote bridge host` -- **Priority**: P1 -- **Type**: e2e -- **Setup**: Start `studio-bridge serve --port 38741` on a test port. Connect a mock plugin using `MockPluginClient`. Set `REMOTE_CONTAINERS=true` in the environment for the client subprocess. -- **Steps**: - 1. Start serve on port 38741, connect mock plugin. - 2. Spawn CLI client with `REMOTE_CONTAINERS=true` env: `studio-bridge exec 'print("auto")'` (no `--remote` flag). - 3. Wait for subprocess to exit. - 4. Capture stdout. -- **Expected result**: Output contains the plugin's response. The CLI connected automatically via auto-detection. Exit code 0. -- **Automation**: vitest with `child_process.spawn` and custom env. - ---- - -### 3.10 Devcontainer Fallback to Local on Timeout - -- **Test name**: `CLI falls back to local mode when devcontainer auto-detection fails` -- **Priority**: P1 -- **Type**: e2e -- **Setup**: No bridge host running on port 38741. Set `REMOTE_CONTAINERS=true` in the environment. -- **Steps**: - 1. Record start time. - 2. Spawn CLI client with `REMOTE_CONTAINERS=true` env: `studio-bridge sessions` (a command that works in local mode with zero sessions). - 3. Wait for subprocess to exit. - 4. Record end time. - 5. Capture stderr and stdout. -- **Expected result**: Falls back to local mode. Stderr contains a warning about devcontainer detection failure. Duration between 3 and 5 seconds (3-second auto-detect timeout). Stdout shows empty session list or local behavior. Exit code 0. -- **Automation**: vitest with timer and custom env. - ---- - -### 3.11 --local Flag Overrides Devcontainer Detection - -- **Test name**: `--local flag skips devcontainer auto-detection` -- **Priority**: P1 -- **Type**: e2e -- **Setup**: Start `studio-bridge serve --port 38741`. Set `REMOTE_CONTAINERS=true`. Connect a mock plugin to serve using `MockPluginClient`. -- **Steps**: - 1. Start serve on 38741, connect mock plugin. - 2. Spawn CLI client with `REMOTE_CONTAINERS=true` env: `studio-bridge sessions --local`. - 3. Wait for subprocess to exit. - 4. Capture stdout and stderr. -- **Expected result**: The CLI does NOT connect to the remote serve instance. Instead, it enters local mode (tries to bind its own port or connects to a local host). The result should differ from what the remote serve would return. No warning about devcontainer detection. Exit code 0. -- **Automation**: vitest with custom env. - ---- - -### 3.12 Wrong Message Routed to Wrong Plugin (Multi-Session) - -- **Test name**: `daemon routes messages to correct plugin when multiple sessions are active` -- **Priority**: P1 -- **Type**: e2e -- **Setup**: Start `studio-bridge serve --port `. Connect two mock plugins using `MockPluginClient` with different session IDs. Each plugin returns a different output (e.g., plugin A returns "from-A", plugin B returns "from-B"). -- **Steps**: - 1. Start serve, wait for "listening on port". - 2. Connect mock plugin A with session ID `session-a`. Plugin A responds to execute with "from-A". - 3. Connect mock plugin B with session ID `session-b`. Plugin B responds to execute with "from-B". - 4. Spawn CLI client targeting session A: `studio-bridge exec --remote localhost: --session session-a 'test()'`. - 5. Spawn CLI client targeting session B: `studio-bridge exec --remote localhost: --session session-b 'test()'`. - 6. Wait for both to complete. -- **Expected result**: Client targeting session A receives "from-A". Client targeting session B receives "from-B". No message cross-routing. -- **Automation**: vitest with multiple `MockPluginClient` instances and concurrent subprocesses. - ---- - -### 3.13 Daemon Cleanup After Test (Graceful) - -- **Test name**: `daemon cleans up all resources on graceful shutdown` -- **Priority**: P1 -- **Type**: e2e -- **Setup**: Start `studio-bridge serve --port `. Connect a mock plugin using `MockPluginClient` and a mock CLI client via WebSocket. -- **Steps**: - 1. Start serve, connect mock plugin and mock CLI client. - 2. Verify both connections are active (health endpoint shows 1 plugin session, 1 client). - 3. Send SIGTERM to the serve subprocess. - 4. Wait for subprocess to exit (max 5 seconds). - 5. Verify exit code is 0. - 6. Verify mock plugin received close event. - 7. Verify mock CLI client received close event. - 8. Verify the port is free (bind a new TCP server on it, then close). - 9. Verify no orphaned child processes or timers (subprocess fully exited). -- **Expected result**: All connections closed. Port freed. Exit code 0. No resource leaks. -- **Automation**: vitest with `child_process.spawn`, `MockPluginClient`, and `net.createServer`. - ---- - -## 4. Edge Case Tests - -### 4.1 Serve with --json Flag - -- **Test name**: `serve --json outputs structured JSON lines` -- **Priority**: P1 -- **Type**: e2e -- **Setup**: Start `studio-bridge serve --port --json`. -- **Steps**: - 1. Start serve with `--json`, wait for first JSON line on stdout. - 2. Parse the first line as JSON. - 3. Connect a mock plugin using `MockPluginClient`. - 4. Wait for the next JSON line on stdout. - 5. Parse it. - 6. Disconnect the mock plugin. - 7. Wait for the next JSON line on stdout. -- **Expected result**: First line: `{ "event": "started", "port": , "timestamp": "..." }`. Second line: `{ "event": "pluginConnected", "sessionId": "...", ... }`. Third line: `{ "event": "pluginDisconnected", "sessionId": "..." }`. All lines are valid JSON. -- **Automation**: vitest. - ---- - -### 4.2 Serve with --timeout Auto-Shutdown - -- **Test name**: `serve --timeout shuts down after idle period` -- **Priority**: P2 -- **Type**: e2e -- **Setup**: Start `studio-bridge serve --port --timeout 2000` (2 second timeout). -- **Steps**: - 1. Start serve, wait for "listening on port". - 2. Wait 3 seconds (no connections made). - 3. Check if subprocess has exited. -- **Expected result**: Subprocess exits with code 0 after approximately 2 seconds of idle time. Stdout contains "Idle timeout reached". -- **Automation**: vitest with timer. - ---- - -### 4.3 Serve --timeout Resets on Connection - -- **Test name**: `serve --timeout resets when a connection arrives` -- **Priority**: P2 -- **Type**: e2e -- **Setup**: Start `studio-bridge serve --port --timeout 3000`. -- **Steps**: - 1. Start serve, wait for "listening on port". - 2. Wait 2 seconds. - 3. Connect a mock plugin using `MockPluginClient` (resets the timer). - 4. Disconnect the mock plugin immediately. - 5. Wait 2 seconds (timer should have restarted from disconnect). - 6. Verify serve is still running (timer has not expired yet -- only 2 of 3 seconds elapsed since last activity). - 7. Wait 2 more seconds. - 8. Verify serve has now exited. -- **Expected result**: Serve stays alive during and shortly after the connection. Exits after 3 seconds of idle following the disconnect. -- **Automation**: vitest with precise timing. - ---- - -### 4.4 SIGHUP Does Not Kill Serve - -- **Test name**: `serve ignores SIGHUP and continues running` -- **Priority**: P2 -- **Type**: e2e -- **Setup**: Start `studio-bridge serve --port ` as a subprocess. -- **Steps**: - 1. Start serve, wait for "listening on port". - 2. Send SIGHUP to the subprocess. - 3. Wait 1 second. - 4. Verify the subprocess is still running (send HTTP GET to health endpoint). -- **Expected result**: Subprocess survives SIGHUP. Health endpoint still responds. -- **Automation**: vitest with `process.kill(pid, 'SIGHUP')`. - ---- - -## 5. Daemon Stays Alive Tests - -### 5.1 Daemon Survives CLI Client Disconnect - -- **Test name**: `daemon stays alive when CLI client disconnects` -- **Priority**: P0 -- **Type**: e2e -- **Setup**: Start `studio-bridge serve --port `. Connect a mock plugin using `MockPluginClient` and a CLI client. -- **Steps**: - 1. Start serve, connect mock plugin and CLI client. - 2. CLI client disconnects. - 3. Verify daemon is still running (health endpoint responds). - 4. New CLI client connects and executes a command. -- **Expected result**: Daemon continues serving. New CLI client can execute commands. Mock plugin remains connected. -- **Automation**: vitest. - ---- - -### 5.2 Daemon Survives Plugin Reconnect - -- **Test name**: `daemon stays alive when plugin disconnects and reconnects` -- **Priority**: P1 -- **Type**: e2e -- **Setup**: Start `studio-bridge serve --port `. Connect a mock plugin using `MockPluginClient`. -- **Steps**: - 1. Start serve, connect mock plugin. - 2. Disconnect mock plugin. - 3. Verify daemon is still running (health endpoint responds). - 4. Connect a new mock plugin with the same session ID. - 5. Connect a CLI client and execute a command targeting the session. -- **Expected result**: Daemon survives plugin disconnect. Reconnected plugin serves new commands. -- **Automation**: vitest with `MockPluginClient`. - ---- - -## Phase 4 Gate - -**Criteria**: Split server mode works. Daemon stays alive independently. Devcontainer auto-detection works. CLI clients can connect remotely. Messages are routed correctly to the right plugin session. - -**Required passing tests (P0)**: -1. All Phase 3 gate tests (see `03-commands.md`). -2. `serveAsync calls BridgeConnection.connectAsync with keepAlive: true` (1.1). -3. `serveAsync throws clear error on EADDRINUSE` (1.1). -4. `isDevcontainer returns true when REMOTE_CONTAINERS is set` (1.2). -5. `isDevcontainer returns false when no signals are present` (1.2). -6. `connectAsync with remoteHost connects as client, not host` (2.1). -7. `connectAsync with remoteHost throws on ECONNREFUSED` (2.1). -8. `connectAsync auto-detects devcontainer and connects remotely` (2.1). -9. `connectAsync auto-detection falls back to local on timeout` (2.1). -10. `serve starts and binds port, health endpoint responds` (3.1). -11. `serve shuts down cleanly on SIGTERM` (3.2). -12. `serve errors when port is already in use` (3.3). -13. `remote client connects to running serve and executes command` (3.4). -14. `remote client errors within 6 seconds when host is unreachable` (3.5). -15. `daemon stays alive when CLI client disconnects` (5.1). - -**Required passing tests (P1)**: -16. `daemon routes messages to correct plugin when multiple sessions are active` (3.12). -17. `daemon cleans up all resources on graceful shutdown` (3.13). -18. `--local flag skips devcontainer auto-detection` (3.11). -19. `multiple CLI clients can connect to one serve instance concurrently` (3.7). -20. `CLI auto-detects devcontainer and connects to remote bridge host` (3.9). -21. `CLI falls back to local mode when devcontainer auto-detection fails` (3.10). - -**Manual verification** (requires devcontainer): -1. On host: run `studio-bridge serve`. -2. In devcontainer: run `studio-bridge exec 'print("hello")'` -- verify output appears. -3. In devcontainer: run `studio-bridge sessions` -- verify session listed. -4. Kill and restart `studio-bridge serve` on host -- verify devcontainer CLI reconnects on next command. -5. In devcontainer: run `studio-bridge exec --local 'print("test")'` -- verify it does NOT use the remote serve. -6. On host: run `studio-bridge serve --json` -- verify structured JSON events appear when plugin connects/disconnects. diff --git a/studio-bridge/plans/execution/validation/05-mcp-server.md b/studio-bridge/plans/execution/validation/05-mcp-server.md deleted file mode 100644 index 013ba3ec77..0000000000 --- a/studio-bridge/plans/execution/validation/05-mcp-server.md +++ /dev/null @@ -1,112 +0,0 @@ -# Validation: Phase 5 -- MCP Integration - -> **Shared test infrastructure**: All tests that connect a mock plugin MUST use the standardized `MockPluginClient` from `shared-test-utilities.md`. Do not create ad-hoc WebSocket mocks. See [shared-test-utilities.md](./shared-test-utilities.md) for the full specification, usage examples, and design decisions. - -Test specifications for MCP server: tool listing, tool calls, session auto-selection. - -**Phase**: 5 (MCP Integration) - -**References**: -- Phase plan: `studio-bridge/plans/execution/phases/05-mcp-server.md` -- Agent prompts: `studio-bridge/plans/execution/agent-prompts/05-mcp-server.md` -- Tech spec: `studio-bridge/plans/tech-specs/06-mcp-server.md` -- Sibling validation: `03-commands.md` (Phase 3), `04-split-server.md` (Phase 4) - -Base path for source files: `/workspaces/NevermoreEngine/tools/studio-bridge/` - ---- - -## 3. End-to-End Test Plans (continued) - -### 3.5 MCP Integration - -- **Test name**: `MCP server advertises all six tools on initialization` -- **Priority**: P1 -- **Type**: e2e -- **Setup**: Start MCP server in-process via stdio transport mock. -- **Steps**: - 1. Send MCP `tools/list` request. - 2. Parse the response. -- **Expected result**: Response contains `studio_sessions`, `studio_state`, `studio_screenshot`, `studio_logs`, `studio_query`, `studio_exec`. -- **Automation**: vitest, mock stdio transport. - ---- - -- **Test name**: `MCP studio_exec tool executes script and returns structured result` -- **Priority**: P1 -- **Type**: e2e -- **Setup**: Start MCP server. Start bridge server with mock plugin. -- **Steps**: - 1. Send MCP `tools/call` with `studio_exec` tool and `{ script: 'print("hi")' }`. - 2. Mock plugin responds with output + scriptComplete. -- **Expected result**: MCP response contains `{ success: true, logs: [{ level: 'Print', body: 'hi' }] }`. -- **Automation**: vitest. - ---- - -- **Test name**: `MCP studio_state tool returns state JSON` -- **Priority**: P1 -- **Type**: e2e -- **Setup**: Same as above. -- **Steps**: - 1. Send MCP `tools/call` with `studio_state`. - 2. Mock plugin responds with stateResult. -- **Expected result**: MCP response contains `{ state: 'Edit', placeName: 'Test', placeId: 123, gameId: 456 }`. -- **Automation**: vitest. - ---- - -- **Test name**: `MCP studio_screenshot tool returns base64 image` -- **Priority**: P1 -- **Type**: e2e -- **Setup**: Same as above. -- **Steps**: - 1. Send MCP `tools/call` with `studio_screenshot`. - 2. Mock plugin responds with screenshotResult. -- **Expected result**: MCP response contains base64 image data with correct format and dimensions. -- **Automation**: vitest. - ---- - -- **Test name**: `MCP session auto-selection: errors when multiple sessions exist and no sessionId provided` -- **Priority**: P1 -- **Type**: e2e -- **Setup**: Register two sessions in the registry. -- **Steps**: - 1. Send MCP `tools/call` with `studio_state` and no `sessionId` input. -- **Expected result**: MCP error response listing available sessions. -- **Automation**: vitest. - ---- - -- **Test name**: `MCP session auto-selection: auto-selects when exactly one session exists` -- **Priority**: P1 -- **Type**: e2e -- **Setup**: Register one session. -- **Steps**: - 1. Send MCP `tools/call` with `studio_state` and no `sessionId`. -- **Expected result**: Successfully queries the single session. -- **Automation**: vitest. - ---- - -## Phase 5 Gate - -**Criteria**: MCP server works. All six tools respond correctly. Session resolution works in MCP context. - -**Required passing tests**: -1. All Phase 4 gate tests (see `04-split-server.md`). -2. MCP tool listing (3.5). -3. MCP `studio_exec` (3.5). -4. MCP `studio_state` (3.5). -5. MCP `studio_screenshot` (3.5). -6. MCP session auto-selection: single session (3.5). -7. MCP session auto-selection: multiple sessions error (3.5). - -8. MCP `studio_screenshot` with a realistic Studio viewport (3D scene with parts, lighting, terrain) -- verify the base64 payload decodes to a valid PNG and the total MCP response (including JSON framing) stays under the 16 MB WebSocket payload limit (3.5). Typical viewport screenshots are 1-3 MB as PNG; verify the base64-encoded version (~1.3x overhead) plus JSON framing fits comfortably. - -**Manual verification**: -1. Configure Claude Code MCP with `studio-bridge mcp` entry. -2. Verify Claude Code discovers all six tools. -3. Use `studio_exec` from Claude Code to run a script in Studio. -4. Use `studio_state` from Claude Code to check Studio state. diff --git a/studio-bridge/plans/execution/validation/06-integration.md b/studio-bridge/plans/execution/validation/06-integration.md deleted file mode 100644 index 9adb1f3f82..0000000000 --- a/studio-bridge/plans/execution/validation/06-integration.md +++ /dev/null @@ -1,503 +0,0 @@ -# Validation: Phase 6 -- Integration, Regression, Performance, and Security - -> **Shared test infrastructure**: All tests that connect a mock plugin MUST use the standardized `MockPluginClient` from `shared-test-utilities.md`. Do not create ad-hoc WebSocket mocks. See [shared-test-utilities.md](./shared-test-utilities.md) for the full specification, usage examples, and design decisions. - -Cross-cutting validation that spans multiple phases: bridge host failover e2e tests, regression tests, performance tests, and security tests. - -**Phase**: 6 (Polish / Integration) - -**References**: -- Phase plan: `studio-bridge/plans/execution/phases/06-integration.md` -- Agent prompts: `studio-bridge/plans/execution/agent-prompts/06-integration.md` -- Tech spec: `studio-bridge/plans/tech-specs/00-overview.md` -- Sibling validation: `01-bridge-network.md` through `05-mcp-server.md` -- Existing tests: `tools/studio-bridge/src/server/web-socket-protocol.test.ts`, `studio-bridge-server.test.ts` - -Base path for source files: `/workspaces/NevermoreEngine/tools/studio-bridge/` - ---- - -## 3. End-to-End Test Plans (continued) - -### 3.3 Bridge Host Failover (end-to-end) - -These tests complement the focused failover integration tests in section 1.4 (see `01-bridge-network.md`) by exercising failover in the context of real commands and session management -- not just raw bridge connections. They verify that the system recovers transparently from the user's perspective. - -- **Test name**: `exec command succeeds after bridge host failover during idle` -- **Priority**: P0 -- **Type**: e2e -- **Setup**: Start bridge host (implicit, via `BridgeConnection`). Connect mock plugin. Run `exec 'print("before")'` successfully. Kill the host. Use `vi.useFakeTimers()`. -- **Steps**: - 1. Execute a command successfully (establishes connection, plugin, session). - 2. Kill the bridge host process. - 3. `vi.advanceTimersByTime(5000)` to advance past recovery window. - 4. Run `exec 'print("after")'` (new CLI process). - 5. Verify the command succeeds. -- **Expected result**: New CLI becomes host, plugin reconnects, command output contains "after". -- **Automation**: vitest with `vi.useFakeTimers()` and mock plugin. Restore with `vi.useRealTimers()` in `afterEach`. - ---- - -- **Test name**: `sessions command shows recovered session after failover` -- **Priority**: P1 -- **Type**: e2e -- **Setup**: Start host, connect mock plugin, connect client. Kill host. Client takes over. -- **Steps**: - 1. After client becomes new host, run `sessions` command. - 2. Verify the mock plugin's session appears in the list. -- **Expected result**: Session list contains one session with correct metadata. No ghost sessions from before the failover. -- **Automation**: vitest. - ---- - -- **Test name**: `terminal mode survives host failover and continues executing` -- **Priority**: P1 -- **Type**: e2e -- **Setup**: Start `studio-bridge terminal` (becomes host). Connect mock plugin. Enter a command. -- **Steps**: - 1. Execute `print("before")` in terminal. - 2. Simulate host death (close transport server directly). - 3. Terminal process detects and recovers (rebinds port as host). - 4. Execute `print("after")` in terminal. -- **Expected result**: Second command succeeds. Terminal does not crash or hang. -- **Automation**: vitest with mock stdin/stdout. - ---- - -- **Test name**: `MCP server reconnects after host failover` -- **Priority**: P2 -- **Type**: e2e -- **Setup**: Start MCP server (as client). Start host separately. Connect mock plugin. -- **Steps**: - 1. Send `studio_state` MCP tool call -- succeeds. - 2. Kill the host. - 3. MCP server detects disconnect, takes over as host. - 4. Mock plugin reconnects. - 5. Send `studio_state` again. -- **Expected result**: Second tool call succeeds. MCP server did not crash or require restart. -- **Automation**: vitest with mock MCP transport. - ---- - -## 4. Studio E2E Validation (Manual) - -> This section consolidates all manual Studio testing deferred from Phases 2 and 3. These checks require a real Roblox Studio instance and cannot be automated with mock plugins. Perform these after all automated gates for Phases 1-5 have passed. - -### 4.1 Plugin Installation and Discovery (from Phase 2) - -1. Run `studio-bridge install-plugin` -- verify file appears in Studio plugins folder. -2. Open Studio -- verify `[StudioBridge]` messages in output log. -3. Start server (`studio-bridge launch`) -- verify plugin discovers and connects. -4. Run `studio-bridge sessions` -- verify session listed. -5. Run `studio-bridge exec --session 'print("hello")'` -- verify output. - -### 4.2 Plugin Reconnection (from Phase 2) - -6. Kill server -- verify plugin enters reconnecting state (visible in Studio output). -7. Restart server -- verify plugin reconnects. - -### 4.3 Multi-Context Detection (from Phase 2) - -8. Enter Play mode -- verify 2 additional sessions appear (client, server contexts) in `studio-bridge sessions`. -9. Stop Play mode -- verify client/server sessions disappear, edit session remains. - -**Studio test matrix for context detection** (verify all rows): - -| Scenario | Expected edit context | Expected server context | Expected client context | -|----------|----------------------|------------------------|------------------------| -| Edit mode (no Play) | 1 session, state=Edit | none | none | -| Play mode (client+server) | 1 session, state=Play | 1 session, state=Run | 1 session, state=Play | -| Play Solo (server only) | 1 session, state=Play | 1 session, state=Run | none | -| Stop Play -> return to Edit | 1 session, state=Edit | disconnected | disconnected | -| Start Play -> Pause -> Resume | 1 session, state=Paused then Play | 1 session, state=Paused then Run | 1 session, state=Paused then Play | -| Rapid Play/Stop toggle (5x) | Survives, 1 session remains | Connects/disconnects cleanly each cycle | Connects/disconnects cleanly each cycle | - -### 4.4 Action Handlers in Real Studio (from Phase 3) - -10. `studio-bridge state` -- verify output matches Studio state. -11. `studio-bridge state --watch` -- change Studio mode (Play/Edit), verify updates appear. -12. `studio-bridge screenshot` -- verify PNG file is written and viewable. -13. `studio-bridge logs` -- verify output matches Studio output window. -14. `studio-bridge logs --follow` -- print something in Studio, verify it appears in the CLI. -15. `studio-bridge query Workspace` -- verify children listed. -16. `studio-bridge query Workspace.SpawnLocation --properties Position,Anchored` -- verify properties. -17. In terminal mode: `.state`, `.screenshot`, `.logs`, `.query Workspace` -- all work. - -### 4.5 Context-Aware Commands in Real Studio (from Phase 3) - -18. Enter Play mode in Studio -- verify `studio-bridge sessions` shows 3 sessions (edit/client/server) for the instance. -19. `studio-bridge exec --context server 'print(game:GetService("ServerStorage"))'` -- verify it runs against the server context. -20. `studio-bridge exec --context client 'print(game:GetService("Players").LocalPlayer)'` -- verify it runs against the client context. -21. `studio-bridge query --context server ServerStorage` -- verify server-only services are accessible. -22. `studio-bridge logs --context server` -- verify server-side logs are shown. -23. Stop Play mode -- verify client/server sessions disappear from `studio-bridge sessions`. - -### 4.6 Failover Recovery in Real Studio - -24. Start server, connect real Studio plugin, verify session active. -25. Kill server process -- verify plugin enters reconnecting state. -26. Start new CLI process -- verify it becomes host and plugin reconnects. -27. Run `studio-bridge exec 'print("after failover")'` -- verify command succeeds. - -### 4.7 Sessions Command with Real Studio - -28. Run `studio-bridge sessions` -- verify real Studio session appears with correct Place name, state, and context. -29. Run `studio-bridge sessions --json` -- verify JSON output includes all fields. -30. Run `studio-bridge sessions --watch` -- enter/exit Play mode, verify updates appear in real-time. - ---- - -## 5. Regression Tests - -### 5.1 Existing CLI Commands - -These tests verify that commands that exist before the persistent sessions feature continue to work identically. - -- **Test name**: `exec command works without --session flag when no sessions exist (launch mode)` -- **Priority**: P0 -- **Type**: integration -- **Setup**: Empty session registry. Mock Studio launch. -- **Steps**: - 1. Call `studio-bridge exec 'print("hello")'` without `--session`. -- **Expected result**: Falls back to current behavior: launches Studio, injects temporary plugin, executes, returns output. -- **Automation**: vitest, existing test pattern from `studio-bridge-server.test.ts`. - ---- - -- **Test name**: `run command reads file and executes` -- **Priority**: P0 -- **Type**: integration -- **Setup**: Write a temp Lua file with `print("from file")`. Mock Studio launch. -- **Steps**: - 1. Call `studio-bridge run /tmp/test.lua`. -- **Expected result**: Script content is read and executed. Output contains "from file". -- **Automation**: vitest. - ---- - -- **Test name**: `terminal command enters REPL in launch mode` -- **Priority**: P1 -- **Type**: integration -- **Setup**: Empty session registry. Mock Studio launch. -- **Steps**: - 1. Start `studio-bridge terminal`. - 2. Verify it launches Studio and enters REPL. -- **Expected result**: REPL prompt appears after Studio launch and plugin handshake. -- **Automation**: vitest, mock stdin/stdout. - ---- - -- **Test name**: `exec --place flag still works` -- **Priority**: P0 -- **Type**: integration -- **Setup**: Mock Studio launch. -- **Steps**: - 1. Call `studio-bridge exec --place /path/to/Game.rbxl 'print("test")'`. -- **Expected result**: Server is created with the specified place path. -- **Automation**: vitest. - ---- - -- **Test name**: `exec --timeout flag still works` -- **Priority**: P1 -- **Type**: integration -- **Setup**: Mock Studio launch. Do not respond from mock plugin. Use `vi.useFakeTimers()`. -- **Steps**: - 1. Call `studio-bridge exec --timeout 200 'while true do end'`. - 2. `vi.advanceTimersByTime(200)` to trigger the timeout. -- **Expected result**: Rejects with timeout error. -- **Automation**: vitest with `vi.useFakeTimers()` and `vi.advanceTimersByTime()`. Restore with `vi.useRealTimers()` in `afterEach`. - -### 5.2 Library API (LocalJobContext) - -These verify the programmatic API used by other tools (e.g., `nevermore-cli`). - -- **Test name**: `StudioBridge (re-exported as StudioBridge) constructor accepts same options as before` -- **Priority**: P0 -- **Type**: unit -- **Setup**: None. -- **Steps**: - 1. `new StudioBridge({ placePath: '/test.rbxl', timeoutMs: 5000 })` -- no errors. -- **Expected result**: Constructor does not throw. No new required options. -- **Automation**: vitest. - ---- - -- **Test name**: `StudioBridge.startAsync + executeAsync + stopAsync lifecycle works` -- **Priority**: P0 -- **Type**: integration -- **Setup**: Mocked external deps. -- **Steps**: - 1. Call `startAsync()`, connect mock plugin, `executeAsync({ scriptContent: '...' })`, `stopAsync()`. -- **Expected result**: Identical behavior to existing tests in `studio-bridge-server.test.ts`. -- **Automation**: vitest. This is effectively a duplicate of the existing test suite running against the modified code. - ---- - -- **Test name**: `index.ts still exports all v1 types` -- **Priority**: P0 -- **Type**: unit -- **Setup**: None. -- **Steps**: - 1. Import all v1 exports from `@quenty/studio-bridge`: `StudioBridge`, `StudioBridgeServerOptions`, `ExecuteOptions`, `StudioBridgeResult`, `StudioBridgePhase`, `OutputLevel`, `findStudioPathAsync`, `findPluginsFolder`, `launchStudioAsync`, `injectPluginAsync`, `encodeMessage`, `decodePluginMessage`, `PluginMessage`, `ServerMessage`, `HelloMessage`, `OutputMessage`, `ScriptCompleteMessage`, `WelcomeMessage`, `ExecuteMessage`, `ShutdownMessage`. -- **Expected result**: All imports resolve without errors. -- **Automation**: vitest, import assertion test. - ---- - -- **Test name**: `index.ts also exports new v2 types` -- **Priority**: P1 -- **Type**: unit -- **Setup**: None. -- **Steps**: - 1. Import new exports: `BridgeConnection`, `BridgeSession`, `decodeServerMessage`, `RegisterMessage`, `StateResultMessage`, `ScreenshotResultMessage`, `DataModelResultMessage`, `LogsResultMessage`, `StateChangeMessage`, `HeartbeatMessage`, `SubscribeResultMessage`, `UnsubscribeResultMessage`, `PluginErrorMessage`, `QueryStateMessage`, `CaptureScreenshotMessage`, `QueryDataModelMessage`, `QueryLogsMessage`, `SubscribeMessage`, `UnsubscribeMessage`, `ServerErrorMessage`, `Capability`, `ErrorCode`, `StudioState`, `SerializedValue`, `DataModelInstance`. -- **Expected result**: All imports resolve. -- **Automation**: vitest. - -### 5.3 Protocol v1 Backward Compatibility - -- **Test name**: `v1 plugin (no protocolVersion) receives v1 welcome and can execute scripts` -- **Priority**: P0 -- **Type**: integration -- **Setup**: Start v2 server. Connect a v1 mock client. -- **Steps**: - 1. Send v1 `hello`. - 2. Receive v1 `welcome` (no `protocolVersion`, no `capabilities`). - 3. Receive `execute` message. - 4. Send `output` + `scriptComplete`. -- **Expected result**: Full v1 execute cycle works on the v2 server. No `requestId` on any message. -- **Automation**: vitest. - ---- - -- **Test name**: `v1 plugin ignores unknown v2 messages gracefully` -- **Priority**: P0 -- **Type**: integration -- **Setup**: Start v2 server. Connect v1 mock client. -- **Steps**: - 1. Complete v1 handshake. - 2. Server accidentally sends a `queryState` message to the v1 client (this should never happen, but test robustness). - 3. v1 client's message handler encounters an unknown type. -- **Expected result**: The v1 client's decoder returns `null` for the unknown type. No crash. No disconnect. -- **Automation**: vitest. - ---- - -- **Test name**: `v2 plugin sending heartbeat to v1 server does not crash` -- **Priority**: P1 -- **Type**: integration -- **Setup**: Start a mock v1 server (existing code path). Connect v2 mock client. -- **Steps**: - 1. Complete handshake (v1 welcome). - 2. v2 client sends `heartbeat` message. -- **Expected result**: v1 server's `decodePluginMessage` returns `null` for heartbeat. Server ignores it. No error. -- **Automation**: vitest. - ---- - -- **Test name**: `v2 plugin register to v1 server falls back to hello after 3 seconds` -- **Priority**: P0 -- **Type**: integration -- **Setup**: Start a mock v1 server that ignores `register` messages. Use `vi.useFakeTimers()`. -- **Steps**: - 1. Connect v2 mock client. - 2. v2 client sends `register`. - 3. `vi.advanceTimersByTime(3000)` to advance past the fallback timeout. - 4. Verify v2 client sends `hello` (fallback). - 5. v1 server sends v1 `welcome`. -- **Expected result**: Handshake completes after the fallback. Negotiated version is 1. -- **Automation**: vitest with `vi.useFakeTimers()` and `vi.advanceTimersByTime()`. Restore with `vi.useRealTimers()` in `afterEach`. - ---- - -## 6. Performance Validation - -- **Test name**: `PendingRequestMap handles 100 concurrent requests without degradation` -- **Priority**: P2 -- **Type**: unit -- **Setup**: None. -- **Steps**: - 1. Add 100 requests with 10-second timeouts. - 2. Resolve all 100 in random order. - 3. Measure total time. -- **Expected result**: All 100 resolve. Total time under 100ms (excluding timer overhead). -- **Automation**: vitest, `performance.now()`. - ---- - -- **Test name**: `Session registry handles 50 concurrent sessions` -- **Priority**: P2 -- **Type**: unit -- **Setup**: Temp directory. -- **Steps**: - 1. Register 50 sessions. - 2. Call `listSessionsAsync()`. - 3. Release all 50. -- **Expected result**: List returns 50 sessions. All cleanup succeeds. -- **Automation**: vitest. - ---- - -- **Test name**: `Large screenshot payload (2MB base64) transmits without error` -- **Priority**: P1 -- **Type**: integration -- **Setup**: Start server, connect v2 mock plugin. -- **Steps**: - 1. Send `captureScreenshot` to mock plugin. - 2. Mock plugin responds with 2MB base64 string in `screenshotResult`. -- **Expected result**: Server receives and parses the full payload. No WebSocket frame errors. -- **Automation**: vitest, generate 2MB base64 string. - ---- - -- **Test name**: `DataModel query with depth=3 and 100+ instances serializes within timeout` -- **Priority**: P2 -- **Type**: integration -- **Setup**: Mock plugin constructs a large DataModel response (100 instances, 3 levels deep). -- **Steps**: - 1. Send `queryDataModel` with `depth: 3`. - 2. Mock plugin responds with the large result. -- **Expected result**: Response arrives within 10-second timeout. JSON parsing succeeds. -- **Automation**: vitest. - ---- - -- **Test name**: `Health endpoint responds under 50ms` -- **Priority**: P2 -- **Type**: integration -- **Setup**: Start server. -- **Steps**: - 1. Measure time for `GET /health` response. -- **Expected result**: Under 50ms. -- **Automation**: vitest, `performance.now()`. - ---- - -- **Test name**: `WebSocket connection + v2 handshake completes under 200ms` -- **Priority**: P2 -- **Type**: integration -- **Setup**: Start server. -- **Steps**: - 1. Measure time from WebSocket connection start to receiving `welcome`. -- **Expected result**: Under 200ms on localhost. -- **Automation**: vitest, `performance.now()`. - ---- - -## 7. Security Validation - -- **Test name**: `WebSocket connection with incorrect session ID in URL is rejected` -- **Priority**: P0 -- **Type**: integration -- **Setup**: Start server with session ID `'abc-123'`. -- **Steps**: - 1. Connect WebSocket to `ws://localhost:{port}/wrong-id`. -- **Expected result**: Connection is rejected (HTTP 404 or WebSocket close). No handshake occurs. -- **Automation**: vitest. (This already exists in the current tests -- verify it still passes.) - ---- - -- **Test name**: `WebSocket connection with correct URL but wrong sessionId in hello is rejected` -- **Priority**: P0 -- **Type**: integration -- **Setup**: Start server with session ID `'abc-123'`. -- **Steps**: - 1. Connect WebSocket to `ws://localhost:{port}/abc-123`. - 2. Send `hello` with `sessionId: 'wrong-id'` in the message body. -- **Expected result**: Server closes the connection. (This already exists in current tests.) -- **Automation**: vitest. - ---- - -- **Test name**: `Session ID is a valid UUIDv4` -- **Priority**: P1 -- **Type**: unit -- **Setup**: Create a `StudioBridgeServer` with default options (no explicit session ID). -- **Steps**: - 1. Inspect the auto-generated session ID. -- **Expected result**: Matches UUID v4 format: `xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx`. -- **Automation**: vitest, regex match. - ---- - -- **Test name**: `Health endpoint does not leak sensitive information` -- **Priority**: P1 -- **Type**: integration -- **Setup**: Start server. -- **Steps**: - 1. Call `GET /health`. - 2. Inspect response body. -- **Expected result**: Response contains only: `status`, `sessionId`, `port`, `protocolVersion`, `serverVersion`. No file paths, no PIDs, no auth tokens. -- **Automation**: vitest, verify exact keys. - ---- - -- **Test name**: `Plugin error messages do not leak internal stack traces to CLI output` -- **Priority**: P1 -- **Type**: integration -- **Setup**: Start server, connect v2 mock plugin. -- **Steps**: - 1. Send `queryDataModel` for a non-existent path. - 2. Mock plugin responds with `error` including `details: { internalStack: '...' }`. - 3. Verify the CLI-facing error message does not include the internal stack. -- **Expected result**: CLI error shows "No instance found at path: ..." without internal details. -- **Automation**: vitest, capture output. - ---- - -- **Test name**: `Server rejects second plugin connection on same session` -- **Priority**: P0 -- **Type**: integration -- **Setup**: Start server, connect first mock plugin and complete handshake. -- **Steps**: - 1. Connect a second WebSocket client to the same URL. - 2. Send `hello` from the second client. -- **Expected result**: Server rejects or closes the second connection. First connection remains active. -- **Automation**: vitest. - ---- - -- **Test name**: `execute payload does not allow script injection beyond the provided string` -- **Priority**: P1 -- **Type**: integration -- **Setup**: Start server, connect mock plugin. -- **Steps**: - 1. Execute a script containing string interpolation attempts: `'print("hi"); --[[evil]]'`. - 2. Verify the mock plugin receives exactly the provided string in `payload.script`. -- **Expected result**: The script string is transmitted verbatim. No interpretation or modification. -- **Automation**: vitest. - ---- - -- **Test name**: `Registry files have restrictive permissions (user-only read/write)` -- **Priority**: P2 -- **Type**: unit -- **Setup**: Create a session file. -- **Steps**: - 1. Check file mode of the created session file. -- **Expected result**: File mode is `0o600` (owner read/write only) on Linux/macOS. -- **Automation**: vitest, `fs.statSync`, skip on Windows. - ---- - -- **Test name**: `Daemon authentication token is required for CLI-to-daemon connections` -- **Priority**: P1 -- **Type**: integration -- **Setup**: Start daemon with token written to session file. -- **Steps**: - 1. Connect CLI client without providing the token. - 2. Attempt to send a command. -- **Expected result**: Connection is rejected or command fails with auth error. -- **Automation**: vitest. - ---- - -- **Test name**: `Daemon authentication token is accepted for valid CLI-to-daemon connections` -- **Priority**: P1 -- **Type**: integration -- **Setup**: Start daemon. Read token from session file. -- **Steps**: - 1. Connect CLI client with the correct token. - 2. Send a command. -- **Expected result**: Command executes successfully. -- **Automation**: vitest. diff --git a/studio-bridge/plans/execution/validation/shared-test-utilities.md b/studio-bridge/plans/execution/validation/shared-test-utilities.md deleted file mode 100644 index 1fb2f35821..0000000000 --- a/studio-bridge/plans/execution/validation/shared-test-utilities.md +++ /dev/null @@ -1,229 +0,0 @@ -# Shared Test Utilities - -Standardized test infrastructure used across all validation phases. All test files that interact with a plugin connection MUST use the `MockPluginClient` defined here instead of ad-hoc WebSocket mocks. - -**Applies to**: Phases 1, 2, 3, 4, 5, 6 - ---- - -## MockPluginClient - -A reusable test helper that simulates a v2 plugin connecting to the bridge server over WebSocket. Encapsulates the connection lifecycle (register/welcome handshake), heartbeat auto-response, and action handling so that individual test files do not need to manage raw WebSocket frames. - -### Interface - -```typescript -import { EventEmitter } from "node:events"; - -/** Session context for multi-context support */ -type SessionContext = "edit" | "client" | "server"; - -interface MockPluginClientOptions { - /** Port to connect to. Default: 38741 */ - port?: number; - /** Instance ID to register with. Default: auto-generated UUID */ - instanceId?: string; - /** Session context. Default: 'edit' */ - context?: SessionContext; - /** Protocol version to advertise. Default: 2 */ - protocolVersion?: number; - /** Whether to auto-respond to heartbeats. Default: true */ - autoHeartbeat?: boolean; - /** Delay before responding to actions (ms). Default: 0 (immediate) */ - responseDelay?: number; - /** Capabilities to advertise. Default: all v2 capabilities */ - capabilities?: string[]; - /** Plugin version string. Default: '1.0.0' */ - pluginVersion?: string; - /** Place name to register with. Default: 'TestPlace' */ - placeName?: string; - /** Initial studio state. Default: 'Edit' */ - state?: string; - /** Place ID. Default: undefined */ - placeId?: number; - /** Game ID. Default: undefined */ - gameId?: number; -} - -class MockPluginClient { - constructor(options?: MockPluginClientOptions); - - /** Connect and complete register/welcome handshake */ - connectAsync(): Promise; - - /** Disconnect cleanly (sends proper close frame) */ - disconnectAsync(): Promise; - - /** Get the assigned session ID (available after connect) */ - get sessionId(): string; - - /** Get the instance ID this mock was created with */ - get instanceId(): string; - - /** Get the context this mock was created with */ - get context(): SessionContext; - - /** Register a handler for a specific action type (e.g., 'queryState', 'execute') */ - onAction(type: string, handler: (request: ActionRequest) => any): void; - - /** Get all messages received from the server (for assertions) */ - get receivedMessages(): BaseMessage[]; - - /** Send a raw string over the WebSocket (for testing malformed inputs) */ - sendRaw(data: string): void; - - /** Simulate a crash (close WebSocket abruptly without clean shutdown) */ - crash(): void; - - /** Whether the WebSocket is currently connected */ - get isConnected(): boolean; -} - -interface ActionRequest { - type: string; - requestId: string; - sessionId: string; - payload: Record; -} - -interface BaseMessage { - type: string; - sessionId?: string; - requestId?: string; - payload?: Record; -} -``` - -### Design Decisions - -The following questions were raised in the final review and are answered here: - -#### Response delay - -Configurable via the `responseDelay` option. Default is `0` (immediate). When set to a nonzero value, the mock waits the specified number of milliseconds before sending any action response. This allows tests to exercise timeout logic, concurrent request handling, and partial-response scenarios without modifying the mock between tests. - -When `vi.useFakeTimers()` is active (which it MUST be for all timing-sensitive tests -- see the Testing Conventions section in `agent-prompts/00-prerequisites.md`), the response delay is scheduled via `setTimeout` and advanced deterministically with `vi.advanceTimersByTime()`. The mock does NOT use `Date.now()` or wall-clock measurements internally. - -#### Buffering / batching - -The mock sends responses immediately (no batching). Each action response is sent as a single WebSocket frame as soon as the response delay (if any) has elapsed. This matches the behavior of the real plugin, which also sends responses individually. Tests that need to verify batching behavior on the server side can use multiple `MockPluginClient` instances or the `sendRaw` method. - -#### Protocol version - -Configurable via the `protocolVersion` option. Default is `2`. Set to `1` (or omit `protocolVersion` from the register message) to simulate a v1 plugin. When `protocolVersion` is `1`, the mock sends a v1 `hello` message instead of a v2 `register` message during `connectAsync()`, and does not send heartbeats or handle action requests (since v1 plugins do not support these). - -#### WebSocket interface - -The underlying WebSocket connection is internal to the mock and not directly exposed. Tests interact with the mock through the high-level methods (`connectAsync`, `disconnectAsync`, `onAction`, `sendRaw`, `crash`). This ensures tests are not coupled to WebSocket implementation details and can focus on protocol-level behavior. - -The `sendRaw` method provides an escape hatch for tests that need to send malformed data, partial frames, or non-JSON content. The `crash` method closes the underlying socket without a clean WebSocket close handshake, simulating an abrupt plugin crash or network failure. - -### Usage Examples - -#### Basic connection test - -```typescript -import { MockPluginClient } from "../test-utils/mock-plugin-client.js"; - -it("connects and registers", async () => { - const mock = new MockPluginClient({ port: server.port }); - await mock.connectAsync(); - expect(mock.sessionId).toBeDefined(); - await mock.disconnectAsync(); -}); -``` - -#### Action handler test - -```typescript -it("handles queryState action", async () => { - const mock = new MockPluginClient({ port: server.port }); - await mock.connectAsync(); - - mock.onAction("queryState", (request) => ({ - state: "Edit", - placeId: 123, - placeName: "TestPlace", - gameId: 456, - })); - - const result = await server.performActionAsync({ - type: "queryState", - sessionId: mock.sessionId, - }); - - expect(result.state).toBe("Edit"); - await mock.disconnectAsync(); -}); -``` - -#### Timeout test with fake timers - -```typescript -it("rejects on action timeout", async () => { - vi.useFakeTimers(); - - const mock = new MockPluginClient({ port: server.port }); - await mock.connectAsync(); - - // Do NOT register an action handler -- mock will not respond - const promise = server.performActionAsync({ - type: "queryState", - sessionId: mock.sessionId, - timeoutMs: 5000, - }); - - vi.advanceTimersByTime(5000); - await expect(promise).rejects.toThrow("timed out"); - - await mock.disconnectAsync(); - vi.useRealTimers(); -}); -``` - -#### Multi-context Play mode test - -```typescript -it("handles Play mode with 3 contexts", async () => { - const instanceId = "inst-1"; - const edit = new MockPluginClient({ port: server.port, instanceId, context: "edit" }); - const client = new MockPluginClient({ port: server.port, instanceId, context: "client" }); - const serverCtx = new MockPluginClient({ port: server.port, instanceId, context: "server" }); - - await edit.connectAsync(); - await client.connectAsync(); - await serverCtx.connectAsync(); - - const sessions = await connection.listSessionsAsync(); - expect(sessions).toHaveLength(3); - - await client.disconnectAsync(); - await serverCtx.disconnectAsync(); - await edit.disconnectAsync(); -}); -``` - -#### Simulating a crash - -```typescript -it("detects plugin crash", async () => { - const mock = new MockPluginClient({ port: server.port }); - await mock.connectAsync(); - - mock.crash(); // Abrupt close, no clean shutdown - - // Server should detect the disconnect and remove the session - await vi.waitFor(() => { - expect(connection.listSessions()).toHaveLength(0); - }); -}); -``` - -### File Location - -The implementation should live at: -``` -tools/studio-bridge/src/test-utils/mock-plugin-client.ts -``` - -This is a test-only utility and MUST NOT be exported from the package's public API (`index.ts`). It should be importable from any test file within the `studio-bridge` package. diff --git a/studio-bridge/plans/prd/main.md b/studio-bridge/plans/prd/main.md deleted file mode 100644 index aff8eb81ed..0000000000 --- a/studio-bridge/plans/prd/main.md +++ /dev/null @@ -1,362 +0,0 @@ -# Studio-Bridge Persistent Sessions PRD - -## Problem Statement - -Studio-bridge currently requires launching a fresh Roblox Studio instance for every interaction. The `exec` and `run` commands each spin up a new Studio process, inject a temporary plugin, wait for it to connect, execute a single script, and tear everything down. This takes 15-30 seconds per invocation on a fast machine and over a minute on slower hardware. The `terminal` command partially addresses this by keeping a single Studio session alive for multiple executions, but it still requires launching Studio from scratch when the terminal starts. - -There is no way to connect to a Studio session that is already running. Developers who keep Studio open all day -- the overwhelming majority of Roblox developers -- cannot use studio-bridge without closing and relaunching Studio through the CLI. This makes the tool impractical for iterative workflows and completely unusable for AI agents that need to inspect, query, or interact with a running game. - -The lack of persistent sessions also prevents building higher-level capabilities. There is no way to ask "what state is Studio in?", capture a screenshot of the viewport, query the DataModel for instances and properties, or tail the output log of a running session. These are all things that require a persistent, discoverable connection to an already-running Studio. - -This PRD defines the requirements for persistent session support: the ability to discover running Studio sessions, connect to them, and interact with them through a rich set of capabilities beyond script execution. - -## User Stories - -### Developer working in Studio - -> As a Roblox developer with Studio already open, I want to run a Luau script in my existing Studio session from the command line, so that I don't have to relaunch Studio every time I want to test something. - -> As a developer debugging a problem, I want to query the DataModel from the terminal to inspect instance properties and service state, so that I can understand what's happening without adding print statements and re-running. - -> As a developer working on UI, I want to capture a screenshot of the Studio viewport from the command line, so that I can quickly verify visual changes without switching windows. - -### AI agent using MCP - -> As an AI coding agent connected via MCP, I want to discover all running Studio sessions and connect to one, so that I can execute Luau code and inspect results on the user's behalf. - -> As an AI agent, I want to query the DataModel to understand the current state of the game (what instances exist, what properties they have, what services are loaded), so that I can provide contextually relevant assistance. - -> As an AI agent, I want to capture a screenshot to see what the user sees in the viewport, so that I can debug visual issues or verify that a UI change looks correct. - -> As an AI agent, I want to check whether Studio is in Edit mode, Play mode, or Paused, so that I can decide whether to execute code in the command bar context or the running game context. - -### Developer managing multiple sessions - -> As a developer working on multiple places simultaneously, I want to list all running Studio sessions and choose which one to interact with, so that I can target the right session without ambiguity. - -> As a developer, I want studio-bridge to remember which session I was last connected to, so that I don't have to specify a session ID every time. - -## Feature Requirements - -### F1: Session Discovery - -Users must be able to list all running Studio sessions that have the studio-bridge plugin active. - -A single Roblox Studio instance can produce multiple simultaneous sessions. The Edit plugin instance is always running and connected to the bridge host. When a developer enters Play mode, Studio creates two additional plugin instances: one for the simulated server and one for the simulated client. The edit instance continues running unchanged -- it is never stopped or restarted by Play mode transitions. Each of the 3 concurrent plugin instances has its own WebSocket connection to the bridge host. They share the same **instance ID** but receive distinct **session IDs** and report different **contexts**. - -**Instance**: A group of sessions originating from the same Studio installation. Sessions within an instance share an `instanceId` (a stable identifier stored in plugin settings, unique per Studio installation). An instance always has 1 session for the Edit context (which runs continuously), and up to 3 sessions total when the developer enters Play mode (the existing Edit session plus 2 new sessions for Client and Server). - -**Session context** (`SessionContext`): One of `edit`, `client`, or `server`. The `edit` context is always present. The `client` and `server` contexts appear only while Studio is in Play mode and disappear when the developer stops the session. - -Each session entry must include: -- **Session ID** -- a stable identifier for the session (not a PID; survives Studio restarts if the plugin reconnects) -- **Instance ID** -- the grouping key that identifies which Studio installation this session belongs to. All sessions from the same Studio instance share this value. -- **Context** -- the session context: `edit`, `client`, or `server` -- **Origin** -- how the session was created: `user` (developer opened Studio manually; persistent plugin connected on its own) or `managed` (studio-bridge launched Studio via `exec`, `run`, `terminal`, or `launch`). This field is critical for both humans and AI agents: it determines cleanup behavior (managed sessions are killed on exit; user sessions are left running) and communicates intent (a `user` session belongs to the developer; a `managed` session was created by tooling and can be safely torn down). -- **Place name** -- the human-readable name of the open place (e.g., "TestPlace" or "My Game") -- **Place file path** -- the file path of the `.rbxl` file, if available -- **Place ID** -- the Roblox place ID, if the place has been published -- **Game ID** -- the Roblox universe/game ID, if applicable -- **Connection status** -- whether the session is currently connected, was connected but dropped, or is connecting -- **Uptime** -- how long the session has been connected - -The session list must update in real time when sessions connect or disconnect. When no sessions are available, the CLI must clearly indicate this rather than hanging or timing out silently. - -### F2: Studio State - -Users must be able to query the current state of a Studio session. - -Each session context has its own independent state. The Edit context is always present and reflects the editing DataModel. The Client and Server contexts only exist while Studio is in Play mode -- they appear when the developer presses Play and disappear when they press Stop. Querying state on a Client or Server context while Studio is not in Play mode is an error. - -The state response must include: -- **Context** -- which context this state belongs to (`edit`, `client`, or `server`) -- **Run mode** -- Edit, Play (Client), Play (Server), Play (Paused), or Run -- **Place name** -- the name of the currently open place -- **Place ID** -- the Roblox place ID, if the place has been published -- **Game ID** -- the Roblox universe/game ID, if applicable - -State must be queryable both as a one-shot request and as a subscription (for agents that want to react to state changes). The plugin must detect state transitions (e.g., the user pressing Play or Stop) and report them without polling. When the developer enters or exits Play mode, the appearance and disappearance of Client and Server sessions must be reported as session lifecycle events (connect/disconnect), not as state changes on the Edit session. - -### F3: Screenshots - -Users must be able to capture a screenshot of the Studio 3D viewport. - -Requirements: -- Capture must use Roblox's `CaptureService` API (or equivalent) to get the actual rendered viewport, not a window screenshot -- The image must be returned as a file path to a PNG on disk (written to a temp directory) -- The CLI must print the file path to stdout so it can be consumed by scripts and pipelines -- For MCP consumers, the image must be returned as base64-encoded data in the tool response -- Capture must work in both Edit and Play modes -- Capture must fail gracefully with a clear error if the viewport is not available (e.g., Studio is minimized on some platforms) - -### F4: Output Logs - -Users must be able to retrieve and follow the output log of a connected Studio session. - -Three modes: -- **Tail** -- show the last N lines of output (default: 50) -- **Head** -- show the first N lines captured since the plugin connected -- **Follow** -- stream new output lines in real time until interrupted (Ctrl+C) - -Requirements: -- The plugin must buffer output logs so that lines generated before the CLI connects are still available (up to a configurable ring buffer size, default: 1000 lines) -- Each log line must include its timestamp and level (Print, Info, Warning, Error) -- The follow mode must support optional level filtering (e.g., show only Warnings and Errors) -- Internal `[StudioBridge]` messages must be filtered out by default (with a `--all` flag to include them) - -### F5: DataModel Queries - -Users must be able to query the Roblox DataModel to inspect instances, properties, attributes, and services. - -The query system must support: -- **Instance lookup by path** -- e.g., `Workspace.SpawnLocation` or `ReplicatedStorage.Modules.MyModule` -- **Property reading** -- get the value of a named property on an instance (e.g., `Workspace.SpawnLocation.Position`) -- **Attribute reading** -- get the value of a named attribute on an instance -- **Children listing** -- list all children of an instance, with their ClassName and Name -- **Service listing** -- list all services currently loaded in the DataModel -- **FindFirstChild / FindFirstDescendant** -- find instances by name, optionally recursive - -The query expression format must be a simple dot-separated path (e.g., `Workspace.Camera.CFrame`), not raw Luau. The plugin translates this into the appropriate API calls and returns structured JSON, not stringified output. This is intentionally more constrained than `exec` -- it provides structured, predictable output suitable for programmatic consumption. - -The response for an instance query must include: -- **ClassName** -- the Roblox class name -- **Name** -- the instance name -- **Properties** -- a selected set of commonly useful properties (at minimum: Name, ClassName, Parent path) -- **Children count** -- how many children the instance has - -Property values must be serialized to JSON-compatible types. CFrames, Vector3s, Color3s, and other Roblox types must have a consistent string or object representation. - -### F6: Script Execution (Adaptation) - -The existing `exec` and `run` commands must be adapted to work with persistent sessions and the multi-context session model. - -#### Session Resolution Cascade - -Session resolution is a two-step process: first resolve the **instance**, then resolve the **context** within that instance. - -**Step 1: Instance resolution** -- When `--session ` is provided, use the session directly (skip context resolution -- the session already identifies a specific context). -- When no `--session` is provided and exactly one instance is connected, select that instance automatically. -- When no `--session` is provided and multiple instances are connected, the CLI must list them and prompt the user to choose (or error in non-interactive mode). -- When no instances are connected, the current behavior (launch a new Studio) must be preserved as a fallback. - -**Step 2: Context resolution** (within the selected instance) -- When `--context ` is provided, use the session matching that context. Error if the requested context is not available (e.g., `--context server` when Studio is in Edit mode). -- When no `--context` is provided and the instance has only one session (Edit mode), select it automatically. -- When no `--context` is provided and the instance has multiple sessions (Play mode), default to the **Edit** context. Edit is the safest default: it is always present, and executing code there does not interfere with the running game simulation. - -This means the zero-flag happy path (`studio-bridge exec 'print("hi")'`) resolves to: sole instance, Edit context. Targeting a specific Play mode context requires the explicit `--context server` or `--context client` flag. - -#### Requirements - -- When a session ID is provided, `exec` and `run` must connect to the existing session instead of launching a new Studio instance -- Instance and context resolution must follow the cascade described above -- The `--context` flag (`edit`, `client`, or `server`) must be accepted on `exec`, `run`, and `terminal` commands -- Consumers can target any context independently -- for example, executing on the Server context to inspect server state while separately executing on the Client context to inspect client state -- The `terminal` command must also accept a session ID or `--context` flag to attach to a specific context within an existing session - -### F7: MCP Integration - -All capabilities (F1-F6) must be exposed as MCP (Model Context Protocol) tools so that AI agents can use them. - -Tools to expose: -- `studio_sessions` -- list all connected sessions (maps to F1) -- `studio_state` -- get the state of a session (maps to F2) -- `studio_screenshot` -- capture a viewport screenshot, returned as base64 (maps to F3) -- `studio_logs` -- retrieve log output (maps to F4) -- `studio_query` -- query the DataModel (maps to F5) -- `studio_exec` -- execute a Luau script (maps to F6) - -MCP requirements: -- The MCP server must run as a long-lived process (not spawn-per-request) -- The MCP server must share session state with the CLI (if a CLI terminal session is connected, MCP must see it too) -- Tool responses must use structured JSON, not formatted text -- Errors must use MCP error codes, not process exit codes -- The MCP server must be registerable as an MCP tool provider (e.g., in Claude Code's MCP configuration) - -## CLI Interface Design - -### Top-Level Commands - -``` -studio-bridge sessions List all connected Studio sessions -studio-bridge connect Connect to an existing session (interactive) -studio-bridge state [session-id] Get Studio state (run mode, place info) -studio-bridge screenshot [session-id] Capture a viewport screenshot -studio-bridge logs [session-id] Retrieve output logs -studio-bridge query [session-id] Query the DataModel -studio-bridge exec [session-id] Execute inline Luau code -studio-bridge run [session-id] Execute a Luau script file -studio-bridge terminal [session-id] Interactive REPL mode -``` - -When `[session-id]` is optional and omitted, the CLI uses the session resolution cascade (see F6): auto-select the sole instance, default to the Edit context. A `--session` / `-s` flag is also accepted as an alternative to the positional argument. - -**Context targeting**: Commands that interact with a session (`state`, `screenshot`, `logs`, `query`, `exec`, `run`, `terminal`) accept a `--context` / `-c` flag with values `edit`, `client`, or `server`. This selects which plugin context within an instance to target. When omitted, defaults to `edit`. - -### `studio-bridge sessions` - -Sessions are grouped by instance. Each instance represents a single Roblox Studio installation. Within an instance, sessions are listed by context. - -``` -$ studio-bridge sessions - Instance abc12345 (user) — TestPlace.rbxl [PlaceId: 1234567890] - SESSION ID CONTEXT STATE CONNECTED - a1b2c3d4-e5f6-7890-abcd-ef1234567890 edit Edit 2m 30s - - Instance def67890 (managed) — MyGame.rbxl [PlaceId: 9876543210] - SESSION ID CONTEXT STATE CONNECTED - f9e8d7c6-b5a4-3210-fedc-ba0987654321 edit Play 15m 42s - b2c3d4e5-f6a7-8901-bcde-f12345678901 client Play 15m 40s - c3d4e5f6-a7b8-9012-cdef-123456789012 server Play 15m 40s - -2 instances, 4 sessions connected. -``` - -In the example above, instance `abc12345` is in Edit mode (1 session). Instance `def67890` is in Play mode, so it has 3 sessions: the Edit context (still present), plus the Client and Server contexts that appeared when the developer pressed Play. - -Flags: -- `--json` -- output as JSON array (for scripting and MCP) -- `--watch` -- continuously update the list (like `watch`) - -### `studio-bridge connect ` - -Enters an interactive terminal session attached to the specified Studio session. Equivalent to `studio-bridge terminal ` but with a name that makes the "attach to existing" intent clear. - -### `studio-bridge state [session-id]` - -``` -$ studio-bridge state -Place: TestPlace -PlaceId: 1234567890 -GameId: 9876543210 -Mode: Edit -``` - -Flags: -- `--json` -- output as JSON -- `--watch` -- continuously print state changes - -### `studio-bridge screenshot [session-id]` - -``` -$ studio-bridge screenshot -Screenshot saved to /tmp/studio-bridge/screenshot-2026-02-20-143022.png -``` - -Flags: -- `--output` / `-o` -- specify output file path (default: temp directory with timestamp) -- `--open` -- open the screenshot in the default image viewer after capture -- `--base64` -- print base64-encoded PNG to stdout instead of writing a file - -### `studio-bridge logs [session-id]` - -``` -$ studio-bridge logs -$ studio-bridge logs --tail 100 -$ studio-bridge logs --follow -$ studio-bridge logs --follow --level Error,Warning -$ studio-bridge logs --head 20 -``` - -Flags: -- `--tail ` -- show last N lines (default: 50) -- `--head ` -- show first N lines -- `--follow` / `-f` -- stream new output in real time -- `--level ` -- comma-separated level filter (Print, Info, Warning, Error) -- `--all` -- include internal `[StudioBridge]` messages -- `--json` -- output each line as a JSON object with timestamp, level, body - -### `studio-bridge query [session-id]` - -``` -$ studio-bridge query Workspace.SpawnLocation -{ - "className": "SpawnLocation", - "name": "SpawnLocation", - "path": "Workspace.SpawnLocation", - "childCount": 0, - "properties": { - "Position": { "x": 0, "y": 4, "z": 0 }, - "Anchored": true, - "Duration": 0 - } -} - -$ studio-bridge query Workspace --children -[ - { "name": "Camera", "className": "Camera" }, - { "name": "Terrain", "className": "Terrain" }, - { "name": "SpawnLocation", "className": "SpawnLocation" } -] - -$ studio-bridge query StarterPlayer.StarterPlayerScripts --descendants -``` - -Flags: -- `--children` -- list immediate children instead of querying the instance itself -- `--descendants` -- list all descendants (tree) -- `--properties ` -- comma-separated list of property names to include -- `--attributes` -- include all attributes -- `--json` -- output as JSON (this is the default; `--pretty` for formatted) -- `--depth ` -- max depth for `--descendants` (default: 1) - -### Existing Commands (Adapted) - -The `exec`, `run`, and `terminal` commands gain the optional `[session-id]` positional argument, `--session` / `-s` flag, and `--context` / `-c` flag. Their existing flags remain unchanged. The `--context` flag accepts `edit`, `client`, or `server` and selects which plugin context to target within the resolved instance. - -## Terminal Mode Extensions - -When in terminal mode (whether launched via `studio-bridge terminal` or `studio-bridge connect`), the following dot-commands are added alongside the existing `.help`, `.exit`, `.run`, and `.clear`: - -| Command | Description | -|---------|-------------| -| `.sessions` | List all connected Studio sessions | -| `.connect ` | Switch to a different session (if in multi-session mode) | -| `.state` | Show the current session's Studio state | -| `.screenshot [path]` | Capture a viewport screenshot | -| `.logs [--tail N \| --follow]` | Show or follow output logs | -| `.query ` | Query the DataModel | -| `.disconnect` | Disconnect from the current session without killing Studio | - -The `.help` output must be updated to include these new commands. - -When connected to a `user`-origin session (i.e., the developer started Studio manually), the `.exit` command must disconnect without killing Studio. The existing behavior of killing Studio on exit must only apply to `managed`-origin sessions (sessions that studio-bridge launched itself). The origin is always visible in session listings so humans and agents can make informed decisions about session lifecycle. - -## Non-Goals - -The following are explicitly out of scope for this project: - -- **Remote Studio connections** -- All connections are localhost only. Connecting to Studio on a different machine over a network is not supported. -- **Multiple simultaneous CLI connections to one session** -- A single Studio session has one WebSocket connection at a time. If a second client connects, the first is disconnected. -- **Automatic plugin installation** -- The persistent plugin must still be installed manually or via `studio-bridge install-plugin`. We do not auto-install plugins into the user's Studio without their explicit action. -- **Place file editing** -- studio-bridge does not modify the place file's DataModel (inserting instances, changing properties from the CLI). It is read-only plus script execution. Write operations happen via `exec`. -- **Source code syncing** -- Rojo handles file syncing. studio-bridge does not replicate or replace any Rojo functionality. -- **Play Solo / Team Test orchestration** -- Programmatically launching Play mode, starting server/client sessions, or coordinating team test is out of scope. Users can trigger these via `exec` if needed. However, **exposing the existing Play mode contexts is a goal**: when a developer has already entered Play mode, studio-bridge surfaces the Client and Server sessions and allows targeting them independently. The non-goal is orchestrating *entry into* Play mode, not interacting with sessions that already exist. -- **Studio version management** -- studio-bridge does not install, update, or manage Roblox Studio versions. -- **Authentication** -- No login or API key management. studio-bridge relies on the user's existing Studio auth session. - -## Success Metrics - -### Adoption Metrics - -- **Session reuse rate** -- Percentage of `exec`/`run` invocations that connect to an existing session rather than launching a new one. Target: >80% within 3 months of release for users who have the persistent plugin installed. -- **MCP tool invocations** -- Number of MCP tool calls per day across all users. This is a leading indicator of AI agent adoption. Target: measurable growth month-over-month. - -### Performance Metrics - -- **Time to first execution (cold start)** -- Time from `studio-bridge exec` to script output when launching a new Studio. Baseline: 15-30s. Target: no regression from current. -- **Time to first execution (warm start)** -- Time from `studio-bridge exec` to script output when connecting to an existing session. Target: <2 seconds. -- **Screenshot latency** -- Time from `studio-bridge screenshot` to file written. Target: <3 seconds. -- **Query latency** -- Time from `studio-bridge query` to JSON response. Target: <1 second. - -### Reliability Metrics - -- **Session reconnection rate** -- When Studio is still running but the WebSocket drops (e.g., CLI process was killed), the plugin must reconnect within 5 seconds of the next CLI invocation. -- **Stale session cleanup** -- Sessions where Studio has quit must be removed from the session list within 10 seconds. -- **Graceful degradation** -- All commands must fail with a clear error message within the timeout period. No hanging indefinitely. - -### User Experience Metrics - -- **Zero-config happy path** -- A user with the persistent plugin installed and one Studio instance open must be able to run `studio-bridge exec 'print("hi")'` with no flags and get output. No session ID, no port, no configuration. This works regardless of whether Studio is in Edit mode or Play mode: in Edit mode there is exactly one session (auto-selected); in Play mode there are three sessions but the resolution cascade defaults to the Edit context (always present, does not interfere with the running game). Targeting a Play mode context requires the explicit `--context` flag, which is the expected progressive-disclosure tradeoff. -- **Error message clarity** -- Every error message must include what went wrong, why, and what the user can do about it (e.g., "No Studio sessions found. Is Studio running with the studio-bridge plugin installed? See: "). diff --git a/studio-bridge/plans/research/open-cloud-websocket-feasibility.md b/studio-bridge/plans/research/open-cloud-websocket-feasibility.md deleted file mode 100644 index 114df38803..0000000000 --- a/studio-bridge/plans/research/open-cloud-websocket-feasibility.md +++ /dev/null @@ -1,244 +0,0 @@ -# Open Cloud WebSocket Feasibility Research - -**Date:** 2026-02-20 -**Question:** Can the cloud test runner connect to the bridge host via WebSocket, unifying the plugin test path with the cloud batch test runner? - -## Executive Summary - -**Recommendation: Not feasible via WebSocket. Partially feasible via HTTP long-polling, but not worth the complexity.** - -WebSocket connections from Roblox cloud game servers (RCC) are explicitly blocked. HTTP outbound requests from Open Cloud Luau execution tasks are available (the blocklist was removed in November 2024), but the cloud execution environment cannot reach a developer's local machine. The fundamental networking topology makes real-time bidirectional communication between a cloud game server and a developer's localhost impractical without significant infrastructure (public tunnels, relay servers). The current two-path architecture (local Studio via WebSocket, cloud via Open Cloud Luau Execution API) is well-designed for each environment's constraints and should be maintained. - ---- - -## 1. WebSocket Capabilities in Roblox - -### Studio: Full WebSocket Support - -Roblox Studio supports WebSocket connections via `HttpService:CreateWebStreamClient()` with `Enum.WebStreamClientType.WebSocket`. This is what the studio-bridge plugin currently uses to connect to `ws://localhost:/`. - -Key facts: -- Maximum 4 concurrent WebStreamClient connections (shared with SSE) -- Studio-only: documentation explicitly states "This feature is restricted to Studio only. Any CreateWebStreamClient() requests made in a live experience will be blocked." -- Announced as available in Studio in a [2025 DevForum post](https://devforum.roblox.com/t/websockets-support-in-studio-is-now-available/4021932) - -### Game Servers (RCC): WebSocket Blocked - -`CreateWebStreamClient()` is **not available** in game servers. Attempting to use it returns the error `"WebStreamClient is not enabled in RCC"`. This is confirmed by: -- [DevForum discussion on Open Cloud Luau Execution](https://devforum.roblox.com/t/beta-open-cloud-engine-api-for-executing-luau/3172185/85) -- [Official HttpService documentation](https://create.roblox.com/docs/reference/engine/classes/HttpService) stating CreateWebStreamClient is Studio-only -- [SSE announcement](https://devforum.roblox.com/t/http-streaming-now-supports-server-sent-events-in-studio/3905367) confirming Studio-only restriction - -**Verdict: WebSocket from cloud game servers is not possible.** - -### Open Cloud Luau Execution Environment - -The Open Cloud Engine API for Executing Luau spins up a headless RCC instance. The same RCC restrictions apply. WebSocket is unavailable. - -## 2. HTTP Capabilities in Cloud Game Servers - -### HttpService in Open Cloud Luau Execution - -HttpService was initially blocked in the Open Cloud Luau Execution environment but the [engine API restrictions were lifted as of November 2024](https://devforum.roblox.com/t/beta-open-cloud-engine-api-for-executing-luau/3172185?page=3). Scripts executed via the Open Cloud Luau Execution API can now use: -- `HttpService:GetAsync()` -- `HttpService:PostAsync()` -- `HttpService:RequestAsync()` - -### Rate Limits - -- **External HTTP requests:** 500 requests per minute per game server -- **Open Cloud requests:** 2500 requests per minute -- **Port restrictions:** Ports below 1024 are blocked except 80 and 443. Ports 1024-65535 are allowed (except 1194). - -### Localhost / Private IP Restrictions - -Roblox game servers **cannot access localhost (127.0.0.1) or private IP addresses**. This is a security restriction that applies to all RCC environments. The game server runs in Roblox's cloud infrastructure, not on the developer's machine. - -This is the critical blocker: even if HttpService is available, the cloud game server cannot reach a developer's local bridge host. - -### Execution Constraints - -From the [Luau Execution documentation](https://create.roblox.com/docs/cloud/reference/features/luau-execution): -- Scripts can execute for up to **5 minutes** maximum -- Limited to **10 concurrent tasks** per place -- Maximum **450 KB** of output logs -- Script size up to **4 MB** -- Return value serialization up to **4 MB** (JSON) or **256 MiB** (binary) - -## 3. Alternative Transport: MessagingService - -[MessagingService](https://create.roblox.com/docs/reference/engine/classes/MessagingService) enables cross-server communication within a universe. There is also an [Open Cloud Messaging API](https://devforum.roblox.com/t/announcing-messaging-service-api-for-open-cloud/1863229) that allows external services to publish messages to game servers. - -### How It Could Work - -1. Bridge host publishes a message to the game server via Open Cloud Messaging API -2. Game server's Luau script subscribes to a topic and receives the message -3. Game server responds by making an HTTP POST to a publicly-reachable endpoint - -### Limitations - -- **Message size:** 1 KB maximum per message (extremely limiting for sending scripts) -- **Rate limits:** 600 + 240*players per minute for sending; 40 + 80*servers per minute for receiving per topic -- **Best-effort delivery:** Not guaranteed -- **Unidirectional from Open Cloud:** External services can only publish, not subscribe. The game server would need another channel to respond. -- **Requires a running game server:** MessagingService works within a live experience, not within Open Cloud Luau Execution tasks. - -**Verdict: MessagingService is not suitable for bidirectional command-and-control communication.** - -## 4. Current Architecture Analysis - -### Cloud Testing Path (nevermore-cli) - -The current cloud testing path is well-architected for the constraints: - -1. **`nevermore test --cloud`** or **`nevermore batch test --cloud`** invokes the CLI -2. **`CloudJobContext`** (`tools/nevermore-cli/src/utils/job-context/cloud-job-context.ts`): - - Uploads a built `.rbxl` place file via Open Cloud Place API - - Creates a Luau execution task via `OpenCloudClient.createExecutionTaskAsync()` - - Polls for task completion via `OpenCloudClient.pollTaskCompletionAsync()` (3-second intervals) - - Retrieves logs via `OpenCloudClient.getRawTaskLogsAsync()` -3. **`batch-test-runner.luau`** (`tools/nevermore-cli/templates/batch-test-runner.luau`): - - Runs inside the headless RCC instance - - Discovers test scripts via CollectionService tags - - Isolates packages by reparenting them in ServerScriptService - - Executes tests sequentially with `loadstring()` - - Outputs structured markers (`===BATCH_TEST_BEGIN===`, `===BATCH_TEST_END===`) for log parsing - - Reports results as JSON via `print(HttpService:JSONEncode(results))` -4. **Results flow back** via the Open Cloud logs API, parsed by `parseBatchTestLogs()` - -### Local Testing Path (studio-bridge) - -1. **`StudioBridgeServer`** (`tools/studio-bridge/src/server/studio-bridge-server.ts`): - - Starts a WebSocket server on a random port on localhost - - Injects a plugin `.rbxm` into Studio's plugins folder (port + session ID baked in) - - Launches Studio with the built place - - Plugin connects via WebSocket, does handshake (hello/welcome) - - Server sends `execute` messages with script content - - Plugin executes via `loadstring()`, streams output via LogService, sends `scriptComplete` -2. **`LocalJobContext`** (`tools/nevermore-cli/src/utils/job-context/local-job-context.ts`): - - Wraps `StudioBridgeServer` as a `JobContext` implementation - - Same interface as `CloudJobContext` (build, deploy, run script, get logs, release) - -### Key Difference - -| Aspect | Local (Studio) | Cloud (Open Cloud) | -|--------|---------------|-------------------| -| Transport | WebSocket (bidirectional, real-time) | Open Cloud REST API (poll-based) | -| Script delivery | WebSocket `execute` message | Luau Execution API `createExecutionTaskAsync` | -| Output streaming | Real-time via WebSocket `output` messages | Post-hoc via logs API | -| Execution host | Developer's machine (Studio) | Roblox cloud infrastructure (RCC) | -| Network reachability | localhost (same machine) | Internet-only (no localhost, no private IPs) | - -### The JobContext Abstraction Already Unifies - -The `JobContext` interface (`tools/nevermore-cli/src/utils/job-context/job-context.ts`) already provides the unification layer. Both `CloudJobContext` and `LocalJobContext` implement the same interface: -- `buildPlaceAsync()` -- `deployBuiltPlaceAsync()` -- `runScriptAsync()` -- `getLogsAsync()` -- `releaseAsync()` -- `disposeAsync()` - -The `BatchScriptJobContext` wraps either inner context transparently. From the batch runner's perspective, cloud and local are already interchangeable. - -## 5. What Unification Would Require - -### Option A: WebSocket from Cloud (Blocked) - -Not possible. `CreateWebStreamClient()` is Studio-only. - -### Option B: HTTP Long-Polling from Cloud - -The game server would need to poll a publicly-reachable bridge host for commands, and POST results back. - -**Requirements:** -1. Bridge host exposed to the internet (via ngrok, Cloudflare Tunnel, or a public server) -2. HTTP polling endpoint on the bridge host (GET for next command, POST for results) -3. Modified plugin that detects "cloud mode" and uses HttpService polling instead of WebSocket -4. Session management to match the polling game server to the correct bridge instance - -**Problems:** -- **Latency:** 0.5-3 second polling intervals, versus instant WebSocket delivery -- **Security:** Exposing the bridge host to the internet creates attack surface. The bridge can execute arbitrary Luau. An authenticated tunnel would be required. -- **Complexity:** HTTP polling transport adds significant complexity to the plugin for marginal benefit -- **5-minute timeout:** Open Cloud Luau Execution tasks time out at 5 minutes, limiting test duration -- **Reliability:** Network path is cloud server -> internet -> tunnel -> developer machine. Many points of failure. -- **No clear benefit over current approach:** The current log-based protocol works reliably for batch testing - -### Option C: External Relay Server - -A relay server (e.g., a lightweight WebSocket-to-HTTP bridge deployed on a cloud provider) could mediate: -1. Bridge host connects to relay via WebSocket (outbound, so no port opening needed) -2. Cloud game server polls relay via HTTP -3. Relay forwards messages bidirectionally - -**Problems:** -- Requires deploying and maintaining infrastructure -- Adds latency and a point of failure -- Cost and operational burden for a dev tool -- Same 5-minute timeout and other Open Cloud constraints still apply - -## 6. Feasibility Assessment - -### Blockers - -| Blocker | Severity | Notes | -|---------|----------|-------| -| No WebSocket in RCC | **Hard blocker** | Platform limitation, no workaround | -| No localhost access from RCC | **Hard blocker** | Cloud game servers cannot reach developer machines | -| 5-minute execution timeout | **Significant** | Limits interactive debugging sessions | -| Bridge host must be internet-reachable | **Significant** | Requires tunnel/relay infrastructure | -| 1 KB MessagingService limit | **Hard blocker** for MessagingService path | Cannot send scripts (often >1 KB) | - -### What Would Change - -Even with a working transport: -- Plugin would need a second boot mode (HTTP polling) alongside WebSocket -- Bridge host would need HTTP endpoints alongside WebSocket server -- Session discovery would need to work across the internet (not just localhost) -- Authentication would be required (API keys, tokens) -- The entire debugging experience would have higher latency - -### What Already Works - -The current architecture already achieves the core goal: -- **Same test runner logic:** `runSingleTestAsync()` works identically with both contexts -- **Same reporting:** `CompositeReporter` and all reporter types work with both paths -- **Same batch aggregation:** `BatchScriptJobContext` wraps either inner context -- **Same result format:** Both paths produce `SingleTestResult` with success + logs - -The only thing that differs is the transport layer, and that difference is inherent to the environment constraints. - -## 7. Recommendation - -**Do not pursue WebSocket/HTTP unification between cloud and local paths.** - -The current two-path architecture is the correct design for the platform constraints: -- **Local Studio testing** uses WebSocket for real-time bidirectional communication (the only environment where it works) -- **Cloud testing** uses the Open Cloud Luau Execution API for headless batch execution (the only way to run code in cloud game servers) -- **The `JobContext` interface** already provides the abstraction that makes both paths interchangeable from the caller's perspective - -### Where to Invest Instead - -If the goal is to improve the cloud testing experience, better investments would be: - -1. **Improve cloud test output fidelity:** The current log-based protocol loses message types (print vs warn vs error). The Open Cloud team has a [feature request](https://devforum.roblox.com/t/include-output-type-in-open-cloud-luau-execution-logs/3420642) for this. - -2. **Studio-bridge plugin improvements:** Make the local plugin more robust (reconnection, multiple script execution, persistent mode) as planned in the current tech specs. - -3. **Cloud test result streaming:** Instead of waiting for the entire execution to complete, poll the logs endpoint periodically during execution to provide incremental feedback. This would make cloud tests feel more interactive without requiring a WebSocket connection from the game server. - -4. **Watch mode for cloud:** When `--watch` is combined with `--cloud`, rebuild and re-upload on file changes. The transport is still Open Cloud API, but the iteration loop is faster. - -## Appendix: Sources - -- [Roblox HttpService Documentation](https://create.roblox.com/docs/reference/engine/classes/HttpService) -- [WebSocket Support in Studio Announcement](https://devforum.roblox.com/t/websockets-support-in-studio-is-now-available/4021932) -- [HTTP Streaming / SSE Announcement](https://devforum.roblox.com/t/http-streaming-now-supports-server-sent-events-in-studio/3905367) -- [Open Cloud Engine API for Executing Luau](https://devforum.roblox.com/t/beta-open-cloud-engine-api-for-executing-luau/3172185) -- [Luau Execution Documentation](https://create.roblox.com/docs/cloud/reference/features/luau-execution) -- [Port Restrictions for HttpService](https://devforum.roblox.com/t/port-restrictions-for-httpservice/1500073) -- [Open Cloud Messaging API](https://devforum.roblox.com/t/announcing-messaging-service-api-for-open-cloud/1863229) -- [MessagingService Documentation](https://create.roblox.com/docs/reference/engine/classes/MessagingService) -- [Cross-Server Messaging Guide](https://create.roblox.com/docs/cloud-services/cross-server-messaging) -- [Open Cloud via HttpService Without Proxies](https://devforum.roblox.com/t/use-open-cloud-via-httpservice-without-proxies/3656373) diff --git a/studio-bridge/plans/tech-specs/00-overview.md b/studio-bridge/plans/tech-specs/00-overview.md deleted file mode 100644 index 79ebd8fae5..0000000000 --- a/studio-bridge/plans/tech-specs/00-overview.md +++ /dev/null @@ -1,932 +0,0 @@ -# Architecture Overview: Technical Specification - -This is the top-level architecture document for studio-bridge persistent sessions. It describes the system-level design, key decisions, and how the components fit together. Detailed designs for individual subsystems are in the companion specs referenced throughout. - -Read this document first. It gives you the full picture in one place. The companion specs go deep on each subsystem. - -## Spec Documents - -| Document | Scope | -|----------|-------| -| `00-overview.md` | **This file.** Architecture overview, key decisions, component map, file layout, migration strategy, security | -| `01-protocol.md` | Wire protocol: message types, request/response correlation, capability negotiation, versioning, TypeScript type definitions | -| `02-command-system.md` | Unified command system: `CommandDefinition` interface, CLI/terminal/MCP adapters, session resolution, output formatting | -| `03-persistent-plugin.md` | Plugin Luau architecture: boot mode detection, discovery protocol, state machine, reconnection, action handlers, `PluginManager` API | -| `04-action-specs.md` | Per-action specification: CLI flags, terminal dot-command, MCP tool schema, wire messages, handler logic, error cases, timeouts | -| `05-split-server.md` | Devcontainer support: `studio-bridge serve` command, explicit bridge host, port forwarding, environment detection | -| `06-mcp-server.md` | MCP server: tool registration from `allCommands`, stdio transport, session auto-selection, error mapping, Claude Code configuration | -| `07-bridge-network.md` | **Authoritative networking spec.** `BridgeConnection` and `BridgeSession` public API, internal architecture (host, client, transport, session tracker, hand-off), role detection, host-client protocol, session lifecycle, testing strategy | - -## 1. Architecture Overview - -The persistent sessions system transforms studio-bridge from a launch-use-discard tool into a long-lived service that maintains connections to running Studio instances. The central design principle: **networking is completely abstracted away from consumers.** The public API is two classes (`BridgeConnection` and `BridgeSession`) and a handful of result types. Everything else -- ports, WebSockets, host/client roles, plugin discovery, hand-off protocol -- is internal to the networking layer and invisible to any code that uses studio-bridge. - -### What consumers see (the only public API) - -``` -┌─────────────────────────────────────────────────────┐ -│ Consumer Code │ -│ │ -│ const conn = await BridgeConnection.connectAsync() │ -│ const session = await conn.waitForSession() │ -│ await session.execAsync(...) │ -│ await session.queryStateAsync() │ -│ await session.captureScreenshotAsync() │ -│ │ -│ // Same code whether 1 Studio or 10 Studios │ -│ // Same code whether local or devcontainer │ -│ // Same code whether this process is host or client │ -│ // No ports, no WebSocket, no host/client roles │ -└──────────────────────────┬──────────────────────────┘ - │ - ┌───────────┴───────────┐ - │ BridgeConnection │ <- only public entry point - │ BridgeSession │ <- only public handle - │ SessionInfo, types │ <- only public data - └───────────┬───────────┘ - │ - ┌───────┴───────┐ - │ (networking) │ <- hidden, not importable - │ (transport) │ by consumer code - └───────────────┘ -``` - -### What is inside the networking layer (internal -- consumers never see this) - -The networking layer handles all the complexity of multi-process coordination. The topology is **many-to-one**: many plugins and many CLI clients all connect to a single bridge host on port 38741. There is never more than one bridge host per port. This diagram is for implementors; consumers never interact with these components directly. - -``` - +---------------------------------------------+ - | CLI / Library Consumer | - | | - | studio-bridge exec 'print("hi")' | - | studio-bridge terminal | - | studio-bridge connect | - | nevermore test --local | - +------------------+---------------------------+ - | - (calls BridgeConnection / BridgeSession only) - | - ══════════════════════════════════════════════════════ - ║ INTERNAL NETWORKING LAYER (never imported directly) ║ - ══════════════════════════════════════════════════════ - | - +------------------v---------------------------+ - | Bridge Host | - | (first CLI to bind port 38741) | - | | - | WebSocket server on port 38741 | - | Tracks connected plugins (live sessions) | - | Groups sessions by instanceId | - | Routes commands to sessions | - | Multiplexes client requests | - +------+------------------+--------------------+ - | | - +----v-----+ +-----v-----------+ - | Plugin A | | Plugin B (Edit) | <-- connect via /plugin - |(Studio 1)| | Plugin B (Srv) | - | (Edit) | | Plugin B (Clt) | - +----------+ +-----------------+ - ^ - Studio 1: Edit mode | Studio 2: Play mode - (1 connection) | (3 connections, same instanceId) - | - CLI Clients ---+ <-- connect via /client - (subsequent CLI processes) -``` - -### Data flow for a persistent-session execution - -1. Consumer calls `BridgeConnection.connectAsync()`. Internally, this attempts to bind port 38741. Success means this process becomes the bridge host; failure (EADDRINUSE) means it connects as a client to the existing host. **The consumer does not know which happened.** -2. Consumer calls `conn.waitForSession()`. Internally, the plugin in Studio polls `localhost:38741/health` every 2 seconds, connects when the host appears, and sends a `register` message with session metadata (including its `instanceId` and `context`). **The consumer just awaits a `BridgeSession`.** When Studio enters Play mode, 2 new plugin instances (client and server) connect as separate sessions with the same `instanceId`, joining the already-connected edit session -- the bridge host groups them automatically. -3. Consumer calls `session.execAsync(...)`. Internally, the command is routed through the bridge host to the plugin, and results flow back. **The consumer sees a promise that resolves with results.** -4. All subsequent calls (`queryStateAsync`, `captureScreenshotAsync`, etc.) follow the same pattern -- the consumer calls a method on `BridgeSession`, the networking layer handles routing, and the consumer gets a typed result. - -### Two operating modes (transparent to consumers) - -Both modes use the exact same consumer API. The difference is entirely within the networking layer: - -- **Implicit host** (default): The first CLI process binds port 38741 and becomes the bridge host. Subsequent CLI processes connect as clients. Plugins connect directly. This is the mode used for local single-machine development. -- **Explicit host** (devcontainer/remote): The user runs `studio-bridge serve` on the host machine, which becomes a dedicated headless bridge host on port 38741. The devcontainer CLI connects to `localhost:38741` (port-forwarded) as a client. Alternatively, `studio-bridge terminal --keep-alive` serves the same role with a REPL attached. - -``` -Implicit host (default) Explicit host (studio-bridge serve) -┌─────────────────────────────┐ ┌─────────────────────────────┐ -│ CLI process (first started) │ │ studio-bridge serve │ -│ ┌─────────────┐ │ │ ┌─────────────┐ │ -│ │ Bridge Host │<── plugins │ │ │ Bridge Host │<── plugins │ -│ └─────────────┘ │ │ └─────────────┘ │ -│ + CLI commands │ └──────────────┬──────────────┘ -└─────────────────────────────┘ │ port 38741 - ┌──────────────┴──────────────┐ - │ CLI process (client mode) │ - │ CLI commands, MCP, terminal │ - └─────────────────────────────┘ -``` - -In both cases, CLI commands use `BridgeConnection` identically. Consumer code calling `BridgeConnection.connectAsync()` cannot tell which mode is active. - -Split-server mode is detailed in `05-split-server.md`. - -### 1.1 API Boundary - -The architecture has a strict boundary between the public API and the internal networking layer. This boundary is enforced by directory structure and import rules. The full API definition is in `07-bridge-network.md` (the authoritative networking spec); what follows is a summary. - -**Public API (exported from `src/bridge/index.ts`):** - -- **`BridgeConnection`** -- connect to the studio-bridge network, access sessions. The ONLY entry point for programmatic use. -- **`BridgeSession`** -- interact with a single Studio instance. Action methods: `execAsync`, `queryStateAsync`, `captureScreenshotAsync`, `queryLogsAsync`, `queryDataModelAsync`, `subscribeAsync`, `unsubscribeAsync`. -- **`SessionInfo`** -- read-only metadata about a session (session ID, place name, state, capabilities, origin, context, instanceId, placeId, gameId). -- **`InstanceInfo`** -- read-only metadata about a Studio instance (instanceId, placeName, placeId, gameId, connected contexts, origin). -- **`SessionContext`** -- `'edit' | 'client' | 'server'` identifying which Studio VM a session belongs to. -- **Result types** -- `ExecResult`, `StateResult`, `ScreenshotResult`, `LogsResult`, `DataModelResult`. -- **Option types** -- `BridgeConnectionOptions`, `LogOptions`, `QueryDataModelOptions`. -- **Error types** -- `SessionNotFoundError`, `ContextNotFoundError`, `ActionTimeoutError`, `HostUnreachableError`, etc. - -**Everything else is internal:** - -Bridge host, bridge client, transport server, transport client, hand-off protocol, health endpoint, WebSocket paths, session tracker, host-protocol envelopes -- ALL internal. Consumers never create a `BridgeHost`, `BridgeClient`, or `TransportServer`. Those classes exist inside `src/bridge/internal/` and are not re-exported. - -**The consumer invariant:** - -A consumer using `BridgeConnection` cannot tell whether: -- There is one Studio or ten Studios connected -- Studio is in Edit mode (1 context) or Play mode (3 contexts) -- Their process is the bridge host or a bridge client -- The connection is local or over a forwarded port -- The plugin connected via persistent discovery or ephemeral injection -- The host was started implicitly or via `studio-bridge serve` - -This is a hard architectural constraint, not a convenience. Any change that would require consumers to be aware of the networking topology is a design violation. - -## 2. Key Design Decisions - -### 2.1 Unified plugin with two boot modes - -There is ONE plugin source, not two. The same Luau code ships in both installation modes. The difference is how the plugin is built and how it discovers the server: - -**Persistent mode** (local development): -- Built with `IS_EPHEMERAL = false`, `PORT = nil`, and `SESSION_ID = nil`. -- Installed once to the Studio plugins folder via `studio-bridge install-plugin`. -- At startup, checks `IS_EPHEMERAL` and enters the discovery loop: polls `localhost:38741` to find the bridge host, connects via WebSocket, and registers with session metadata. -- Survives across Studio restarts; reconnects automatically when a server appears or reappears. - -**Ephemeral mode** (CI, legacy, fallback): -- Built with `IS_EPHEMERAL = true`, `PORT` set to a number, and `SESSION_ID` set to a UUID string. -- Injected per session by `StudioBridgeServer.startAsync()`, deleted on `stopAsync()`. -- At startup, checks `IS_EPHEMERAL` and connects directly to the known server -- no discovery, no polling. -- Behaves identically to the current temporary plugin. - -Build constants are injected via a two-step pipeline: Handlebars template substitution (in TemplateHelper) replaces placeholders like `{{IS_EPHEMERAL}}` in the Lua source, then Rojo builds the substituted sources into an `.rbxm` plugin file. The result is a plain boolean constant that the plugin checks without any string comparison tricks: - -```lua -local IS_EPHEMERAL = {{IS_EPHEMERAL}} -- substituted by Handlebars, then built by Rojo -local PORT = {{PORT}} -- replaced with number (ephemeral) or nil (persistent) -local SESSION_ID = "{{SESSION_ID}}" -- replaced with UUID (ephemeral) or nil/empty (persistent) -``` - -If `IS_EPHEMERAL` is true, the plugin connects directly (ephemeral mode). Otherwise, it enters the discovery state machine (persistent mode). - -Why a unified source instead of two separate plugins: -- Eliminates code drift between persistent and ephemeral implementations -- All action handlers, protocol logic, and serialization are shared -- validated once, used everywhere -- Reduces validation risk: a bug fix in one mode automatically applies to the other -- Eliminates the most fragile part of the current system (file injection races, stale plugins after crashes) in persistent mode -- Enables the plugin to reconnect after server restarts without re-launching Studio -- Required for split-server mode where the server may start after Studio -- Allows the plugin to offer richer capabilities (screenshot, DataModel query) that persist across sessions - -Trade-offs: -- Plugin must handle discovery and reconnection logic (more complex Luau code), though this only activates in persistent mode -- Users must explicitly install the plugin for persistent mode (one-time setup step) -- Security surface increases in persistent mode (see section 10) - -Details in `03-persistent-plugin.md`. - -### 2.1.1 Plugin management as a reusable subsystem - -The plugin build/install infrastructure is a general-purpose utility, not a studio-bridge-specific feature. The `src/plugins/` module provides a `PluginManager` class that operates on `PluginTemplate` descriptors -- it never hard-codes paths, filenames, or build constants for any specific plugin. studio-bridge registers its plugin template during initialization; future tools register theirs. - -This means that adding a new persistent plugin (e.g., for Rojo sync, test running, or remote debugging) requires only: -1. Creating a template directory with a Rojo project and Luau source. -2. Defining a `PluginTemplate` with the template's name, path, build constants, and output filename. -3. Calling `pluginManager.registerTemplate(template)`. - -No changes to `PluginManager` itself. The build, install, version tracking, and uninstall flows work unchanged for any registered template. See `03-persistent-plugin.md` section 2 for the full API design. - -### 2.2 Bridge host discovery - -Sessions are discovered live through the bridge host, not via files on disk. A single well-known port (38741) serves as the rendezvous point -- the topology is many-to-one (many plugins and CLI clients, one bridge host). The first CLI process to start binds this port and becomes the bridge host; subsequent CLI processes connect as clients. - -Session discovery works as follows: -1. CLI starts (as host or connects as client) -2. Sends a `listSessions` request to the host -3. Host responds with all currently connected plugins and their metadata (place name, state, session ID, context, instanceId -- all from the plugin's `register` message) -4. If no plugins are connected yet, the host waits up to `timeoutMs` for plugins to connect - -The bridge host groups sessions by `instanceId`. A single Studio instance may have 1 session (Edit mode) or up to 3 sessions (Play mode: Edit + Client + Server). Consumers typically interact at the instance level (via `listInstances()` and `resolveSession()`) rather than enumerating individual context sessions. - -There is no session registry on disk. "Session scanning" = "see which plugins are connected to the host right now." - -Why bridge host instead of file-based registry: -- Roblox plugins cannot read arbitrary files from disk (`plugin:SetSetting()` is opaque to external processes) -- CLI processes are ephemeral -- making them "session owners" inverts the natural lifecycle (Studio is the long-lived process) -- Zero-infrastructure: no daemon management, no lock files, no stale PID checks -- Self-healing: if the bridge host dies, a connected client takes over the port automatically -- Cross-process: `nevermore-cli`'s `LocalJobContext` connects to the same bridge host as the `studio-bridge` CLI - -### 2.3 Named message types with request/response correlation - -Currently, the server-to-plugin protocol has one action: `execute` (run a Luau string). The persistent plugin needs to support a richer set of operations: state queries, screenshots, DataModel inspection, and log retrieval. - -The solution is named message types with request/response correlation. Each operation has its own dedicated server-to-plugin request type and a corresponding plugin-to-server response type: - -```typescript -// Server -> Plugin (each operation gets its own type) -{ type: 'queryState', sessionId, requestId, payload: {} } -{ type: 'captureScreenshot', sessionId, requestId, payload: { format?: 'png' } } -{ type: 'queryDataModel', sessionId, requestId, payload: { path, depth?, ... } } -{ type: 'queryLogs', sessionId, requestId, payload: { count?, ... } } - -// Plugin -> Server (named responses) -{ type: 'stateResult', sessionId, requestId, payload: { state, placeId, ... } } -{ type: 'screenshotResult', sessionId, requestId, payload: { data, format, ... } } -{ type: 'dataModelResult', sessionId, requestId, payload: { instance: { ... } } } -{ type: 'logsResult', sessionId, requestId, payload: { entries: [...] } } -``` - -Named types are more explicit, produce better TypeScript discriminated unions, and are easier to validate per-message. A `requestId` field (UUIDv4) on each request enables concurrent request/response correlation -- the server can have multiple operations in flight simultaneously. - -The existing `execute` and `scriptComplete` message types are fully preserved. The `execute` message gains an optional `requestId` field; if present, `scriptComplete` echoes it. Legacy plugins that omit `requestId` continue to work with sequential semantics. - -Details in `01-protocol.md`. - -### 2.4 Backward compatibility as a hard constraint - -The library API (`StudioBridgeServer` class, re-exported as `StudioBridge` from `index.ts` via `export { StudioBridgeServer as StudioBridge }`, consumed by `LocalJobContext` in nevermore-cli) must not break. Existing callers that do: - -```typescript -const bridge = new StudioBridgeServer({ placePath }); -await bridge.startAsync(); -const result = await bridge.executeAsync({ scriptContent }); -await bridge.stopAsync(); -``` - -...must continue to work unchanged. The persistent session features are additive: new options on existing methods, new methods on the class, and new CLI commands. - -The re-export alias ensures backward compatibility: - -```typescript -// src/index.ts -- re-export alias preserves the public name -export { StudioBridgeServer as StudioBridge } from './server/studio-bridge-server.js'; -``` - -The temporary plugin injection path remains available as a fallback when the persistent plugin is not installed, preserving zero-config behavior for CI environments. - -The existing `StudioBridgeServer` class wraps `BridgeConnection` internally: - -```typescript -// src/server/studio-bridge-server.ts -- preserved API -export class StudioBridgeServer { - private _connection?: BridgeConnection; - private _session?: BridgeSession; - - async startAsync(): Promise { - this._connection = await BridgeConnection.connectAsync({ - keepAlive: true, - timeoutMs: this._defaultTimeoutMs, - }); - this._session = await this._connection.waitForSession(this._defaultTimeoutMs); - } - - async executeAsync(options: ExecuteOptions): Promise { - return this._session!.execAsync(options.scriptContent); - } - - async stopAsync(): Promise { - await this._connection?.disconnectAsync(); - } -} -``` - -Callers of `new StudioBridgeServer()` (or `new StudioBridge()` via the re-export) / `startAsync()` / `executeAsync()` / `stopAsync()` see no change. - -## 3. Component Map - -### 3.1 Bridge module file layout - -The `src/bridge/` directory is organized to make the API boundary structurally obvious. Public files live at the top level; internal networking files live in `internal/`. The directory structure IS the API contract. - -``` -src/bridge/ - index.ts PUBLIC: re-exports ONLY BridgeConnection, BridgeSession, types - - # Public API (importable by consumers via src/bridge/index.ts) - bridge-connection.ts BridgeConnection class - bridge-session.ts BridgeSession class - types.ts SessionInfo, SessionOrigin, result types, option types - - # Internal networking (NEVER imported by consumers) - internal/ - bridge-host.ts WebSocket server on port 38741, plugin + client management - bridge-client.ts WebSocket client connecting to existing host - transport-server.ts Low-level WebSocket/HTTP server - transport-client.ts Low-level WebSocket client - transport-handle.ts TransportHandle interface (abstraction between layers) - health-endpoint.ts HTTP /health endpoint - hand-off.ts Host transfer logic (graceful shutdown + crash recovery) - host-protocol.ts Client-to-host envelope messages (listSessions, hostTransfer, etc.) - session-tracker.ts In-memory session map (used by bridge-host) - environment-detection.ts isDevcontainer(), getDefaultRemoteHost() (split-server auto-detection) -``` - -The `internal/` directory makes it structurally clear what is and is not public. TypeScript path restrictions (or convention enforced by review) ensure consumers only import from `src/bridge/index.ts`. - -### 3.1.1 Plugin management module file layout - -The `src/plugins/` directory contains the **universal plugin management subsystem**. This is a reusable utility -- not specific to studio-bridge. studio-bridge is its first consumer, but any Nevermore tool that needs to build and install a persistent Roblox Studio plugin uses this same infrastructure. The design is detailed in `03-persistent-plugin.md` section 2. - -``` -src/plugins/ - index.ts PUBLIC: re-exports PluginManager, PluginTemplate, types - plugin-manager.ts PluginManager class: build, install, uninstall, list - plugin-template.ts PluginTemplate interface and validation - plugin-discovery.ts discoverPluginsDirAsync() -- platform-specific Studio folder detection - types.ts InstalledPlugin, BuiltPlugin, BuildOverrides types -``` - -The plugin manager is parameterized by `PluginTemplate` -- it never hard-codes paths or names for any specific plugin. studio-bridge registers its template during initialization; future tools register theirs. Adding a new plugin never requires modifying the manager. - -### 3.2 Other new files - -| File | Purpose | -|------|---------| -| `src/server/pending-request-map.ts` | Track in-flight requests by `requestId`, enforce timeouts, resolve/reject promises | -| `src/server/action-dispatcher.ts` | Route incoming response messages to waiting callers by `requestId` via `PendingRequestMap` | -| `src/server/actions/query-state.ts` | Server-side handler for `queryState` action (used by `StudioBridgeServer`) | -| `src/server/actions/capture-screenshot.ts` | Server-side handler for `captureScreenshot` action (used by `StudioBridgeServer`) | -| `src/server/actions/query-logs.ts` | Server-side handler for `queryLogs` action (used by `StudioBridgeServer`) | -| `src/server/actions/query-datamodel.ts` | Server-side handler for `queryDataModel` action (used by `StudioBridgeServer`) | -| `src/commands/index.ts` | Command registry: barrel file exporting all command definitions and the `allCommands` array. CLI, terminal, and MCP all register from this single source. See `02-command-system.md` section 3. | -| `src/commands/types.ts` | `CommandDefinition`, `CommandContext`, `CommandResult`, `ArgSpec` types | -| `src/commands/session-resolver.ts` | Shared `resolveSessionAsync` utility used by all adapters | -| `src/commands/sessions.ts` | `sessions` command handler -- list active sessions | -| `src/commands/state.ts` | `state` command handler -- query Studio state (run mode, place info) | -| `src/commands/screenshot.ts` | `screenshot` command handler -- capture viewport screenshot | -| `src/commands/logs.ts` | `logs` command handler -- retrieve and follow output logs | -| `src/commands/query.ts` | `query` command handler -- query the DataModel | -| `src/commands/exec.ts` | `exec` command handler -- execute Luau code (extracted from exec-command.ts) | -| `src/commands/run.ts` | `run` command handler -- run a Luau file (extracted from run-command.ts) | -| `src/commands/connect.ts` | `connect` command handler -- connect to an already-running Studio | -| `src/commands/disconnect.ts` | `disconnect` command handler -- disconnect from a session | -| `src/commands/launch.ts` | `launch` command handler -- explicitly launch a new Studio session | -| `src/commands/install-plugin.ts` | `install-plugin` command handler -- delegates to `PluginManager` to build and install the studio-bridge persistent plugin | -| `src/commands/serve.ts` | `serve` command handler -- start a dedicated bridge host process (see `05-split-server.md`) | -| `src/cli/adapters/cli-adapter.ts` | `createCliCommand` -- generic adapter: `CommandDefinition` to yargs `CommandModule` | -| `src/cli/adapters/terminal-adapter.ts` | `createDotCommandHandler` -- generic adapter: `CommandDefinition[]` to dot-command dispatcher | -| `src/mcp/adapters/mcp-adapter.ts` | `createMcpTool` -- generic adapter: `CommandDefinition` to MCP tool. See `06-mcp-server.md`. | -| `src/mcp/mcp-server.ts` | MCP server lifecycle: creates `BridgeConnection`, registers tools from `allCommands`, handles stdio transport. See `06-mcp-server.md`. | -| `src/mcp/index.ts` | Public exports for the MCP module | -| `src/commands/mcp.ts` | `mcp` command handler (`mcpEnabled: false`) -- starts MCP server via `startMcpServerAsync()` | - -### 3.3 Modified files - -| File | Changes | -|------|---------| -| `src/server/studio-bridge-server.ts` | Add bridge connection integration; support both temporary and persistent plugin modes; add `requestId`-based request dispatch alongside existing execute path | -| `src/server/web-socket-protocol.ts` | Add v2 message types (`queryState`, `stateResult`, `captureScreenshot`, `screenshotResult`, `queryDataModel`, `dataModelResult`, `queryLogs`, `logsResult`, `subscribe`, `subscribeResult`, `unsubscribe`, `unsubscribeResult`, `stateChange`, `heartbeat`, `register`, `error`); add `requestId` and `protocolVersion` to base envelope; add shared types (`Capability`, `ErrorCode`, `StudioState`, `SerializedValue`, `DataModelInstance`); keep all existing types | -| `src/plugin/plugin-injector.ts` | Delegate to `PluginManager.isInstalledAsync('studio-bridge')` for persistent plugin detection; skip injection when persistent plugin is present; use `PluginManager.buildAsync()` with overrides for ephemeral builds | -| `src/cli/cli.ts` | Register all commands via `allCommands` loop (imports from `src/commands/index.js`, no individual command imports). Add `--remote` and `--local` global options for split-server mode. | -| `src/cli/commands/terminal/terminal-mode.ts` | Wire up `dotcommand` event to `createDotCommandHandler(allCommands)`. Support connecting to existing sessions. | -| `src/cli/commands/terminal/terminal-editor.ts` | Emit `dotcommand` event for non-intrinsic dot-commands (`.help`, `.exit`, `.clear` stay inline) | -| `src/mcp/mcp-server.ts` | Register all MCP-eligible tools via `allCommands.filter(c => c.mcpEnabled !== false)` loop (imports from `src/commands/index.js`, no individual command imports). See `06-mcp-server.md`. | -| `src/index.ts` | Export new public types (`BridgeConnection`, `BridgeSession`, `SessionInfo`, `InstanceInfo`, `SessionContext`, result types, error types, v2 message types, `Capability`, `ErrorCode`, `StudioState`, `SerializedValue`, `DataModelInstance`) | -| `templates/studio-bridge-plugin/` | Upgraded in-place: same directory, same name, but source now supports both persistent and ephemeral boot modes with full v2 protocol support | - -### 3.4 Import rules - -These rules enforce the API boundary between the public bridge API and the internal networking layer: - -``` -Import rules: - src/bridge/index.ts Re-exports public API only. No internal/ types leak out. - src/bridge/bridge-connection.ts May import from internal/ (it orchestrates networking). - src/bridge/bridge-session.ts May import from internal/ (it delegates to transport handles). - src/bridge/types.ts No imports from internal/ (pure type definitions). - src/bridge/internal/*.ts May import from each other. NEVER imported outside src/bridge/. - - src/plugins/index.ts Re-exports PluginManager, PluginTemplate, types. - src/plugins/*.ts Self-contained module. May import from src/plugins/ only (no bridge internals). - - src/commands/*.ts Imports from src/bridge/index.ts and src/plugins/index.ts (public APIs). - src/cli/*.ts Imports from src/bridge/index.ts and src/plugins/index.ts (public APIs). - src/mcp/*.ts Imports from src/bridge/index.ts only (public API). - src/plugin/plugin-injector.ts Imports from src/plugins/index.ts (uses PluginManager for build/install checks). - src/index.ts Re-exports from src/bridge/index.ts and src/plugins/index.ts (public API surfaces). - - External consumers (nevermore-cli) Import from 'studio-bridge' package entry (src/index.ts). -``` - -The key rule: **nothing outside `src/bridge/` may import from `src/bridge/internal/`**. This is what makes the networking abstraction real. If a consumer needs something from the internal layer, the correct fix is to add it to the public API in `src/bridge/index.ts`, not to reach into internals. - -**Shared workspace dependency**: `@quenty/cli-output-helpers` is already a dependency of studio-bridge (used for `OutputHelper` colored output). The persistent sessions work adds a dependency on `@quenty/cli-output-helpers/output-modes` for command output formatting (table rendering, JSON output, watch/follow mode). These output mode utilities are new additions to the existing shared package -- no new package is created. The CLI adapter (`src/cli/adapters/cli-adapter.ts`) is the primary consumer. See `execution/output-modes-plan.md` for the full design. - -### 3.5 Unified plugin template directory - -The existing `templates/studio-bridge-plugin/` directory is upgraded in-place. There is no second template directory. The same source supports both boot modes. - -``` -templates/studio-bridge-plugin/ (unified -- replaces the old single-purpose template) - default.project.json - src/ - StudioBridgePlugin.server.lua -- entry point, detects boot mode, runs state machine - Discovery.lua -- HTTP health polling (persistent mode only) - Protocol.lua -- JSON encode/decode, send helpers - ActionHandler.lua -- dispatch table, routes messages to handlers - Actions/ - ExecuteAction.lua -- handle 'execute' messages - StateAction.lua -- handle 'queryState', send 'stateResult' - ScreenshotAction.lua -- handle 'captureScreenshot', send 'screenshotResult' - DataModelAction.lua -- handle 'queryDataModel', send 'dataModelResult' - LogAction.lua -- handle 'queryLogs', send 'logsResult' - SubscribeHandler.lua -- handle 'subscribe'/'unsubscribe' - LogBuffer.lua -- ring buffer for output log entries - StateMonitor.lua -- detect and report Studio state changes - ValueSerializer.lua -- Roblox type to JSON serialization -``` - -## 4. Session Discovery - -### 4.1 In-memory session tracking - -Sessions are tracked entirely in-memory by the bridge host. When a plugin connects to port 38741 via the `/plugin` WebSocket path, it sends a `register` message containing its session metadata (including `instanceId`, `context`, `placeId`, and `gameId`). The bridge host stores this in a live map of connected plugins, grouped by `instanceId`. When a plugin disconnects, its session is removed from the map immediately. When all sessions for an `instanceId` have disconnected, the instance group is removed. - -Each session has an `origin` field that records how the plugin connected. Plugins that connect on their own (the persistent plugin polling and discovering an existing bridge host) are `'user'` origin -- these represent Studio instances the developer opened manually. Plugins that connect because studio-bridge launched Studio and injected or waited for the plugin are `'managed'` origin -- these represent Studio instances that the bridge owns. - -Each session also has a `context` field (`'edit'`, `'client'`, or `'server'`) indicating which Studio VM it represents. In Edit mode, a Studio instance has one session with `context: 'edit'`. When Studio enters Play mode, the Client and Server VMs each spawn a separate plugin instance that connects as additional sessions with the same `instanceId`. The bridge host automatically groups these into a single logical instance. - -There is no directory structure, no lock files, and no PID-based stale session detection. A session exists if and only if its plugin is currently connected to the bridge host. - -``` -~/.nevermore/studio-bridge/ - plugin/ - StudioBridgePlugin.rbxm # installed persistent plugin - config.json # optional user config -``` - -### 4.2 BridgeConnection and BridgeSession (public API summary) - -`BridgeConnection` is the ONLY way to interact with studio-bridge programmatically. The full API definition with all method signatures, events, and error types is in `07-bridge-network.md` section 2. This section provides a summary for orientation. - -The same code works identically in all scenarios: -- **1:1** (one CLI, one Studio in Edit mode) -- `resolveSession()` auto-selects the single Edit session -- **1:1 Play mode** (one CLI, one Studio in Play mode) -- `resolveSession()` auto-selects the Edit context; `resolveSession(undefined, 'server')` selects the Server context -- **N:N** (multiple CLIs, multiple Studios) -- `listInstances()` returns instance groups, `listSessions()` returns all sessions, `getSession(id)` targets a specific one -- **Local** -- networking is localhost -- **Remote/devcontainer** -- networking is port-forwarded, but the API is the same -- **Host role** -- this process bound the port -- **Client role** -- this process connected to an existing host - -```typescript -// BridgeConnection -- the ONLY entry point -static connectAsync(options?: BridgeConnectionOptions): Promise; -disconnectAsync(): Promise; -listSessions(): SessionInfo[]; // in-memory, synchronous -listInstances(): InstanceInfo[]; // unique Studio instances (grouped by instanceId) -getSession(sessionId: string): BridgeSession | undefined; -waitForSession(timeout?: number): Promise; -resolveSession(sessionId?: string, context?: SessionContext, instanceId?: string): Promise; -readonly role: 'host' | 'client'; - -// InstanceInfo -- a Studio instance that may have 1-3 context sessions -{ instanceId, placeName, placeId, gameId, contexts: SessionContext[], origin } - -// resolveSession() is instance-aware: -// 1. If sessionId provided -> return that session -// 2. If instanceId provided -> select that instance, apply context selection -// 3. Collect unique instances (by instanceId) -// 4. If 0 instances -> wait (with timeout) -// 5. If 1 instance: -// a. If context flag provided -> return that context's session -// b. If only 1 context (Edit mode) -> return it -// c. If multiple contexts (Play mode) -> return Edit context (default) -// 6. If N instances -> throw with instance list (use --session or --instance) - -// BridgeConnectionOptions -{ port?, timeoutMs?, keepAlive?, remoteHost? } - -// BridgeSession -- handle to a single Studio instance -readonly info: SessionInfo; -execAsync(code: string, timeout?: number): Promise; -queryStateAsync(): Promise; -captureScreenshotAsync(): Promise; -queryLogsAsync(options?: LogOptions): Promise; -queryDataModelAsync(options: QueryDataModelOptions): Promise; -subscribeAsync(events: SubscribableEvent[]): Promise; -unsubscribeAsync(events: SubscribableEvent[]): Promise; - -// SessionInfo -- read-only metadata -{ sessionId, placeName, placeFile?, state, pluginVersion, capabilities, connectedAt, origin, - context, instanceId, placeId, gameId } - -// SessionContext -- which Studio VM this session represents -type SessionContext = 'edit' | 'client' | 'server'; - -// SessionOrigin -type SessionOrigin = 'user' | 'managed'; -``` - -Note: the overview uses abbreviated signatures for readability. See `07-bridge-network.md` section 2 for complete interface definitions including events, error types, and `followLogs()` async iterable. - -### 4.3 Stale session handling - -There is no stale session problem. Sessions are live WebSocket connections: -- Plugin connects -> session appears (grouped by instanceId) -- Plugin disconnects (Studio closed, crash, network drop) -> session disappears immediately -- All contexts for an instance disconnect -> instance group is removed -- Studio leaves Play mode -> Client and Server contexts disconnect, Edit stays -- Bridge host dies -> clients detect disconnect, one takes over the port, plugins reconnect within ~2 seconds - -### 4.4 Plugin discovery: many-to-one topology - -Discovery is many-to-one, not many-to-many. There is exactly one bridge host on port 38741. All plugins connect to it. All CLI/MCP processes either are the host or connect to it. - -``` -Studio A (Edit plugin) ─────────┐ - │ /plugin WebSocket -Studio B (Edit plugin) ─────────┼──→ Bridge Host (:38741) ←──┬── CLI (host process) -Studio B (Server plugin) ───────┤ ├── CLI (client) -Studio B (Client plugin) ───────┘ └── MCP server (client) - instanceId groups: - Studio A: [edit] (Edit mode) - Studio B: [edit, server, client] (Play mode) -``` - -Each Studio instance runs one persistent plugin in Edit context. When Studio enters Play mode, the Client and Server VMs each load a separate plugin instance. These additional instances connect to the bridge host as separate WebSocket sessions, sharing the same `instanceId` but with distinct `context` values (`'edit'`, `'client'`, `'server'`). The bridge host groups all sessions with the same `instanceId` into a single logical instance. - -The persistent plugin discovers the bridge host by polling `localhost:38741/health` (HTTP GET) every 2 seconds. When the health endpoint responds with HTTP 200 and `status: "ok"`: - -1. The plugin opens a WebSocket connection to `ws://localhost:38741/plugin` -2. It generates a UUID (via `HttpService:GenerateGUID()`) and sends a `register` message with this proposed session ID, plus session metadata (instanceId, context, place name, placeId, gameId, Studio state, capabilities) -3. The bridge host accepts the plugin's proposed session ID (or overrides it on collision), stores the session (grouped by instanceId), and responds with `welcome` containing the authoritative session ID -4. The plugin adopts the session ID from the `welcome` response and enters the connected state, processing commands and sending heartbeats (every 5 seconds) -5. If the connection drops (host died, crash), the plugin returns to polling with exponential backoff - -Multiple plugins can connect simultaneously. Each generates its own UUID as the proposed session ID (collisions are astronomically unlikely). The bridge host tracks all connected sessions in an in-memory map, grouped by `instanceId`. CLI consumers typically target sessions via `resolveSession()`, which auto-selects based on instance count and context. Direct session targeting by ID is available for advanced use. - -When Studio enters Play mode, 2 new `register` messages arrive (server and client) joining the existing edit session, all sharing the same `instanceId` but with different `context` values. The edit plugin was already connected and is unaffected by Play mode transitions. When Studio leaves Play mode, the Client and Server contexts disconnect; the Edit context remains connected. The bridge host removes an instance from the grouping only when all its context sessions have disconnected. - -If no bridge host is running, plugins poll indefinitely (the health check is a lightweight HTTP GET with 500ms timeout, negligible cost). When a CLI process eventually starts and binds port 38741, plugins discover it on the next poll cycle. - -The full discovery protocol, including race conditions, disambiguation, and debugging, is documented in `03-persistent-plugin.md` section 3. - -### 4.5 Connection types on port 38741 - -The WebSocket server distinguishes connection types by path: - -| Path | Source | Purpose | -|------|--------|---------| -| `/plugin` | Studio plugin (Luau) | Plugin connection. Plugin sends `register`/`hello`, receives actions, sends responses and push messages. | -| `/client` | CLI process / MCP server | Client connection. Client sends host-protocol envelopes, receives forwarded responses and session events. | -| `/health` | HTTP GET (any) | Health check. Returns JSON with host status, session count, and uptime. Used by plugins for discovery. | - -## 5. Plugin Architecture - -### 5.1 Unified plugin -- two boot modes - -The same plugin source operates in two modes, determined at startup by the presence of build-time constants (injected via a two-step pipeline: Handlebars template substitution in TemplateHelper, then Rojo build): - -| Aspect | Ephemeral mode (CI / fallback) | Persistent mode (local dev) | -|--------|-------------------------------|----------------------------| -| Build-time constants | `IS_EPHEMERAL = true`, `PORT = `, `SESSION_ID = ""` | `IS_EPHEMERAL = false`, `PORT = nil`, `SESSION_ID = nil` | -| Installation | Auto-injected per session by `startAsync()` | One-time `studio-bridge install-plugin` | -| Server discovery | Connects directly to hardcoded PORT | Polls `localhost:38741` health endpoint | -| Lifespan | Deleted on `stopAsync()` | Survives across Studio restarts | -| Reconnection | None (plugin is deleted with session) | Auto-reconnect on server restart with exponential backoff | -| Session binding | `Workspace:GetAttribute("StudioBridgeSessionId")` guard | Plugin generates instanceId, detects context, announces via `register` | -| Capabilities | All v2 capabilities (shared source) | All v2 capabilities (shared source) | - -Both modes share the same action handlers, protocol logic, serialization, and log buffering. The only difference is the connection establishment path. - -### 5.2 Plugin state machine - -``` - +----------+ - | idle | (Studio just opened, plugin loaded) - +----+-----+ - | begin discovery - +----v-----+ - |searching | (polling localhost:38741 every 2 seconds) - +----+-----+ - | server found - +----v-------+ - | connecting | (WebSocket handshake in progress) - +----+-------+ - | handshake accepted - +----v-----+ - |connected | (ready for actions) - +----+-----+ - | WebSocket closed / error - +----v--------+ - |reconnecting | (back to searching after backoff) - +-------------+ -``` - -Details in `03-persistent-plugin.md`. - -## 6. Protocol Extensions - -### 6.1 Current protocol (preserved) - -``` -Plugin -> Server: hello, output, scriptComplete -Server -> Plugin: welcome, execute, shutdown -``` - -All six message types remain valid. Existing plugins that only speak this protocol continue to work. - -### 6.2 New message types - -``` -Server -> Plugin: queryState (request Studio run mode and place info) -Server -> Plugin: captureScreenshot (request viewport capture) -Server -> Plugin: queryDataModel (request instance tree / property lookup) -Server -> Plugin: queryLogs (request buffered log history) -Server -> Plugin: subscribe (subscribe to push events) -Server -> Plugin: unsubscribe (cancel event subscriptions) - -Plugin -> Server: register (persistent plugin handshake, superset of hello; includes instanceId, context, placeId, gameId) -Plugin -> Server: stateResult (response to queryState) -Plugin -> Server: screenshotResult (response to captureScreenshot) -Plugin -> Server: dataModelResult (response to queryDataModel) -Plugin -> Server: logsResult (response to queryLogs) -Plugin -> Server: subscribeResult (confirmation of subscribe) -Plugin -> Server: unsubscribeResult (confirmation of unsubscribe) -Plugin -> Server: stateChange (unsolicited push: Studio mode transition) -Plugin -> Server: logPush (unsolicited push: individual log entry from LogService) -Plugin -> Server: heartbeat (periodic keep-alive with state info) -Plugin -> Server: error (error response to any request) -``` - -### 6.3 Capability negotiation - -On handshake, the plugin's `hello` message gains an optional `capabilities` field: - -```json -{ - "type": "hello", - "sessionId": "abc-123", - "protocolVersion": 2, - "payload": { - "sessionId": "abc-123", - "capabilities": ["execute", "queryState", "captureScreenshot", "queryDataModel", "queryLogs", "subscribe", "heartbeat"], - "pluginVersion": "1.0.0" - } -} -``` - -The server's `welcome` response confirms which capabilities it will use, allowing graceful fallback when talking to an older plugin. Persistent plugins use `register` instead of `hello` to provide richer metadata (place name, file path, Studio state) in a single message. - -Details in `01-protocol.md`. - -## 7. Bridge Host Modes - -### 7.1 Implicit host (default) - -The first CLI process to start becomes the bridge host by binding port 38741. Subsequent CLI processes connect as clients to the existing host. Changes from current behavior: - -- `BridgeConnection.connectAsync()` attempts to bind port 38741. Success = host, EADDRINUSE = client -- If a persistent plugin is installed, the host waits for the plugin to connect (plugin polls port 38741 every 2 seconds) -- If no persistent plugin is installed, `startAsync()` falls back to temporary plugin injection (existing behavior preserved for CI) -- If a host is already running, the CLI connects as a client and sends commands through the host - -The state machine for the bridge host: - -``` -idle -> binding-port -> waiting-for-plugin -> ready -> executing -> ready -> idle/shutdown - ^^^^^^^^^^^^^^^^^^^ - (plugin connects via polling, sends register message) -``` - -The state machine for a bridge client: - -``` -idle -> connecting-to-host -> ready -> executing -> ready -> disconnecting -> done -``` - -If the bridge host dies, the hand-off protocol kicks in: a connected client re-binds port 38741, becomes the new host, and plugins reconnect within ~2 seconds. - -### 7.2 Hand-off protocol - -When the bridge host process exits (gracefully or crash): - -**Graceful exit** (Ctrl+C, normal shutdown): -1. Host sends `hostTransfer` message to all connected clients -2. Clients receive the message and enter "takeover standby" mode -3. Host closes the server -4. First client to successfully bind 38741 becomes new host -5. New host sends `hostReady` to remaining clients -6. Remaining clients reconnect to new host -7. Plugins poll, detect new server, reconnect - -**Crash / kill -9**: -1. Clients detect WebSocket disconnect (error or close event) -2. Each client waits a random jitter (0-500ms) to avoid thundering herd -3. First client to bind 38741 becomes new host -4. Remaining clients retry connection to 38741 -5. Plugins poll, detect new server, reconnect - -**No clients connected when host exits**: -1. Host exits, port freed -2. Plugins poll, get connection refused, keep polling -3. Next CLI invocation becomes the new host - -### 7.3 Idle behavior - -When the bridge host is running but has no active CLI commands: -- If the host was started by `studio-bridge terminal`, it stays alive (terminal REPL is interactive) -- If the host was started by `studio-bridge exec` or `run`, it enters idle mode after the command completes -- In idle mode: if other clients are connected, the host stays alive. If no clients and no pending commands, the host exits after a 5-second grace period (allows plugins to remain connected briefly for rapid re-invocation) -- The `--keep-alive` flag forces the host to stay alive indefinitely (useful for MCP servers that want plugins to stay connected) - -Idle shutdown (the 5-second grace period and automatic exit) only applies to `managed` sessions -- sessions where studio-bridge launched Studio. `user` sessions (where the developer opened Studio manually and the persistent plugin connected on its own) are never killed by the bridge host. The bridge host will stay alive as long as any `user` session is connected, regardless of idle state. - -### 7.4 Split-server mode - -For devcontainer workflows where Studio runs on the host OS but the CLI runs inside a container. The `studio-bridge serve` command starts a dedicated bridge host on the host machine; the devcontainer CLI connects as a client via port forwarding: - -``` -+-----------------------------+ +-------------------------------+ -| Devcontainer | | Host OS | -| | | | -| nevermore test --local ----+-----+---> localhost:38741 | -| studio-bridge exec '...' | TCP | (bridge host via serve) | -| | | | | -+-----------------------------+ | WebSocket | - | | | - | +----v-----+ | - | | Studio | | - | | Plugin | | - | +----------+ | - +-------------------------------+ -``` - -The user runs `studio-bridge serve` on the host machine. This starts a dedicated headless bridge host on port 38741. Alternatively, `studio-bridge terminal --keep-alive` serves the same role with a REPL attached. The devcontainer CLI connects to `localhost:38741` (port-forwarded) as a client. - -The `serve` command is a thin wrapper: it calls `BridgeConnection.connectAsync({ keepAlive: true })` and sets up signal handling. There is no separate daemon process, no PID files, no auth tokens. All bridge host logic lives in `src/bridge/internal/bridge-host.ts`. The `serve` command lives in `src/commands/serve.ts` like any other command. Environment detection for auto-detecting devcontainers lives in `src/bridge/internal/environment-detection.ts`. - -Details in `05-split-server.md`. - -## 8. Migration Strategy - -### 8.1 Phase 1: Protocol v2 + bridge host module (non-breaking) - -1. Add v2 message types, capability negotiation, and `requestId` correlation to the protocol module -2. Build the `src/bridge/` module: `BridgeConnection`, `BridgeSession` (public), `bridge-host`, `bridge-client`, hand-off protocol (internal) -3. Build `PendingRequestMap` for request/response correlation -4. Integrate `BridgeConnection` into the existing `StudioBridgeServer` class (transparent wrapper; re-exported as `StudioBridge` via `export { StudioBridgeServer as StudioBridge }`) -5. Add v2 handshake support and action dispatch to `StudioBridgeServer` -6. All existing behavior unchanged -- temporary plugin injection remains the default - -At this point, the bridge host infrastructure is in place but no user-visible behavior has changed. - -### 8.2 Phase 2: Unified plugin upgrade (opt-in persistent mode) - -1. Upgrade the existing `templates/studio-bridge-plugin/` with the unified source that supports both boot modes (persistent discovery and ephemeral direct-connect) -2. Ship the `install-plugin` command that builds the unified plugin without template substitution (persistent mode) and installs it to the Studio plugins folder -3. Add health endpoint to the bridge host for plugin discovery -4. Add detection in `BridgeConnection`: if persistent plugin is installed, wait for plugin to discover the host; if not, build the unified plugin with substituted constants (ephemeral mode) and inject it -5. Add `sessions` CLI command that queries the bridge host's connected plugin list -6. Add `--session` flag and session selection to existing commands (`exec`, `run`, `terminal`) -7. Ephemeral injection remains as fallback (same plugin source, different build) - -Users who run `studio-bridge install-plugin` get the persistent experience. Everyone else gets the same plugin code but in ephemeral mode -- identical to current behavior but with v2 capabilities. - -### 8.3 Phase 3: Protocol extensions (additive) - -1. Implement action handlers in the persistent plugin for each new capability: state query, screenshot, DataModel inspection, log retrieval -2. Implement server-side action wrappers and CLI commands for each capability -3. Add terminal dot-commands for all new actions -4. Add `subscribe`/`unsubscribe` for push events (`stateChange`, `logPush`) via the WebSocket push subscription protocol (see `01-protocol.md` section 5.2 and `07-bridge-network.md` section 5.3) - -### 8.4 Phase 4: Split-server mode (new command) - -1. Add `studio-bridge serve` command (headless bridge host with `--keep-alive`) -2. Add `--remote` flag to CLI for explicit remote connection -3. Add devcontainer auto-detection for implicit remote connection - -### 8.5 Library API compatibility -- Public API Freeze - -The `StudioBridgeServer` class (re-exported as `StudioBridge` from `index.ts` via `export { StudioBridgeServer as StudioBridge }`, consumed by `LocalJobContext` in `/workspaces/NevermoreEngine/tools/nevermore-cli/src/utils/job-context/local-job-context.ts`) keeps its existing interface. - -**Public API Freeze** -- the following method signatures, type exports, and re-exports from `src/index.ts` MUST remain unchanged: - -```typescript -// From StudioBridgeServer (exported as StudioBridge via: export { StudioBridgeServer as StudioBridge }): -constructor(options?: StudioBridgeServerOptions) -startAsync(): Promise -executeAsync(options: ExecuteOptions): Promise -stopAsync(): Promise -``` - -These are consumed by `LocalJobContext` in `/workspaces/NevermoreEngine/tools/nevermore-cli/src/utils/job-context/local-job-context.ts`. New methods and new exports are additive and permitted; changes to the above signatures are not. - -New capabilities are exposed through: -- Additional optional fields on `StudioBridgeServerOptions` (e.g., `preferPersistentPlugin`) -- New methods on the class (e.g., `queryStateAsync`, `captureScreenshotAsync`, `queryDataModelAsync`, `queryLogsAsync`) -- New standalone exports (`BridgeConnection`, `BridgeSession`, result types, error types) - -## 9. MCP Integration - -PRD requirement F7 specifies that all capabilities (F1-F6) must be exposed as MCP tools. The MCP server is a long-lived process that shares session state with the CLI. Full design: `06-mcp-server.md`. - -### 9.1 MCP tool mapping - -| MCP Tool | PRD Feature | Protocol Messages Used | -|----------|-------------|----------------------| -| `studio_sessions` | F1: Session Discovery | `BridgeConnection.listSessions()` (no plugin message needed) | -| `studio_state` | F2: Studio State | `queryState` / `stateResult` | -| `studio_screenshot` | F3: Screenshots | `captureScreenshot` / `screenshotResult` (returns base64 in tool response) | -| `studio_logs` | F4: Output Logs | `queryLogs` / `logsResult` | -| `studio_query` | F5: DataModel Queries | `queryDataModel` / `dataModelResult` | -| `studio_exec` | F6: Script Execution | `execute` / `scriptComplete` + `output` | - -### 9.2 Architecture - -The MCP server runs as `studio-bridge mcp` (a new CLI command, added to the component map). It is a thin adapter over the same `CommandDefinition` handlers used by the CLI and terminal -- it does not have its own business logic. Each MCP tool is generated from a `CommandDefinition` via `createMcpTool()`. See `02-command-system.md` for the unified handler pattern and `06-mcp-server.md` for the full MCP server design. - -The MCP server: -- Starts a long-lived process that speaks the MCP protocol over stdio -- Connects to the bridge host (or becomes the host) via `BridgeConnection` -- Registers MCP tools from `allCommands.filter(c => c.mcpEnabled !== false)` -- Returns structured JSON tool responses (not formatted text) -- Uses MCP error codes for failures (not process exit codes) -- Returns base64 image data for screenshots (MCP image content blocks) - -### 9.3 Session selection in MCP - -MCP tools accept optional `sessionId` and `context` parameters. The auto-selection heuristic matches the CLI via the shared `resolveSessionAsync` utility: if exactly one instance exists, select its Edit context (or the specified `context`); if multiple instances exist, return an error listing available instances so the agent can choose. The `--context` parameter allows targeting `server` or `client` contexts in Play mode. Unlike the CLI, the MCP server does NOT launch Studio when no sessions are available -- it returns an error with guidance. - -### 9.4 Configuration - -Register studio-bridge as an MCP tool provider in Claude Code: - -```json -{ - "mcpServers": { - "studio-bridge": { - "command": "studio-bridge", - "args": ["mcp"] - } - } -} -``` - -## 10. Security Considerations - -### 10.1 Increased attack surface with persistent plugin - -The temporary plugin model has a narrow security window: the plugin only exists for the duration of a test run. The persistent plugin is always loaded in Studio, which means: - -**Risk: Localhost port scanning** -Any process on the machine can connect to the WebSocket server. Mitigations: -- WebSocket upgrade is only accepted on `/plugin` and `/client` paths; all other paths return 404 -- The bridge host validates plugin `register` messages before accepting connections -- In ephemeral mode, the session ID in the WebSocket path acts as an unguessable token (UUIDv4), preserving existing behavior -- All connections (including split-server mode) are localhost-only or over secure port-forwarded localhost - -**Risk: Stale plugin after uninstall** -If a user uninstalls studio-bridge but the persistent plugin remains, it will keep attempting discovery connections. Mitigations: -- `install-plugin` command prints clear instructions about how to uninstall -- Plugin has a configurable inactivity timeout after which it stops polling -- The plugin's polling (HTTP GET to `localhost:38741/health` with 500ms timeout) is lightweight - -### 10.2 No new network exposure - -The system only binds to `localhost`. No external network access is introduced. The persistent plugin uses the same `HttpService:CreateWebStreamClient` API as the temporary plugin, which Roblox restricts to `localhost` in Studio. - -### 10.3 CI environments - -In CI (GitHub Actions, etc.), the persistent plugin is not installed. The system falls back to temporary plugin injection, which requires no persistent state on the machine. The bridge host pattern has no disk state to clean up. - -## 11. Reference: Current File Paths - -These are the existing source files that the implementation will modify or interact with: - -| File | Role | -|------|------| -| `/workspaces/NevermoreEngine/tools/studio-bridge/src/server/studio-bridge-server.ts` | Main server class with state machine | -| `/workspaces/NevermoreEngine/tools/studio-bridge/src/server/web-socket-protocol.ts` | Message types and JSON codec | -| `/workspaces/NevermoreEngine/tools/studio-bridge/src/plugin/plugin-injector.ts` | Temporary plugin build + inject | -| `/workspaces/NevermoreEngine/tools/studio-bridge/src/process/studio-process-manager.ts` | Studio path resolution + launch | -| `/workspaces/NevermoreEngine/tools/studio-bridge/src/cli/cli.ts` | CLI entry point with yargs | -| `/workspaces/NevermoreEngine/tools/studio-bridge/src/cli/script-executor.ts` | Shared exec lifecycle for CLI commands | -| `/workspaces/NevermoreEngine/tools/studio-bridge/src/cli/args/global-args.ts` | `StudioBridgeGlobalArgs` interface | -| `/workspaces/NevermoreEngine/tools/studio-bridge/src/cli/commands/exec-command.ts` | `exec ` command | -| `/workspaces/NevermoreEngine/tools/studio-bridge/src/cli/commands/run-command.ts` | `run ` command | -| `/workspaces/NevermoreEngine/tools/studio-bridge/src/cli/commands/terminal/terminal-command.ts` | `terminal` command | -| `/workspaces/NevermoreEngine/tools/studio-bridge/src/cli/commands/terminal/terminal-mode.ts` | REPL orchestration | -| `/workspaces/NevermoreEngine/tools/studio-bridge/src/cli/commands/terminal/terminal-editor.ts` | Raw-mode editor | -| `/workspaces/NevermoreEngine/tools/studio-bridge/src/index.ts` | Public API exports | -| `/workspaces/NevermoreEngine/tools/studio-bridge/templates/studio-bridge-plugin/src/StudioBridgePlugin.server.lua` | Current plugin Luau source | -| `/workspaces/NevermoreEngine/tools/nevermore-cli/src/utils/job-context/local-job-context.ts` | Library consumer in nevermore-cli | diff --git a/studio-bridge/plans/tech-specs/01-protocol.md b/studio-bridge/plans/tech-specs/01-protocol.md deleted file mode 100644 index 7cd5c9159b..0000000000 --- a/studio-bridge/plans/tech-specs/01-protocol.md +++ /dev/null @@ -1,1648 +0,0 @@ -# Protocol Extensions: Technical Specification - -This document defines the extended WebSocket protocol for studio-bridge persistent sessions. It covers message versioning, request/response correlation, all new message types, capability negotiation, error handling, and backward compatibility. This is the companion document referenced from `00-overview.md` section 6. - -## 1. Design Principles - -1. **Additive only** -- New message types and fields are added alongside existing ones. No existing message type is removed or has its semantics changed. -2. **Old plugins keep working** -- A legacy plugin that speaks only `hello`/`output`/`scriptComplete` must work with a new server without modification. -3. **New plugins degrade gracefully** -- A new plugin connecting to an old server must detect the lack of extended capabilities and fall back to basic behavior. -4. **Correlation is typed** -- Request/response messages extend `RequestMessage` (which requires `requestId`), push messages extend `PushMessage` (no `requestId`). The type system enforces which messages carry correlation IDs rather than relying on optional fields. Legacy fire-and-forget messages remain valid. -5. **Typed, not stringly** -- Every message type has a dedicated TypeScript interface. The union types are exhaustive and the compiler enforces correctness. The `BaseMessage` / `RequestMessage` / `PushMessage` hierarchy makes the correlation semantics visible at the type level. - -## 2. Message Envelope - -### 2.1 Current envelope (preserved) - -Every message on the wire is a JSON object with three required fields: - -```typescript -{ - type: string; - sessionId: string; - payload: object; -} -``` - -This structure is unchanged. All existing messages continue to use it exactly as they do today. - -### 2.2 Extended envelope - -New messages may include two additional top-level fields: - -```typescript -{ - type: string; - sessionId: string; - payload: object; - requestId?: string; // present on request/response messages only - protocolVersion?: number; // present only in handshake messages (hello, welcome, register) -} -``` - -- **`requestId`** -- A caller-generated unique string (UUIDv4 recommended). Present on request messages and echoed back on the corresponding response or error. Absent on unsolicited push messages (`output`, `stateChange`, `logPush`, `heartbeat`). In the TypeScript type hierarchy, messages that require `requestId` extend `RequestMessage`, which makes it a required field. Messages that never have a `requestId` extend `PushMessage`. A few messages (`execute`, `scriptComplete`, `error`) use `BaseMessage` with an optional `requestId` because they bridge v1 and v2 behavior. -- **`protocolVersion`** -- An integer indicating which protocol revision the sender supports. Present only on `hello`, `welcome`, and `register` messages during handshake. Absent on all other messages (the negotiated version is established once and held for the connection lifetime). This field belongs in the wire envelope, not in the TypeScript base message types. - -Legacy messages that omit these fields are valid. A decoder must treat missing `requestId` as `undefined` and missing `protocolVersion` as `1` (the implicit version of the original protocol). - -## 3. Protocol Versioning - -### 3.1 Version numbering - -Versions are positive integers, not semver. Each version is a strict superset of the previous one. - -| Version | Capabilities | -|---------|-------------| -| 1 | Original protocol: `hello`, `welcome`, `execute`, `output`, `scriptComplete`, `shutdown` | -| 2 | Adds: `register`, `queryState`, `stateResult`, `captureScreenshot`, `screenshotResult`, `queryDataModel`, `dataModelResult`, `queryLogs`, `logsResult`, `subscribe`, `unsubscribe`, `stateChange`, `logPush`, `heartbeat`, `error`. Adds `requestId` correlation, `protocolVersion` negotiation, `capabilities` in handshake. | - -### 3.2 Negotiation during handshake - -The plugin sends its maximum supported version in the `hello` (or `register`) message: - -```json -{ - "type": "hello", - "sessionId": "abc-123", - "protocolVersion": 2, - "payload": { - "sessionId": "abc-123", - "capabilities": ["execute", "queryState", "captureScreenshot", "queryDataModel", "queryLogs", "subscribe"], - "pluginVersion": "1.0.0" - } -} -``` - -The server responds with the effective version -- the minimum of the server's version and the plugin's version: - -```json -{ - "type": "welcome", - "sessionId": "abc-123", - "protocolVersion": 2, - "payload": { - "sessionId": "abc-123", - "capabilities": ["execute", "queryState", "captureScreenshot", "queryDataModel", "queryLogs", "subscribe"] - } -} -``` - -The `capabilities` list in the `welcome` response is the intersection of what the plugin offered and what the server intends to use. The server must not send message types that require capabilities the plugin did not advertise. - -### 3.2.1 Plugin version negotiation - -The `hello` (or `register`) message includes an optional `pluginVersion` field (semver string, e.g., `"1.0.0"`). The server's `welcome` response includes a `serverVersion` field. The server compares `pluginVersion` to its own minimum-supported plugin version. If the plugin version is older than the minimum, the server still completes the handshake (to maintain backward compatibility), but logs a warning: `"Plugin version {pluginVersion} is older than recommended minimum {minVersion}. Some features may not work. Run 'studio-bridge install-plugin' to update."` The server also includes a `pluginUpdateAvailable: true` field in the `welcome` payload when the plugin is outdated. The CLI can surface this warning to the user on the next interactive command. The minimum-supported plugin version is a constant in the server code, bumped only when a protocol-breaking change is introduced. - -### 3.3 Omitted version field - -If `protocolVersion` is absent from `hello`, the server treats it as version 1. The server responds with a version 1 `welcome` (no `protocolVersion` field, no `capabilities`). This is exactly today's behavior. - -### 3.4 Forward compatibility - -If a plugin sends a `protocolVersion` higher than the server supports, the server clamps to its own maximum and responds accordingly. The plugin must handle receiving a lower version than it requested and disable features that require the higher version. - -If either side receives an unknown message type, it must ignore the message (not disconnect, not error). This allows future versions to add message types without breaking older peers. - -## 4. Request/Response Correlation - -### 4.1 Problem - -The current protocol is sequential: the server sends `execute`, then waits for `output` messages followed by a single `scriptComplete`. There is no way to have two operations in flight simultaneously, and no way to match a response to a specific request. - -### 4.2 Solution - -Request messages include a `requestId` field. The corresponding response echoes the same `requestId`. The server can have multiple requests in flight to the same plugin, and the plugin can respond to them in any order. - -``` -Server → Plugin: { type: "queryState", sessionId: "...", requestId: "req-001", payload: {} } -Server → Plugin: { type: "queryLogs", sessionId: "...", requestId: "req-002", payload: { count: 50 } } - -Plugin → Server: { type: "logsResult", sessionId: "...", requestId: "req-002", payload: { ... } } -Plugin → Server: { type: "stateResult", sessionId: "...", requestId: "req-001", payload: { ... } } -``` - -### 4.3 Rules - -- Every request message (server-to-plugin query) must include a `requestId`. -- The corresponding response must echo the exact same `requestId`. -- If a request cannot be fulfilled, the plugin sends an `error` message with that `requestId` (see section 7). -- Unsolicited push messages (`output`, `stateChange`, `logPush`, `heartbeat`) do not have a `requestId`. -- The legacy `execute` message may optionally include a `requestId`. If present, `scriptComplete` echoes it. If absent, the existing sequential behavior applies. This preserves backward compatibility with old servers that send `execute` without a `requestId`. -- The server must time out requests that receive no response. Default timeouts are per-message-type (see the Timeout Defaults table in section 7.4). On timeout, the server resolves with an error locally; it does not send a cancellation to the plugin. - -### 4.4 Concurrency limits - -The plugin may execute at most one `execute` script at a time (Luau is single-threaded; concurrent `loadstring` calls would interfere). If the server sends a second `execute` while the first is in flight, the plugin must queue it and respond with each `scriptComplete` in order. Queries (`queryState`, `queryLogs`, `queryDataModel`, `captureScreenshot`) are lightweight and can be processed concurrently with a running script. - -## 5. Complete Message Type Catalog - -### 5.1 Existing messages (version 1, unchanged) - -These six message types are defined in the current `web-socket-protocol.ts` and are fully preserved. - -**Plugin to Server:** - -| Type | Payload | Purpose | -|------|---------|---------| -| `hello` | `{ sessionId: string }` | Initiate handshake | -| `output` | `{ messages: Array<{ level: OutputLevel, body: string }> }` | Batched log output | -| `scriptComplete` | `{ success: boolean, error?: string }` | Script execution finished | - -**Server to Plugin:** - -| Type | Payload | Purpose | -|------|---------|---------| -| `welcome` | `{ sessionId: string }` | Accept handshake | -| `execute` | `{ script: string }` | Run a Luau script | -| `shutdown` | `{}` | Request disconnect | - -### 5.2 New Server to Plugin messages (version 2) - -#### `queryState` - -Request the current Studio state. - -```typescript -{ - type: 'queryState'; - sessionId: string; - requestId: string; - payload: {}; -} -``` - -Expected response: `stateResult` or `error`. - -#### `captureScreenshot` - -Request a viewport capture. - -```typescript -{ - type: 'captureScreenshot'; - sessionId: string; - requestId: string; - payload: { - format?: 'png'; // only png supported initially; field reserved for future formats - }; -} -``` - -Expected response: `screenshotResult` or `error`. - -The plugin uses `CaptureService:CaptureScreenshot(callback)` to capture the 3D viewport. CaptureService is confirmed to work in Studio plugins. The callback receives a `contentId` string, which is loaded into an `EditableImage` (via `AssetService:CreateEditableImageAsync(contentId)` or similar). The pixel bytes are read from the `EditableImage` and base64-encoded before transmission. See `04-action-specs.md` section 4 (screenshot plugin handler) for the full call chain. - -#### `queryDataModel` - -Query the instance tree and/or properties. - -```typescript -{ - type: 'queryDataModel'; - sessionId: string; - requestId: string; - payload: { - path: string; // dot-separated instance path, e.g. "game.Workspace.SpawnLocation" - depth?: number; // max child traversal depth (default: 0 = instance only, no children) - properties?: string[]; // property names to read (default: Name, ClassName, Parent) - includeAttributes?: boolean; // include all attributes (default: false) - find?: { // optional: search for instances by name - name: string; // instance name to search for - recursive?: boolean; // true = FindFirstDescendant, false = FindFirstChild (default: false) - }; - listServices?: boolean; // if true, ignores path and returns all loaded services (default: false) - }; -} -``` - -When `find` is provided, the plugin resolves `path` first, then calls `FindFirstChild(name)` or searches descendants. The result is the found instance (or an error if not found). - -When `listServices` is true, the plugin returns a list of all services loaded in the DataModel as the children of `game`. The `path` field is ignored. - -Expected response: `dataModelResult` or `error`. - -**Path format**: Dot-separated, matching Roblox convention. All paths in the wire protocol start from `game` (the DataModel root). The plugin resolves the path by splitting on `.` and calling `FindFirstChild` at each segment starting from `game`. Examples: -- `game.Workspace` -- the Workspace service -- `game.Workspace.SpawnLocation` -- a named child of Workspace -- `game.Workspace.Part1.Position` -- a property path (the plugin resolves up to the instance, then reads the property) -- `game.ReplicatedStorage.Modules.MyModule` -- nested path -- `game.StarterPlayer.StarterPlayerScripts` -- service child - -**Path resolution algorithm** (plugin side): -1. Split the path on `.` to get segments: `["game", "Workspace", "SpawnLocation"]`. -2. Start at `game` (the DataModel root). Skip the first segment (which must be `"game"`). -3. For each subsequent segment, call `current:FindFirstChild(segment)`. -4. If `FindFirstChild` returns `nil` at any point, return an `INSTANCE_NOT_FOUND` error with `resolvedTo` (the dot-path of the last successful instance) and `failedSegment` (the segment that failed). -5. The final resolved instance is the target for property reads, child enumeration, etc. - -**Edge case -- instance names containing dots**: Instance names containing literal dots (e.g., a Part named `"my.part"`) are rare in practice. The current path format does not support escaping dots. If an instance name contains a dot, `FindFirstChild` will fail to resolve it because the dot is treated as a path separator. This is a known limitation. Implementers may choose to document this as unsupported, or add escaping support (e.g., backslash-dot `\.`) in a future protocol version. - -**CLI path translation**: The CLI accepts user-facing paths without the `game.` prefix (e.g., `studio-bridge query Workspace.SpawnLocation`). The CLI prepends `game.` before sending the `queryDataModel` message. If the user explicitly includes `game.` the CLI does not double-prefix. This keeps the CLI ergonomic while the wire protocol is unambiguous. - -#### `queryLogs` - -Request buffered log history from the plugin. - -```typescript -{ - type: 'queryLogs'; - sessionId: string; - requestId: string; - payload: { - count?: number; // max entries to return (default: 50) - direction?: 'head' | 'tail'; // 'head' = oldest first from start, 'tail' = newest first from end (default: 'tail') - levels?: OutputLevel[]; // filter by level (default: all levels) - includeInternal?: boolean; // include [StudioBridge] internal messages (default: false) - }; -} -``` - -Expected response: `logsResult` or `error`. - -The plugin maintains a ring buffer of log entries (default capacity: 1000). This query reads from that buffer. - -- `direction: 'tail'` (default): Returns the most recent `count` entries, in chronological order. This maps to the CLI's `--tail` flag. -- `direction: 'head'`: Returns the oldest `count` entries from the buffer, in chronological order. This maps to the CLI's `--head` flag. - -Internal `[StudioBridge]` messages are filtered out by default (`includeInternal: false`). The CLI's `--all` flag maps to `includeInternal: true`. - -#### `subscribe` - -Subscribe to push events from the plugin. - -```typescript -{ - type: 'subscribe'; - sessionId: string; - requestId: string; - payload: { - events: SubscribableEvent[]; - }; -} -``` - -Where `SubscribableEvent` is one of: -- `'stateChange'` -- receive `stateChange` push messages when Studio transitions between modes. This is the mechanism that backs the CLI's `studio-bridge state --watch` mode. Transport: WebSocket push. The plugin sends `stateChange` messages over its WebSocket connection to the bridge host; the host forwards them to all CLI clients that have an active `stateChange` subscription for that session. -- `'logPush'` -- receive `logPush` push messages as log entries are generated (from `LogService.MessageOut`). This is the mechanism that backs the CLI's `studio-bridge logs --follow` mode. Transport: WebSocket push. The plugin sends `logPush` messages over its WebSocket connection to the bridge host; the host forwards them to all CLI clients that have an active `logPush` subscription for that session. Unlike the `output` message (which is scoped to script execution and batches multiple lines), `logPush` is a continuous stream of individual log entries from all sources, not limited to script output. - -The full subscription flow is: - -1. **CLI sends `subscribe`** to the bridge host with the desired events (e.g., `['stateChange']` or `['logPush']`). -2. **Bridge host forwards `subscribe`** to the plugin over the plugin's WebSocket connection. -3. **Plugin confirms** by sending a `subscribeResult` response, then begins pushing the requested event messages (`stateChange` and/or `logPush`). -4. **Bridge host forwards push messages** from the plugin to all CLI clients that are subscribed to that event for that session. -5. **CLI sends `unsubscribe`** to stop receiving push messages. The bridge host forwards the `unsubscribe` to the plugin, which confirms with `unsubscribeResult` and stops pushing. - -Subscriptions are maintained as a map on the bridge host: `Map>` per session. See `07-bridge-network.md` for the host-side routing details. - -The plugin confirms the subscription by sending a `subscribeResult` response, then begins pushing the requested event messages. - -```typescript -// Plugin → Server (confirmation) -{ - type: 'subscribeResult'; - sessionId: string; - requestId: string; - payload: { - events: SubscribableEvent[]; // the events actually subscribed (may be subset if some unsupported) - }; -} -``` - -Subscriptions persist for the lifetime of the WebSocket connection. They do not survive reconnection; the server must resubscribe after the plugin reconnects. - -#### `unsubscribe` - -Cancel one or more event subscriptions. - -```typescript -{ - type: 'unsubscribe'; - sessionId: string; - requestId: string; - payload: { - events: SubscribableEvent[]; - }; -} -``` - -Expected response: `unsubscribeResult` echoing the events that were actually unsubscribed. - -```typescript -{ - type: 'unsubscribeResult'; - sessionId: string; - requestId: string; - payload: { - events: SubscribableEvent[]; - }; -} -``` - -### 5.3 New Plugin to Server messages (version 2) - -#### `register` - -Alternative to `hello` for persistent plugin sessions. The persistent plugin uses `register` instead of `hello` to provide richer metadata about itself. - -The plugin generates a UUID (via `HttpService:GenerateGUID()` in Luau) and sends it as the `sessionId` in the `register` message. The server accepts the plugin's ID unless there is a collision with an existing session, in which case the server generates a replacement ID. The server's `welcome` response contains the authoritative `sessionId`. The plugin must use the `sessionId` from the `welcome` response for all subsequent messages (in case the server overrode it). - -```typescript -{ - type: 'register'; - sessionId: string; // plugin-generated UUID (proposed session ID) - protocolVersion: number; - payload: { - pluginVersion: string; // semver of the installed persistent plugin - instanceId: string; // unique ID for this Studio installation (persisted in plugin settings, shared across all contexts of the same Studio) - context: SessionContext; // which plugin context is connecting: 'edit', 'client', or 'server' - placeName: string; // DataModel.Name - placeId: number; // game.PlaceId (0 if unpublished) - gameId: number; // game.GameId (0 if unpublished) - placeFile?: string; // file path if available (may be nil for published-only places) - state: StudioState; // current run mode of THIS context (not the whole Studio) - pid?: number; // Studio process ID if detectable - capabilities: Capability[]; - }; -} -``` - -The server responds with a `welcome` message, identical to the `hello` flow. The `register` message is treated as a superset of `hello` -- it establishes the handshake and provides discovery metadata in a single message. The `welcome` response's `sessionId` is authoritative -- the plugin must adopt it, since the server may have overridden the plugin's proposed ID (e.g., due to a collision). - -**Multi-context sessions**: When Studio enters Play mode, 2 new plugin instances (server and client) connect independently, joining the already-connected edit instance. Each sends its own `register` message over its own WebSocket: -- **Edit context** (`context: 'edit'`): Always present. `state` is always `'Edit'`. -- **Play-Server context** (`context: 'server'`): Present during Play/Run. `state` is `'Run'` or `'Paused'`. -- **Play-Client context** (`context: 'client'`): Present during Play. `state` is `'Play'` or `'Paused'`. - -All three share the same `instanceId` (identifying the Studio installation) but have different `context` values. The server uses the `(instanceId, context)` pair to uniquely identify each connection. The `state` field in the `register` message reflects the state of that specific context, not the state of the Studio as a whole. - -If the server does not recognize `register` (old server, version 1), the plugin falls back to sending `hello` instead. - -#### `stateResult` - -Response to `queryState`. - -```typescript -{ - type: 'stateResult'; - sessionId: string; - requestId: string; - payload: { - state: StudioState; - placeId: number; // 0 if unpublished - placeName: string; - gameId: number; // 0 if unpublished - }; -} -``` - -Where `StudioState` is: - -```typescript -type StudioState = 'Edit' | 'Play' | 'Paused' | 'Run' | 'Server' | 'Client'; -``` - -These map to Roblox Studio's run modes and are **per-context**, not per-Studio. Each WebSocket connection (each plugin context) reports its own state independently: - -- `Edit` -- normal editing, not playing. The Edit context is always in this state. -- `Play` -- Play solo, client context. The Client context reports this state during active play. -- `Run` -- Run mode (server context, no character). The Server context reports this state during active play. -- `Paused` -- Play or Run, but paused. Whichever context is paused reports this state. -- `Server` -- Team Test server. -- `Client` -- Team Test client. - -States are **not mutually exclusive across contexts**. During Play mode, the Server context may report `'Run'` while the Client context simultaneously reports `'Play'`. Each context's state is independent. - -#### `screenshotResult` - -Response to `captureScreenshot`. - -```typescript -{ - type: 'screenshotResult'; - sessionId: string; - requestId: string; - payload: { - data: string; // base64-encoded image data - format: 'png'; - width: number; // pixel dimensions of the captured image - height: number; - }; -} -``` - -**Size considerations**: A 1920x1080 PNG screenshot is typically 500KB-2MB, which base64-encodes to 670KB-2.7MB. WebSocket frames can handle this, but the server should set a generous max frame size (at least 10MB) and the implementation should be aware of memory pressure when handling multiple screenshots in flight. - -#### `dataModelResult` - -Response to `queryDataModel`. - -```typescript -{ - type: 'dataModelResult'; - sessionId: string; - requestId: string; - payload: { - instance: DataModelInstance; - }; -} -``` - -Where `DataModelInstance` is a recursive structure: - -```typescript -interface DataModelInstance { - name: string; - className: string; - path: string; // full dot-separated path from game - properties: Record; - attributes: Record; - childCount: number; - children?: DataModelInstance[]; // present only if depth > 0 was requested -} -``` - -And `SerializedValue` handles Roblox types. Primitive types (string, number, boolean) are passed through as bare JSON values without wrapping. Complex Roblox types use a `type` discriminant field and a flat `value` array containing the numeric components: - -```typescript -type SerializedValue = - | string // bare primitive - | number // bare primitive - | boolean // bare primitive - | null - | { type: 'Vector3'; value: [number, number, number] } // [x, y, z] - | { type: 'Vector2'; value: [number, number] } // [x, y] - | { type: 'CFrame'; value: [number, number, number, number, number, number, number, number, number, number, number, number] } - // [posX, posY, posZ, r00, r01, r02, r10, r11, r12, r20, r21, r22] -- position xyz + 9 rotation matrix components - | { type: 'Color3'; value: [number, number, number] } // [r, g, b] in 0-1 range - | { type: 'UDim2'; value: [number, number, number, number] } // [xScale, xOffset, yScale, yOffset] - | { type: 'UDim'; value: [number, number] } // [scale, offset] - | { type: 'BrickColor'; name: string; value: number } // name + numeric ID - | { type: 'EnumItem'; enum: string; name: string; value: number } // enum type name, item name, numeric value - | { type: 'Instance'; className: string; path: string } // reference to another instance via dot-path - | { type: 'Unsupported'; typeName: string; toString: string }; // fallback for types we cannot serialize -``` - -**Wire examples**: - -```json -// Vector3 -{ "type": "Vector3", "value": [1, 2, 3] } - -// Vector2 -{ "type": "Vector2", "value": [1, 2] } - -// CFrame (position xyz + 9 rotation matrix components) -{ "type": "CFrame", "value": [1, 2, 3, 1, 0, 0, 0, 1, 0, 0, 0, 1] } - -// Color3 -{ "type": "Color3", "value": [0.5, 0.2, 1.0] } - -// UDim2 -{ "type": "UDim2", "value": [0.5, 100, 0.5, 200] } - -// UDim -{ "type": "UDim", "value": [0.5, 100] } - -// BrickColor -{ "type": "BrickColor", "name": "Bright red", "value": 21 } - -// EnumItem -{ "type": "EnumItem", "enum": "Material", "name": "Plastic", "value": 256 } - -// Instance reference (dot-separated path) -{ "type": "Instance", "className": "Part", "path": "game.Workspace.Part1" } - -// Primitives are passed as-is without wrapping -"hello" -42 -true - -// Unsupported type (fallback) -{ "type": "Unsupported", "typeName": "Ray", "toString": "Ray(0, 0, 0, 1, 0, 0)" } -``` - -The `type` discriminant field allows the receiver to reconstruct or display Roblox-specific types. The flat `value` array format is compact and easy to destructure. Simple types (string, number, boolean) are passed through as JSON primitives without wrapping. The `Unsupported` variant ensures the plugin never fails to serialize a property -- it always produces a string representation as a last resort. - -#### `logsResult` - -Response to `queryLogs`. - -```typescript -{ - type: 'logsResult'; - sessionId: string; - requestId: string; - payload: { - entries: Array<{ - level: OutputLevel; - body: string; - timestamp: number; // milliseconds since plugin connection established (monotonic) - }>; - total: number; // total entries in the ring buffer (before offset/count filtering) - bufferCapacity: number; // max entries the ring buffer can hold - }; -} -``` - -Timestamps are relative to the plugin's connection time rather than wall-clock time, because Roblox's `os.clock()` provides a monotonic timer but `os.time()` is only second-precision and cannot be reliably correlated with the server's clock. - -#### `stateChange` - -Unsolicited push notification when a plugin context transitions between run modes. Only sent if the server has an active `stateChange` subscription. This message is **per-WebSocket-connection** (per-context), not per-Studio -- each context reports its own state transitions independently over its own WebSocket. - -```typescript -{ - type: 'stateChange'; - sessionId: string; - payload: { - previousState: StudioState; - newState: StudioState; - timestamp: number; // monotonic ms since connection - }; -} -``` - -No `requestId` -- this is a push message. - -The plugin detects state changes by listening to `RunService` events: -- `RunService:IsEdit()` transitions -- `RunService.Running` / `RunService.Stopped` signals -- `RunService:IsRunMode()`, `RunService:IsClient()`, `RunService:IsServer()` checks - -Because each context has its own WebSocket, a single "Play" button press in Studio may produce `stateChange` messages on multiple connections simultaneously (e.g., the Server context transitions to `'Run'` and the Client context transitions to `'Play'`). - -#### `logPush` - -Unsolicited push notification containing a log entry generated by the plugin context. Only sent if the server has an active `logPush` subscription. This is the continuous log stream that backs `studio-bridge logs --follow`. Unlike the `output` message (which batches log lines produced during script execution), `logPush` streams individual entries from all sources (LogService, print, warn, error) regardless of whether a script is executing. - -```typescript -{ - type: 'logPush'; - sessionId: string; - payload: { - entry: { - level: OutputLevel; // 'Print' | 'Info' | 'Warning' | 'Error' - body: string; - timestamp: number; // monotonic ms since plugin connection - }; - }; -} -``` - -No `requestId` -- this is a push message. - -The plugin generates `logPush` messages by listening to `LogService.MessageOut`. When a `logPush` subscription is active, each log entry is sent individually as it occurs (not batched). Internal `[StudioBridge]` messages are included in the push stream; filtering is the responsibility of the receiving CLI client, which applies the user's `--level` and `--all` flags locally. - -#### `heartbeat` - -Keep-alive message from the plugin to the server, sent at a regular interval to prevent WebSocket idle timeouts and to allow the server to detect stale connections quickly. - -```typescript -{ - type: 'heartbeat'; - sessionId: string; - payload: { - uptimeMs: number; // ms since plugin connected - state: StudioState; // current state as a convenience - pendingRequests: number; // number of unfinished requests the plugin is processing - }; -} -``` - -No `requestId` -- this is an unsolicited push message. - -### Heartbeat Protocol - -- **Plugin → Server**: Every 15 seconds -- **Server stale detection**: 45 seconds (3 missed heartbeats) → mark session as stale -- **Server disconnect**: 60 seconds (4 missed heartbeats) → remove session, emit `session-disconnected` -- **Heartbeat payload**: `{ uptimeMs: number, state: StudioState, pendingRequests: number }` - -The server does not respond to heartbeats. Stale detection and disconnect thresholds are based on missed heartbeat intervals as described above. - -#### `subscribeResult` - -Confirmation of a `subscribe` request. See section 5.2 under `subscribe`. - -#### `unsubscribeResult` - -Confirmation of an `unsubscribe` request. See section 5.2 under `unsubscribe`. - -### 5.4 Error message (bidirectional, version 2) - -Either side can send an `error` message, though in practice it is almost always the plugin responding to a server request. - -```typescript -{ - type: 'error'; - sessionId: string; - requestId?: string; // present if this is a response to a specific request - payload: { - code: ErrorCode; - message: string; // human-readable description - details?: unknown; // optional structured data for debugging - }; -} -``` - -### 5.5 Extended `hello` and `welcome` (version 2 additions) - -When a version 2 plugin sends `hello`, it includes additional optional fields: - -```typescript -// Extended hello payload (version 2) -{ - type: 'hello'; - sessionId: string; - protocolVersion: 2; - payload: { - sessionId: string; // preserved from v1 - capabilities?: Capability[]; // new in v2 - pluginVersion?: string; // new in v2 - }; -} -``` - -When a version 2 server sends `welcome`, it includes: - -```typescript -// Extended welcome payload (version 2) -{ - type: 'welcome'; - sessionId: string; // authoritative session ID (confirms or overrides the plugin's proposed ID) - protocolVersion: 2; - payload: { - sessionId: string; // same as envelope sessionId (preserved from v1 for backward compat) - capabilities?: Capability[]; // new in v2, intersection of plugin + server capabilities - serverVersion?: string; // new in v2 - }; -} -``` - -The `sessionId` in the `welcome` response is authoritative. If the plugin sent a `register` message with a proposed session ID, the server may accept it as-is or override it (e.g., if it collides with an existing session). The plugin must use the `sessionId` from the `welcome` response for all subsequent messages. - -If `capabilities` is omitted from `hello`, the server assumes `['execute']` only (version 1 behavior). - -## 6. Capabilities - -### 6.1 Capability strings - -```typescript -type Capability = - | 'execute' // run Luau scripts (required; always present) - | 'queryState' // query Studio run mode and place info - | 'captureScreenshot' // capture viewport as PNG - | 'queryDataModel' // query instance tree and properties - | 'queryLogs' // retrieve buffered log history - | 'subscribe' // subscribe to push events - | 'heartbeat'; // send periodic heartbeat -``` - -### 6.2 Negotiation rules - -1. The plugin advertises all capabilities it supports. -2. The server responds with the subset it intends to use (may be all, may be fewer). -3. The server must not send a message type that requires a capability the plugin did not advertise. -4. If the server sends a `queryState` to a plugin that did not advertise `queryState`, the plugin should respond with an `error` of code `CAPABILITY_NOT_SUPPORTED`. -5. The `execute` capability is always implicitly present. Even a version 1 plugin supports it. - -### 6.2.1 Capability profiles by plugin type - -```typescript -type Capability = 'execute' | 'queryState' | 'captureScreenshot' | 'queryLogs' | 'queryDataModel' | 'subscribe'; -``` - -- Ephemeral (v1) plugins: `['execute']` -- Persistent (v2) plugins: all capabilities - -The server checks capabilities before dispatching actions; it returns `UNSUPPORTED_CAPABILITY` if the plugin doesn't advertise the required capability. - -### 6.3 Capability requirements by message type - -| Message | Required Capability | -|---------|-------------------| -| `execute` | `execute` | -| `queryState` | `queryState` | -| `captureScreenshot` | `captureScreenshot` | -| `queryDataModel` | `queryDataModel` | -| `queryLogs` | `queryLogs` | -| `subscribe` / `unsubscribe` | `subscribe` | -| `heartbeat` | `heartbeat` | -| `shutdown` | (none -- always valid) | - -## 7. Error Handling - -### 7.1 Error codes - -```typescript -type ErrorCode = - | 'UNKNOWN_REQUEST' // message type not recognized - | 'INVALID_PAYLOAD' // payload failed validation - | 'TIMEOUT' // operation timed out within the plugin - | 'CAPABILITY_NOT_SUPPORTED' // plugin does not support the requested capability - | 'INSTANCE_NOT_FOUND' // DataModel path did not resolve to an instance - | 'PROPERTY_NOT_FOUND' // requested property does not exist on the instance - | 'SCREENSHOT_FAILED' // CaptureService call failed - | 'SCRIPT_LOAD_ERROR' // loadstring failed (syntax error) - | 'SCRIPT_RUNTIME_ERROR' // script threw during execution - | 'BUSY' // plugin is already processing a request of this type - | 'SESSION_MISMATCH' // session ID in message does not match connection - | 'INTERNAL_ERROR'; // unexpected plugin-side error -``` - -### 7.2 Error response format - -```json -{ - "type": "error", - "sessionId": "abc-123", - "requestId": "req-001", - "payload": { - "code": "INSTANCE_NOT_FOUND", - "message": "No instance found at path: game.Workspace.NonExistent", - "details": { - "resolvedTo": "game.Workspace", - "failedSegment": "NonExistent" - } - } -} -``` - -### 7.3 Error vs. scriptComplete - -For backward compatibility, `execute` failures continue to use `scriptComplete` with `success: false` and an `error` string. The `error` message type is used for query failures and protocol-level errors. This avoids breaking existing consumers that parse `scriptComplete`. - -If an `execute` message includes a `requestId` and the script fails, both the `scriptComplete` response (with `requestId`) and the error details in its `error` field carry the failure information. The `error` message type is not sent for script failures; `scriptComplete` is the canonical response. - -### Error Retryability - -| Error | Retryable | Action | -|-------|-----------|--------| -| `TIMEOUT` | Yes | Retry with same parameters; consider increasing timeout | -| `SESSION_NOT_FOUND` | No | Session does not exist; re-resolve with `resolveSession()` | -| `SESSION_DISCONNECTED` | Yes (after reconnect) | Wait for `session-connected` event, then retry | -| `PLUGIN_ERROR` | Maybe | Plugin-side error; inspect `details` field | -| `INVALID_REQUEST` | No | Malformed request; fix the caller | -| `UNSUPPORTED_CAPABILITY` | No | Plugin does not support this action | -| `HOST_UNREACHABLE` | Yes | Bridge host down; retry with exponential backoff | - -### 7.4 Server-side timeout handling - -The server maintains a pending request map keyed by `requestId`. When a request times out: - -1. The promise associated with the `requestId` is rejected with a timeout error. -2. The `requestId` is removed from the pending map. -3. No message is sent to the plugin. If the plugin eventually responds, the response is ignored (no matching `requestId` in the pending map). - -### Timeout Defaults - -| Action | Default Timeout | Notes | -|--------|----------------|-------| -| `execute` | 300,000 ms (5 min) | Script execution; overridable per-call | -| `queryState` | 5,000 ms | Fast local operation | -| `captureScreenshot` | 15,000 ms | Rendering + encoding | -| `queryLogs` | 5,000 ms | Read from ring buffer | -| `queryDataModel` | 30,000 ms | Large tree traversal possible | -| `subscribe` | 5,000 ms | Fast registration | -| `unsubscribe` | 5,000 ms | Fast deregistration | -| `register` | 10,000 ms | Handshake + capability negotiation | - -This table is the single source of truth for timeout defaults. All implementations must use these values unless the caller explicitly overrides. - -## 8. Complete TypeScript Type Definitions - -This section provides the full type hierarchy as it would appear in the updated `web-socket-protocol.ts`. - -```typescript -// =========================================================================== -// Shared types -// =========================================================================== - -export type OutputLevel = 'Print' | 'Info' | 'Warning' | 'Error'; - -export type StudioState = 'Edit' | 'Play' | 'Paused' | 'Run' | 'Server' | 'Client'; - -/** Which plugin context this connection represents. */ -export type SessionContext = 'edit' | 'client' | 'server'; - -export type SubscribableEvent = 'stateChange' | 'logPush'; - -export type SessionOrigin = 'user' | 'managed'; - -/** Server-side representation of a connected plugin context. */ -export interface SessionInfo { - sessionId: string; - instanceId: string; // shared across all contexts of the same Studio installation - context: SessionContext; // which plugin context this connection represents - placeId: number; // game.PlaceId (0 if unpublished) - gameId: number; // game.GameId (0 if unpublished) - placeName: string; - state: StudioState; // current state of THIS context - pluginVersion: string; - capabilities: Capability[]; - connectedAt: number; // server timestamp (ms) when connection was established. - // The wire protocol uses a millisecond timestamp (number). - // The public TypeScript API converts this to a Date object. - // CLI/JSON output serializes as ISO 8601 string. -} - -export type Capability = - | 'execute' - | 'queryState' - | 'captureScreenshot' - | 'queryDataModel' - | 'queryLogs' - | 'subscribe' - | 'heartbeat'; - -export type ErrorCode = - | 'UNKNOWN_REQUEST' - | 'INVALID_PAYLOAD' - | 'TIMEOUT' - | 'CAPABILITY_NOT_SUPPORTED' - | 'INSTANCE_NOT_FOUND' - | 'PROPERTY_NOT_FOUND' - | 'SCREENSHOT_FAILED' - | 'SCRIPT_LOAD_ERROR' - | 'SCRIPT_RUNTIME_ERROR' - | 'BUSY' - | 'SESSION_MISMATCH' - | 'INTERNAL_ERROR'; - -export type SerializedValue = - | string - | number - | boolean - | null - | { type: 'Vector3'; value: [number, number, number] } - | { type: 'Vector2'; value: [number, number] } - | { type: 'CFrame'; value: [number, number, number, number, number, number, number, number, number, number, number, number] } - | { type: 'Color3'; value: [number, number, number] } - | { type: 'UDim2'; value: [number, number, number, number] } - | { type: 'UDim'; value: [number, number] } - | { type: 'BrickColor'; name: string; value: number } - | { type: 'EnumItem'; enum: string; name: string; value: number } - | { type: 'Instance'; className: string; path: string } - | { type: 'Unsupported'; typeName: string; toString: string }; - -export interface DataModelInstance { - name: string; - className: string; - path: string; - properties: Record; - attributes: Record; - childCount: number; - children?: DataModelInstance[]; -} - -// =========================================================================== -// Base message hierarchy -// =========================================================================== -// -// Messages are split into three base types: -// -// BaseMessage -- all messages have `type` and `sessionId` -// RequestMessage -- request/response messages add a required `requestId` -// PushMessage -- unsolicited push messages (no requestId) -// -// Each concrete message extends the appropriate base. `protocolVersion` -// belongs only in the wire envelope (section 2), not in the type hierarchy. -// =========================================================================== - -interface BaseMessage { - type: string; - sessionId: string; -} - -interface RequestMessage extends BaseMessage { - requestId: string; -} - -interface PushMessage extends BaseMessage { - // no requestId -- unsolicited push messages -} - -// =========================================================================== -// Plugin -> Server messages -// =========================================================================== - -// --- Version 1 (preserved) --- - -export interface HelloMessage extends PushMessage { - type: 'hello'; - payload: { - sessionId: string; - capabilities?: Capability[]; - pluginVersion?: string; - }; -} - -export interface OutputMessage extends PushMessage { - type: 'output'; - payload: { - messages: Array<{ - level: OutputLevel; - body: string; - }>; - }; -} - -export interface ScriptCompleteMessage extends BaseMessage { - type: 'scriptComplete'; - requestId?: string; // present if the triggering execute had a requestId (v2), absent for v1 - payload: { - success: boolean; - error?: string; - }; -} - -// --- Version 2 (new) --- - -export interface RegisterMessage extends PushMessage { - type: 'register'; - // sessionId (from PushMessage/BaseMessage) is a plugin-generated UUID. - // The server accepts it or overrides it; the welcome response is authoritative. - protocolVersion: number; - payload: { - pluginVersion: string; - instanceId: string; - context: SessionContext; - placeName: string; - placeId: number; - gameId: number; - placeFile?: string; - state: StudioState; - pid?: number; - capabilities: Capability[]; - }; -} - -export interface StateResultMessage extends RequestMessage { - type: 'stateResult'; - payload: { - state: StudioState; - placeId: number; - placeName: string; - gameId: number; - }; -} - -export interface ScreenshotResultMessage extends RequestMessage { - type: 'screenshotResult'; - payload: { - data: string; - format: 'png'; - width: number; - height: number; - }; -} - -export interface DataModelResultMessage extends RequestMessage { - type: 'dataModelResult'; - payload: { - instance: DataModelInstance; - }; -} - -export interface LogsResultMessage extends RequestMessage { - type: 'logsResult'; - payload: { - entries: Array<{ - level: OutputLevel; - body: string; - timestamp: number; - }>; - total: number; - bufferCapacity: number; - }; -} - -export interface StateChangeMessage extends PushMessage { - type: 'stateChange'; - payload: { - previousState: StudioState; - newState: StudioState; - timestamp: number; - }; -} - -export interface LogPushMessage extends PushMessage { - type: 'logPush'; - payload: { - entry: { - level: OutputLevel; - body: string; - timestamp: number; - }; - }; -} - -export interface HeartbeatMessage extends PushMessage { - type: 'heartbeat'; - payload: { - uptimeMs: number; - state: StudioState; - pendingRequests: number; - }; -} - -export interface SubscribeResultMessage extends RequestMessage { - type: 'subscribeResult'; - payload: { - events: SubscribableEvent[]; - }; -} - -export interface UnsubscribeResultMessage extends RequestMessage { - type: 'unsubscribeResult'; - payload: { - events: SubscribableEvent[]; - }; -} - -export interface PluginErrorMessage extends BaseMessage { - type: 'error'; - requestId?: string; // present if this is a response to a specific request - payload: { - code: ErrorCode; - message: string; - details?: unknown; - }; -} - -// --- Union type --- - -export type PluginMessage = - // Version 1 - | HelloMessage - | OutputMessage - | ScriptCompleteMessage - // Version 2 - | RegisterMessage - | StateResultMessage - | ScreenshotResultMessage - | DataModelResultMessage - | LogsResultMessage - | StateChangeMessage - | LogPushMessage - | HeartbeatMessage - | SubscribeResultMessage - | UnsubscribeResultMessage - | PluginErrorMessage; - -// =========================================================================== -// Server -> Plugin messages -// =========================================================================== - -// --- Version 1 (preserved) --- - -export interface WelcomeMessage extends PushMessage { - type: 'welcome'; - // sessionId (from PushMessage/BaseMessage) is the authoritative session ID. - // Confirms the plugin's proposed ID, or overrides it if there was a collision. - payload: { - sessionId: string; // same as envelope sessionId (for backward compat) - capabilities?: Capability[]; - serverVersion?: string; - }; -} - -export interface ExecuteMessage extends BaseMessage { - type: 'execute'; - requestId?: string; // present in v2 for correlation, absent in v1 - payload: { - script: string; - }; -} - -export interface ShutdownMessage extends PushMessage { - type: 'shutdown'; - payload: Record; -} - -// --- Version 2 (new) --- - -export interface QueryStateMessage extends RequestMessage { - type: 'queryState'; - payload: {}; -} - -export interface CaptureScreenshotMessage extends RequestMessage { - type: 'captureScreenshot'; - payload: { - format?: 'png'; - }; -} - -export interface QueryDataModelMessage extends RequestMessage { - type: 'queryDataModel'; - payload: { - path: string; - depth?: number; - properties?: string[]; - includeAttributes?: boolean; - find?: { - name: string; - recursive?: boolean; - }; - listServices?: boolean; - }; -} - -export interface QueryLogsMessage extends RequestMessage { - type: 'queryLogs'; - payload: { - count?: number; - direction?: 'head' | 'tail'; - levels?: OutputLevel[]; - includeInternal?: boolean; - }; -} - -export interface SubscribeMessage extends RequestMessage { - type: 'subscribe'; - payload: { - events: SubscribableEvent[]; - }; -} - -export interface UnsubscribeMessage extends RequestMessage { - type: 'unsubscribe'; - payload: { - events: SubscribableEvent[]; - }; -} - -export interface ServerErrorMessage extends BaseMessage { - type: 'error'; - requestId?: string; // present if this is a response to a specific request - payload: { - code: ErrorCode; - message: string; - details?: unknown; - }; -} - -// --- Union type --- - -export type ServerMessage = - // Version 1 - | WelcomeMessage - | ExecuteMessage - | ShutdownMessage - // Version 2 - | QueryStateMessage - | CaptureScreenshotMessage - | QueryDataModelMessage - | QueryLogsMessage - | SubscribeMessage - | UnsubscribeMessage - | ServerErrorMessage; - -// =========================================================================== -// Encode / decode function signatures -// =========================================================================== - -/** - * Encode a server message to a JSON string for transmission. - * Handles both v1 and v2 message types. - */ -export function encodeMessage(msg: ServerMessage): string; - -/** - * Decode a raw JSON string from the plugin into a typed PluginMessage. - * Returns null if the message is malformed or has an unrecognized type. - * - * Version 2 behavior: unknown message types return null (not an error). - * The caller decides whether to log or ignore unknown types. - */ -export function decodePluginMessage(raw: string): PluginMessage | null; - -/** - * NEW: Decode a raw JSON string from the server into a typed ServerMessage. - * Used by test code and by the split-server CLI client. - * Returns null if the message is malformed. - */ -export function decodeServerMessage(raw: string): ServerMessage | null; -``` - -## 9. Backward Compatibility Matrix - -### 9.1 Old plugin (v1) connecting to new server (v2) - -| Aspect | Behavior | -|--------|----------| -| Handshake | Plugin sends `hello` without `protocolVersion`. Server detects v1, responds with v1-style `welcome` (no `capabilities`, no `protocolVersion`). | -| Execute | Server sends `execute`, plugin responds with `output` + `scriptComplete`. No `requestId` on any message. Works identically to today. | -| Queries | Server never sends `queryState`, `captureScreenshot`, etc. The server knows the plugin has no extended capabilities. | -| Shutdown | Unchanged. | -| Unknown messages | If the server accidentally sends a v2 message, the plugin's `MessageReceived` handler has a default case that ignores unknown types. No crash. | - -### 9.2 New plugin (v2) connecting to old server (v1) - -| Aspect | Behavior | -|--------|----------| -| Handshake | Plugin sends `hello` with `protocolVersion: 2` and `capabilities`. Old server ignores the extra fields (they are in `payload`, which the server does not validate beyond `sessionId`). Server responds with v1 `welcome`. | -| Detecting v1 server | Plugin checks the `welcome` response for `protocolVersion`. If absent, plugin knows it is v1 and disables extended features. | -| Execute | Plugin handles `execute` as before, responds with `output` + `scriptComplete`. | -| Heartbeat | Plugin may still send `heartbeat` messages. Old server's `decodePluginMessage` returns `null` for unknown types and ignores them. No crash. | -| Register | If plugin initially sends `register` (persistent mode, with a plugin-generated UUID as `sessionId`) and gets no response within 3 seconds, it falls back to `hello`. | - -### 9.3 Mixed version flow - -``` -New Plugin Old Server (v1) - | | - |-- register (v2, plugin-generated | - | sessionId) -------------------->| - | | (decodePluginMessage returns null, ignored) - | (3 second timeout) | - |-- hello (v1 fallback) ----------->| - | | - |<-------------- welcome (v1) ------| - | | - | (plugin detects v1, disables | - | extended features, uses | - | welcome.sessionId going forward) | -``` - -``` -Old Plugin (v1) New Server (v2) - | | - |-- hello (no version) ------------>| - | | (server detects v1) - |<-------------- welcome (v1) ------| - | | - | (server marks connection as v1, | - | only sends execute/shutdown) | -``` - -## 10. Wire Protocol Examples - -### 10.1 Full v2 session lifecycle - -``` -Plugin → Server (plugin generates UUID "a1b2c3" as proposed sessionId): -{ - "type": "register", - "sessionId": "a1b2c3", - "protocolVersion": 2, - "payload": { - "pluginVersion": "1.0.0", - "instanceId": "inst-xyz", - "context": "edit", - "placeName": "TestPlace", - "placeId": 1234567890, - "gameId": 9876543210, - "placeFile": "/Users/dev/game/TestPlace.rbxl", - "state": "Edit", - "pid": 12345, - "capabilities": ["execute", "queryState", "captureScreenshot", "queryDataModel", "queryLogs", "subscribe", "heartbeat"] - } -} - -Server → Plugin (server accepts "a1b2c3" -- no collision): -{ - "type": "welcome", - "sessionId": "a1b2c3", - "protocolVersion": 2, - "payload": { - "sessionId": "a1b2c3", - "capabilities": ["execute", "queryState", "captureScreenshot", "queryDataModel", "queryLogs", "subscribe"], - "serverVersion": "0.5.0" - } -} - -Server → Plugin: -{ - "type": "subscribe", - "sessionId": "a1b2c3", - "requestId": "sub-001", - "payload": { "events": ["stateChange", "logPush"] } -} - -Plugin → Server: -{ - "type": "subscribeResult", - "sessionId": "a1b2c3", - "requestId": "sub-001", - "payload": { "events": ["stateChange", "logPush"] } -} - -Server → Plugin: -{ - "type": "queryState", - "sessionId": "a1b2c3", - "requestId": "req-001", - "payload": {} -} - -Plugin → Server: -{ - "type": "stateResult", - "sessionId": "a1b2c3", - "requestId": "req-001", - "payload": { - "state": "Edit", - "placeId": 1234567890, - "placeName": "TestPlace", - "gameId": 9876543210 - } -} - -Server → Plugin: -{ - "type": "execute", - "sessionId": "a1b2c3", - "requestId": "req-002", - "payload": { "script": "print('Hello from persistent session')" } -} - -Plugin → Server: -{ - "type": "output", - "sessionId": "a1b2c3", - "payload": { - "messages": [{ "level": "Print", "body": "Hello from persistent session" }] - } -} - -Plugin → Server: -{ - "type": "scriptComplete", - "sessionId": "a1b2c3", - "requestId": "req-002", - "payload": { "success": true } -} - -Plugin → Server: -{ - "type": "heartbeat", - "sessionId": "a1b2c3", - "payload": { "uptimeMs": 45000, "state": "Edit", "pendingRequests": 0 } -} - -Plugin → Server: -{ - "type": "stateChange", - "sessionId": "a1b2c3", - "payload": { "previousState": "Edit", "newState": "Play", "timestamp": 47230 } -} - -Server → Plugin: -{ - "type": "queryDataModel", - "sessionId": "a1b2c3", - "requestId": "req-003", - "payload": { - "path": "game.Workspace.SpawnLocation", - "depth": 0, - "properties": ["Position", "Anchored", "Size"], - "includeAttributes": false - } -} - -Plugin → Server: -{ - "type": "dataModelResult", - "sessionId": "a1b2c3", - "requestId": "req-003", - "payload": { - "instance": { - "name": "SpawnLocation", - "className": "SpawnLocation", - "path": "game.Workspace.SpawnLocation", - "properties": { - "Position": { "type": "Vector3", "value": [0, 4, 0] }, - "Anchored": true, - "Size": { "type": "Vector3", "value": [8, 1, 8] } - }, - "attributes": {}, - "childCount": 0 - } - } -} - -Server → Plugin: -{ - "type": "captureScreenshot", - "sessionId": "a1b2c3", - "requestId": "req-004", - "payload": {} -} - -Plugin → Server: -{ - "type": "screenshotResult", - "sessionId": "a1b2c3", - "requestId": "req-004", - "payload": { - "data": "iVBORw0KGgoAAAANSUhEUgAA...", - "format": "png", - "width": 1920, - "height": 1080 - } -} - -Server → Plugin: -{ - "type": "shutdown", - "sessionId": "a1b2c3", - "payload": {} -} -``` - -### 10.2 Error response example - -``` -Server → Plugin: -{ - "type": "queryDataModel", - "sessionId": "a1b2c3", - "requestId": "req-005", - "payload": { - "path": "game.Workspace.NonExistentPart", - "depth": 0, - "properties": ["Position"] - } -} - -Plugin → Server: -{ - "type": "error", - "sessionId": "a1b2c3", - "requestId": "req-005", - "payload": { - "code": "INSTANCE_NOT_FOUND", - "message": "No instance found at path: game.Workspace.NonExistentPart", - "details": { - "resolvedTo": "game.Workspace", - "failedSegment": "NonExistentPart" - } - } -} -``` - -### 10.3 Concurrent requests example - -``` -Server → Plugin: (queryState, req-010) -Server → Plugin: (execute, req-011) -Server → Plugin: (queryLogs, req-012) - -Plugin → Server: (stateResult, req-010) // fast query returns first -Plugin → Server: (logsResult, req-012) // buffer read returns second -Plugin → Server: (output, no requestId) // script output streams -Plugin → Server: (output, no requestId) // more output -Plugin → Server: (scriptComplete, req-011) // script finishes last -``` - -### 10.4 Multi-context Play mode example - -When the user presses Play in Studio, 2 new plugin instances (server and client) connect independently, joining the already-connected edit instance. All share the same `instanceId` but report different `context` and `state` values. - -``` -Edit context plugin → Server (already connected): -{ - "type": "register", - "sessionId": "edit-001", - "protocolVersion": 2, - "payload": { - "pluginVersion": "1.0.0", - "instanceId": "inst-xyz", - "context": "edit", - "placeName": "TestPlace", - "placeId": 1234567890, - "gameId": 9876543210, - "placeFile": "/Users/dev/game/TestPlace.rbxl", - "state": "Edit", - "pid": 12345, - "capabilities": ["execute", "queryState", "queryDataModel", "queryLogs", "subscribe", "heartbeat"] - } -} - -Server context plugin → Server (new connection on Play): -{ - "type": "register", - "sessionId": "server-001", - "protocolVersion": 2, - "payload": { - "pluginVersion": "1.0.0", - "instanceId": "inst-xyz", - "context": "server", - "placeName": "TestPlace", - "placeId": 1234567890, - "gameId": 9876543210, - "state": "Run", - "pid": 12345, - "capabilities": ["execute", "queryState", "queryDataModel", "queryLogs", "subscribe", "heartbeat"] - } -} - -Client context plugin → Server (new connection on Play): -{ - "type": "register", - "sessionId": "client-001", - "protocolVersion": 2, - "payload": { - "pluginVersion": "1.0.0", - "instanceId": "inst-xyz", - "context": "client", - "placeName": "TestPlace", - "placeId": 1234567890, - "gameId": 9876543210, - "state": "Play", - "pid": 12345, - "capabilities": ["execute", "queryState", "captureScreenshot", "queryDataModel", "queryLogs", "subscribe", "heartbeat"] - } -} -``` - -The server responds with a `welcome` to each, confirming (or overriding) the plugin-generated `sessionId`. Note: -- Each plugin context generates its own UUID as the proposed `sessionId` (e.g., `"edit-001"`, `"server-001"`, `"client-001"`). The server accepts these unless there is a collision, in which case it overrides with a new UUID in the `welcome` response. -- All three share `instanceId: "inst-xyz"` -- the server uses this to group them as belonging to the same Studio. -- Each has a different `sessionId` and `context`. -- The Edit context remains in `state: "Edit"`. The Server context is `state: "Run"`. The Client context is `state: "Play"`. -- The Client context may advertise `captureScreenshot` since it has the 3D viewport. - -When the user stops Play mode, the Server and Client contexts disconnect (their WebSockets close). The Edit context remains connected and may send a `stateChange` if its state is affected. - -## 11. Decoder Implementation Notes - -### 11.1 Updating `decodePluginMessage` - -The existing `decodePluginMessage` function uses a `switch` on `type` and returns `null` for unknown types. This is already forward-compatible -- unknown v2 message types sent to a v1 server are safely ignored. - -For the v2 server, the switch gains new cases: - -```typescript -case 'register': - // validate payload.pluginVersion, payload.instanceId, payload.context, payload.placeId, payload.gameId, payload.capabilities, etc. - return { type: 'register', sessionId, protocolVersion, requestId, payload: { ... } }; - -case 'stateResult': - // validate requestId present, payload.state is valid StudioState, etc. - return { type: 'stateResult', sessionId, requestId, payload: { ... } }; - -// ... additional cases for each new PluginMessage type -``` - -### 11.2 Validation strategy - -Each message type validates its own payload fields strictly. If a required field is missing or has the wrong type, `decodePluginMessage` returns `null`. This matches the existing behavior and prevents malformed messages from propagating. - -Optional fields (`requestId`, `protocolVersion`, new optional payload fields) are extracted if present and omitted if absent. The TypeScript types use `?` to reflect this. - -### 11.3 `decodeServerMessage` (new) - -A symmetric function for decoding server messages, used by: -- Test code that simulates a plugin client -- The split-server CLI client that receives forwarded server messages -- Any future tooling that needs to parse server-side messages - -The implementation mirrors `decodePluginMessage` with a switch over server message types. - -## 12. Relationship to Action System - -The `00-overview.md` tech spec describes a generic action envelope (`ActionRequest` / `ActionResponse`) as an alternative framing for the protocol extensions. This document takes a different approach: each operation has its own named message type (`queryState` / `stateResult`, `captureScreenshot` / `screenshotResult`, etc.). - -The rationale: named message types are more explicit, produce better TypeScript unions (discriminated on `type`), and are easier to validate per-message. The generic action envelope is useful as a conceptual model but adds a level of indirection that complicates the type system without providing meaningful extensibility benefits -- adding a new operation requires defining types either way. - -If a future extension needs a truly generic action dispatch (e.g., user-defined plugin actions), it can be added as a single new message type (`customAction` / `customActionResult`) without retrofitting the existing named types. - -## 13. WebSocket Configuration - -### 13.1 Frame size limits - -The server must configure the WebSocket to accept frames up to 16MB to accommodate screenshot payloads. The `ws` library's `maxPayload` option: - -```typescript -new WebSocketServer({ port: 0, path: `/${sessionId}`, maxPayload: 16 * 1024 * 1024 }); -``` - -### 13.2 Compression - -WebSocket per-message compression (`permessage-deflate`) should be enabled for connections that negotiate v2, as screenshot and DataModel payloads benefit significantly. The `ws` library supports this natively: - -```typescript -new WebSocketServer({ - port: 0, - path: `/${sessionId}`, - maxPayload: 16 * 1024 * 1024, - perMessageDeflate: true, -}); -``` - -This is negotiated at the WebSocket level and is transparent to the JSON protocol. - -### 13.3 Heartbeat and idle timeout - -The server should configure a WebSocket-level ping/pong alongside the application-level heartbeat: - -- WebSocket ping: every 30 seconds (handled by `ws` library) -- Application heartbeat: every 15 seconds (sent by plugin) -- Stale detection: 45 seconds (3 missed heartbeats) with no heartbeat → mark session as stale -- Disconnect: 60 seconds (4 missed heartbeats) → remove session, emit `session-disconnected` - -See the Heartbeat Protocol section in 5.3 for the full specification. The application heartbeat carries state information that WebSocket pings do not, which is why both are needed. diff --git a/studio-bridge/plans/tech-specs/02-command-system.md b/studio-bridge/plans/tech-specs/02-command-system.md deleted file mode 100644 index 5d9f10ce54..0000000000 --- a/studio-bridge/plans/tech-specs/02-command-system.md +++ /dev/null @@ -1,1177 +0,0 @@ -# Unified Command System: Technical Specification - -This document describes how CLI commands, terminal dot-commands, and MCP tools share a single handler implementation. It is the companion document referenced from `00-overview.md` ("CLI command design, `connect` semantics, session selection heuristics"). - -## 1. Problem - -Studio-bridge currently has two separate command surfaces: - -1. **CLI commands** — yargs `CommandModule` classes in `src/cli/commands/` (`exec-command.ts`, `run-command.ts`, `terminal-command.ts`) -2. **Terminal dot-commands** — string-matched in `terminal-editor.ts` (lines 342-403): `.help`, `.exit`, `.run `, `.clear` - -These are completely separate implementations. Adding a new capability (state, screenshot, logs, query, sessions) would require: -- A new yargs `CommandModule` class for the CLI -- A new dot-command branch in the terminal editor -- A new MCP tool definition for AI agents -- Duplicated argument parsing, validation, error handling, and output formatting in each - -With 7+ new commands planned, this duplication is unsustainable. - -## 2. Golden Rule - -**Every action is implemented EXACTLY ONCE as a handler function. The CLI, terminal, and MCP surfaces are thin adapters that parse input and format output -- they NEVER contain business logic.** - -This is the single most important constraint in this spec. If you are writing code that calls `session.queryStateAsync()` in a CLI command file, a terminal handler, AND an MCP tool -- you are violating this rule. There is ONE handler. The three surfaces call it. - -The handler: -- Receives typed, validated input and a `CommandContext` -- Performs the operation (calls session methods, reads files, etc.) -- Returns a structured result -- Knows nothing about which surface invoked it - -The adapters: -- Parse surface-specific input (yargs argv, dot-command string, MCP JSON) into the handler's input type -- Call the handler -- Format the handler's structured output for their surface (terminal text, JSON, MCP response) -- Handle surface-specific concerns (exit codes, ANSI colors, MCP content blocks) - -### 2.1 Anti-pattern: what NOT to do - -This is what happens without the golden rule. Three files, three implementations, same logic: - -```typescript -// BAD: src/cli/commands/state-command.ts -export class StateCommand implements CommandModule { - handler = async (argv) => { - const registry = new SessionRegistry(); - const session = await resolveSessionAsync(registry, { sessionId: argv.session }); - try { - const result = await session.queryStateAsync(); // business logic HERE - if (argv.json) { - console.log(JSON.stringify(result)); - } else { - console.log(`Place: ${result.placeName}`); // formatting HERE - console.log(`Mode: ${result.state}`); - } - } catch (err) { - OutputHelper.error(err.message); // error handling HERE - process.exit(1); - } finally { - await session.disconnectAsync(); - } - }; -} - -// BAD: terminal-editor.ts (inside _handleDotCommand switch) -case '.state': { - try { - const result = await this._session.queryStateAsync(); // SAME business logic, copy-pasted - console.log(`Place: ${result.placeName}`); // SAME formatting, copy-pasted - console.log(`Mode: ${result.state}`); - } catch (err) { - console.log(`Error: ${err.message}`); // DIFFERENT error handling (bug) - } - break; -} - -// BAD: src/mcp/tools/studio-state-tool.ts -export const studioStateTool = { - handler: async (input) => { - const registry = new SessionRegistry(); - const session = await resolveSessionAsync(registry, { sessionId: input.sessionId }); - const result = await session.queryStateAsync(); // SAME business logic, third copy - return { content: [{ type: 'text', text: JSON.stringify(result) }] }; - // BUG: forgot to disconnectAsync() — only the CLI version does cleanup - }, -}; -``` - -Three copies of `queryStateAsync()` calling. Three copies of session resolution. Three different error-handling strategies. One of them has a cleanup bug. This is what happens when each surface implements the action itself. - -### 2.2 Correct pattern: what TO do - -One handler. Three thin adapters that call it. - -```typescript -// GOOD: src/commands/state.ts — THE implementation (one file, one place) -export const stateCommand: CommandDefinition> = { - name: 'state', - description: 'Query Studio session state (run mode, place info)', - requiresSession: true, - args: [], - handler: async (_input, context) => { - const result = await context.session!.queryStateAsync(); - return { - data: result, - summary: [ - `Place: ${result.placeName}`, - `PlaceId: ${result.placeId}`, - `GameId: ${result.gameId}`, - `Mode: ${result.state}`, - ].join('\n'), - }; - }, -}; - -// GOOD: CLI — thin adapter (no business logic, generated from definition) -// src/cli/cli.ts: -yargs.command(createCliCommand(stateCommand)); // one line - -// GOOD: Terminal — thin adapter (no separate file, dispatched via registry) -// terminal-mode.ts: -const dotHandler = createDotCommandHandler([stateCommand, /* ... */]); - -// GOOD: MCP — thin adapter (no business logic, generated from definition) -// src/mcp/mcp-server.ts: -mcpServer.addTool(createMcpTool(stateCommand, connection)); // one line -``` - -The `queryStateAsync()` call appears in exactly ONE place: the handler in `src/commands/state.ts`. If the state query needs a timeout, a retry, or a new field -- you change one file. - -## 3. Architectural Enforcement: File Structure as Registry - -The golden rule (section 2) says every action is implemented once. This section describes how the file structure makes that rule **unbreakable**. You cannot accidentally create a command outside the pattern because the architecture rejects it structurally. - -### 3.1 The `src/commands/` directory IS the command registry - -Every `.ts` file in `src/commands/` (except `types.ts`, `session-resolver.ts`, `index.ts`) defines exactly one `CommandDefinition`. No exceptions. No command logic exists outside this directory. If a command handler is not in `src/commands/`, it does not exist. - -### 3.2 The `src/commands/index.ts` barrel file IS the registration mechanism - -```typescript -// src/commands/index.ts — THE command registry -// Every command is imported and re-exported here. -// This is the single source of truth for all available commands. - -export { sessionsCommand } from './sessions.js'; -export { stateCommand } from './state.js'; -export { screenshotCommand } from './screenshot.js'; -export { logsCommand } from './logs.js'; -export { queryCommand } from './query.js'; -export { execCommand } from './exec.js'; -export { runCommand } from './run.js'; -export { connectCommand } from './connect.js'; -export { disconnectCommand } from './disconnect.js'; -export { launchCommand } from './launch.js'; -export { installPluginCommand } from './install-plugin.js'; -export { serveCommand } from './serve.js'; - -// This array is used by CLI, terminal, and MCP to register all commands. -// Adding a command = adding one line here + one file in this directory. -// -// Notes on special commands: -// - serveCommand: requiresSession=false because it IS the bridge host. mcpEnabled=false. -// - installPluginCommand: requiresSession=false, local setup only. mcpEnabled=false. -// - mcpCommand: requiresSession=false, starts the MCP server. mcpEnabled=false. -// - connectCommand/disconnectCommand: terminal session management. mcpEnabled=false. -// - launchCommand: explicitly launches Studio. mcpEnabled=false (agents discover sessions). -// -// The MCP adapter filters: allCommands.filter(c => c.mcpEnabled !== false) -// Only sessions, state, screenshot, logs, query, exec, and run are MCP-eligible. -export const allCommands: CommandDefinition[] = [ - sessionsCommand, - stateCommand, - screenshotCommand, - logsCommand, - queryCommand, - execCommand, - runCommand, - connectCommand, - disconnectCommand, - launchCommand, - installPluginCommand, - serveCommand, -]; -``` - -### 3.3 All surfaces register from `allCommands` - -Every surface -- CLI, terminal, MCP -- registers commands from the same `allCommands` array. No surface imports individual command files. No surface maintains its own list. - -The three thin adapters, one for each surface: - -```typescript -// Three thin adapters, one shared handler -createCliCommand(cmd: CommandDefinition): YargsCommand -createDotCommand(cmd: CommandDefinition): DotCommand -createMcpTool(cmd: CommandDefinition): McpTool -``` - -Registration: - -```typescript -// src/cli/cli.ts — ALL commands registered in one loop -import { allCommands } from '../commands/index.js'; -import { createCliCommand } from './adapters/cli-adapter.js'; - -for (const cmd of allCommands) { - yargs.command(createCliCommand(cmd)); -} - -// src/cli/commands/terminal/terminal-mode.ts — same source, same loop -import { allCommands } from '../../../commands/index.js'; -import { createDotCommandHandler } from '../../adapters/terminal-adapter.js'; - -const dotHandler = createDotCommandHandler(allCommands); - -// src/mcp/mcp-server.ts — same source, filtered by mcpEnabled -import { allCommands } from '../commands/index.js'; -import { createMcpTool } from './adapters/mcp-adapter.js'; - -for (const cmd of allCommands.filter(c => c.mcpEnabled !== false)) { - mcpServer.addTool(createMcpTool(cmd, connection)); -} -``` - -The CLI registers ALL commands (including `serve`, `install-plugin`, `mcp`). The terminal adapter receives ALL commands but some are filtered by the adapter itself (e.g., `serve` is not meaningful as a dot-command). The MCP adapter only registers commands where `mcpEnabled` is not `false` -- commands like `serve`, `install-plugin`, `mcp`, `connect`, `disconnect`, and `launch` are excluded because they are process-level or interactive-only actions that do not make sense as MCP tools. - -### 3.4 Why this works - -- **Adding a new command** = create one file in `src/commands/`, add one line to `index.ts`. That is it. All three surfaces pick it up automatically. -- **Forgetting to register?** The command does not appear in `allCommands`. It is not registered anywhere. Easy to catch in review -- a command file that is not in `index.ts` is dead code. -- **Putting command logic in `src/cli/`?** It will not be in `allCommands`. It cannot be registered through the standard path. The architecture rejects it. -- **The `allCommands` array is the definitive list.** CLI, terminal, and MCP all use the same list. There is no way for the surfaces to disagree about which commands exist. -- **Parallel execution safety.** Seven tasks (1.7b, 2.4, 2.6, 3.1, 3.2, 3.3, 3.4) all add commands. Because each task only appends an export line to `index.ts` (and creates its own handler file), parallel worktrees produce auto-mergeable changes. Without this pattern, all seven tasks would modify `cli.ts` at the same yargs chain, causing merge conflicts. See `../execution/TODO.md` ("Merge Conflict Mitigation") for the full rationale. - -### 3.5 What is NOT in `src/commands/` - -Not everything belongs in the commands directory. The following are explicitly excluded: - -- **Adapters** (`src/cli/adapters/`, `src/mcp/adapters/`) -- these translate between surfaces and handlers. They are generic functions that operate on any `CommandDefinition`, not specific command logic. -- **Surface-specific entry points** (`src/cli/cli.ts`, `src/mcp/mcp-server.ts`) -- these call the adapters with `allCommands`. They are wiring, not logic. -- **Editor intrinsics** (`.help`, `.exit`, `.clear`) -- these are terminal-editor concerns that control the editor itself, not Studio. They do not go through the command system. - -## 4. Where Business Logic Lives - -Every concern has exactly one home. If you find yourself writing the same logic in two places, something is wrong. - -| Concern | Where it lives | NOT where it lives | -|---------|---------------|-------------------| -| Calling session methods (`queryStateAsync`, `execAsync`, etc.) | Handler (`src/commands/*.ts`) | CLI adapter, terminal adapter, MCP adapter | -| Argument validation (required fields, value ranges) | Handler (throws typed errors) | Adapters (they parse, not validate) | -| Session resolution | Shared utility (`resolveSessionAsync`) | Each command individually | -| Session cleanup (disconnect/stop) | Adapters (via `CommandContext.session` ownership) | Handler (it does not know about lifecycle) | -| Error handling (catch + format) | Adapters catch handler errors and format for their surface | Handler throws, does not catch-and-format | -| Output formatting (ANSI, JSON, MCP content blocks) | Adapters | Handler (returns structured `CommandResult`) | -| Human-readable summary text | Handler (returns `summary` string) | Adapters (they print it, they don't compose it) | -| Timeout enforcement | Handler (part of the operation) | Adapters | -| Exit codes, `process.exit()` | CLI adapter only | Handler, terminal adapter, MCP adapter | -| ANSI color codes | Terminal/CLI adapter formatting | Handler | - -The key insight: the handler returns a `CommandResult` with both structured `data` and a human-readable `summary`. The CLI adapter prints `summary` (or `JSON.stringify(data)` with `--json`). The terminal adapter prints `summary`. The MCP adapter returns `data` as JSON. No adapter needs to understand the business logic to format output. - -## 5. Command Handler Interface - -```typescript -// src/commands/types.ts - -import type { TableColumn } from '@quenty/cli-output-helpers/output-modes'; - -/** - * Optional output formatting configuration for a command. - * Used by the CLI adapter to select table/JSON/watch output modes. - * The MCP adapter ignores this entirely (it always returns raw data). - */ -export interface CommandOutputConfig { - /** Table columns for table output mode. If not provided, CLI falls back to summary text. */ - table?: TableColumn[]; - /** - * Whether this command supports --watch mode (continuously updating output). - * Watch/follow modes use the WebSocket push subscription protocol: the handler - * sends `subscribe { events: [...] }` to the plugin, and the plugin pushes - * updates (`stateChange`, `logPush`) through the bridge host to subscribed - * clients. See `01-protocol.md` section 5.2 and `07-bridge-network.md` - * section 5.3 for the subscription routing mechanism. - */ - supportsWatch?: boolean; - /** Custom watch render function (if different from re-running the handler) */ - watchRender?: (data: T) => string; -} - -/** - * A command handler that works across CLI, terminal, and MCP surfaces. - * TInput: the parsed arguments. TOutput: the structured result. - */ -export interface CommandDefinition { - /** Machine-readable name, matches CLI command and dot-command (e.g., 'state', 'screenshot') */ - name: string; - - /** Human-readable description for help text */ - description: string; - - /** Whether this command requires an active session (most do, `sessions` and `install-plugin` don't) */ - requiresSession: boolean; - - /** Argument specification for all surfaces */ - args: ArgSpec[]; - - /** The handler. Receives parsed input and a session (if requiresSession is true). */ - handler: (input: TInput, context: CommandContext) => Promise; - - /** - * Optional output formatting configuration. - * Tells the CLI adapter how to render the handler's result in table, JSON, or watch mode. - * See `output-modes-plan.md` for the full output modes design. - */ - output?: CommandOutputConfig ? D : unknown>; - - // -- MCP surface configuration -- - - /** - * Whether this command is exposed as an MCP tool. Default: true. - * Set to false for commands that don't make sense as MCP tools: - * - `serve` (process-level, starts a bridge host) - * - `install-plugin` (local setup, requires user action) - * - `mcp` (the MCP server itself) - * - `connect` / `disconnect` (terminal session management) - * - `launch` (explicitly launches Studio; agents should discover existing sessions) - */ - mcpEnabled?: boolean; - - /** Override the MCP tool name. Default: `studio_${name}`. */ - mcpName?: string; - - /** Override the description for MCP context (may need different phrasing for AI agents). */ - mcpDescription?: string; -} - -export interface ArgSpec { - name: string; - description: string; - type: 'string' | 'number' | 'boolean'; - required: boolean; - positional?: boolean; - alias?: string; - default?: unknown; -} - -type SessionContext = 'edit' | 'client' | 'server'; - -export interface CommandContext { - /** The connected session, or undefined if requiresSession is false. - * This is a BridgeSession from the bridge network module (src/bridge/). */ - session?: BridgeSession; - /** The bridge connection, always available */ - connection: BridgeConnection; - /** Whether the caller is interactive (terminal) or non-interactive (CLI pipe, MCP) */ - interactive: boolean; - /** The resolved session context, if applicable. Set by session resolution when --context is used or auto-detected. */ - context?: SessionContext; -} -``` - -### 5.1 Result formatting - -Handlers return structured objects. Each surface formats them differently: - -```typescript -export interface CommandResult { - /** Structured data for programmatic consumers (MCP, --json) */ - data: T; - /** Human-readable summary for CLI/terminal output */ - summary: string; -} -``` - -- **CLI**: prints `summary` by default, `JSON.stringify(data)` with `--json` -- **Terminal**: prints `summary` inline -- **MCP**: returns `data` as the tool response JSON - -## 6. Complete Example: The `state` Command End-to-End - -This is the full implementation of the `state` command across all four files. This is not pseudocode -- this is what the actual TypeScript will look like. - -### 6.1 The handler (`src/commands/state.ts`) - -This is THE implementation. All business logic for querying Studio state lives here and nowhere else. - -```typescript -// src/commands/state.ts - -import type { CommandDefinition, CommandResult, CommandContext } from './types.js'; - -// -- Input and output types -------------------------------------------------- - -export type StudioState = 'Edit' | 'Play' | 'Paused' | 'Run' | 'Server' | 'Client'; - -export interface StateInput { - // state takes no arguments beyond session (handled by the framework) -} - -export interface StateOutput { - state: StudioState; - placeName: string; - placeId: number; - gameId: number; -} - -// -- Handler ----------------------------------------------------------------- - -export const stateCommand: CommandDefinition> = { - name: 'state', - description: 'Query Studio session state (run mode, place info)', - requiresSession: true, - args: [], - - handler: async (_input: StateInput, context: CommandContext): Promise> => { - const result = await context.session!.queryStateAsync(); - - return { - data: { - state: result.state, - placeName: result.placeName, - placeId: result.placeId, - gameId: result.gameId, - }, - summary: [ - `Place: ${result.placeName}`, - `PlaceId: ${result.placeId}`, - `GameId: ${result.gameId}`, - `Mode: ${result.state}`, - ].join('\n'), - }; - }, -}; -``` - -That is the entire implementation. 40 lines. Everything else is adapter wiring. - -### 6.2 CLI adapter wiring (`src/cli/cli.ts`) - -No separate `state-command.ts` file. The CLI registers ALL commands from `allCommands` in a single loop: - -```typescript -// src/cli/cli.ts (updated excerpt) -import { allCommands } from '../commands/index.js'; -import { createCliCommand } from './adapters/cli-adapter.js'; - -const cli = yargs(hideBin(process.argv)) - .scriptName('studio-bridge'); - // ... global options ... - -for (const command of allCommands) { - cli.command(createCliCommand(command)); -} - -// Legacy commands kept as-is during migration -cli.command(new TerminalCommand() as any); -``` - -`createCliCommand` is the generic adapter that generates a yargs `CommandModule` from any `CommandDefinition`. It handles session resolution, error formatting, cleanup, and `--json` output. See section 8 for its implementation. - -Running `studio-bridge state` invokes: -1. yargs parses args (the generic adapter's `builder`) -2. The generic adapter's `handler` calls `resolveSessionAsync` to get a session -3. The generic adapter's `handler` calls `stateCommand.handler(argv, context)` -- the ONE handler -4. The generic adapter's `handler` prints `result.summary` (or `JSON.stringify(result.data)` with `--json`) - -### 6.3 Terminal adapter wiring (`terminal-mode.ts`) - -No separate file for terminal dot-commands. The terminal mode registers ALL commands from `allCommands` into a dispatcher: - -```typescript -// In terminal-mode.ts (updated excerpt) -import { allCommands } from '../../../commands/index.js'; -import { createDotCommandHandler } from '../../adapters/terminal-adapter.js'; - -const dotHandler = createDotCommandHandler(allCommands); -``` - -When the user types `.state` in the terminal, the flow is: -1. `terminal-editor.ts` detects the `.` prefix and delegates to `dotHandler` -2. `dotHandler` looks up `stateCommand` by name -3. `dotHandler` calls `stateCommand.handler({}, context)` -- the ONE handler -4. `dotHandler` prints `result.summary` - -### 6.4 MCP adapter wiring (`src/mcp/mcp-server.ts`) - -No separate `studio-state-tool.ts` file. The MCP server registers all MCP-eligible commands from `allCommands` via the generic adapter: - -```typescript -// src/mcp/mcp-server.ts (excerpt) -import { allCommands } from '../commands/index.js'; -import { createMcpTool } from './adapters/mcp-adapter.js'; - -for (const cmd of allCommands.filter(c => c.mcpEnabled !== false)) { - mcpServer.addTool(createMcpTool(cmd, connection)); -} -``` - -When an MCP client calls `studio_state`, the flow is: -1. The MCP server dispatches to the generated tool handler -2. The generated handler calls `resolveSessionAsync` to get a session -3. The generated handler calls `stateCommand.handler({}, context)` -- the ONE handler -4. The generated handler returns `{ content: [{ type: 'text', text: JSON.stringify(result.data) }] }` - -Full MCP server design: `06-mcp-server.md`. - -### 6.5 File layout for the `state` command - -``` -src/ - commands/ - index.ts ← allCommands array includes stateCommand - state.ts ← THE implementation (handler + types) - cli/ - cli.ts ← loops over allCommands (no per-command lines) - (no state-command.ts) - cli/commands/terminal/ - terminal-mode.ts ← passes allCommands to createDotCommandHandler - (no separate state handler) - mcp/ - mcp-server.ts ← loops over allCommands.filter(c => c.mcpEnabled !== false) - (no studio-state-tool.ts) -``` - -One file contains the logic. One line in `index.ts` registers it. The three surface files never change when commands are added -- they all loop over `allCommands`. - -## 7. Session Resolution - -Session resolution is a shared utility, not duplicated per command. It is **instance-aware**: a single Studio instance produces 1-3 sessions that share an `instanceId`, differing by `context` (`'edit'`, `'client'`, `'server'`). - -```typescript -// src/commands/session-resolver.ts - -type SessionContext = 'edit' | 'client' | 'server'; - -export interface ResolvedSession { - session: BridgeSession; - source: 'explicit' | 'auto-selected' | 'launched'; - context: SessionContext; -} - -/** - * Resolves a session for command execution using instance-aware heuristics. - * - * 1. If sessionId is provided → find by ID in registry (error if not found) - * 2. If no sessionId → group sessions by instanceId: - * a. 0 instances → launch new Studio (for exec/run) or error (for other commands) - * b. 1 instance, --context provided → select matching context within instance - * c. 1 instance, Edit mode, no --context → auto-select Edit session - * d. 1 instance, Play mode, no --context → default to Edit context - * e. N instances → error with grouped list (CLI) or prompt (interactive) - */ -export async function resolveSessionAsync( - connection: BridgeConnection, - options: { - sessionId?: string; - instanceId?: string; - context?: SessionContext; - interactive: boolean; - placePath?: string; - timeoutMs?: number; - } -): Promise; -``` - -The `--session` / `-s`, `--instance`, and `--context` global options feed into `resolveSessionAsync`. All commands that require a session use this same function. The adapters call it -- the handler never calls it directly (it receives the session via `CommandContext`). - -- `--session ` / `-s `: target a specific session by session ID. -- `--instance `: target a specific Studio instance by instance ID. When multiple instances are connected, this selects the instance without requiring a full session ID. Context selection (step 5a-5c in the algorithm) still applies within the selected instance. -- `--context edit|client|server`: select which VM context to target within the resolved instance. - -### 7.1 Auto-selection behavior (instance-aware) - -Sessions are grouped by `instanceId` before applying the heuristic: - -| Instances | `--session` flag | `--instance` flag | `--context` flag | Behavior | -|-----------|-----------------|-------------------|-----------------|----------| -| 0 | not set | not set | any | Launch new Studio (preserves current exec/run behavior) or error | -| 0 | set | any | any | Error: "Session not found: {id}" | -| 1 (Edit mode) | not set | not set | not set | Auto-select the Edit session | -| 1 (Edit mode) | not set | not set | `edit` | Select Edit session | -| 1 (Edit mode) | not set | not set | `server`/`client` | Error: "No server/client context. Studio is in Edit mode." | -| 1 (Play mode) | not set | not set | not set | Default to Edit context (safe default) | -| 1 (Play mode) | not set | not set | `server` | Select Server session | -| 1 (Play mode) | not set | not set | `client` | Select Client session | -| 1 | set | any | any | Use specified session directly | -| N > 1 | not set | not set | any | Error: "Multiple Studio instances connected. Use --session or --instance to specify." + grouped list | -| N > 1 | not set | set | any | Select that instance, apply context selection | -| N > 1 | not set, interactive | not set | any | Prompt user to choose instance, then apply context | -| N > 1 | set | any | any | Use specified session directly | - -### 7.2 Connect vs. launch semantics - -When session resolution selects an existing session, the command **connects** to it (no Studio launch, no plugin injection, no cleanup on exit). The session has origin `'user'`. When session resolution launches a new Studio, the command **owns** it (cleanup on exit, kill Studio on stop, remove temp plugin). The session has origin `'managed'`. - -This distinction is tracked on the `BridgeSession` (from `src/bridge/index.ts` -- see `07-bridge-network.md` for the full interface): - -```typescript -export type SessionOrigin = 'user' | 'managed'; - -export interface BridgeSession { - /** Read-only metadata about this session. */ - readonly info: SessionInfo; - /** Which Studio VM this session represents (edit, client, or server). */ - readonly context: SessionContext; - /** Whether the session's plugin is still connected. */ - readonly isConnected: boolean; - /** How this session was created: 'user' (manually opened) or 'managed' (launched by studio-bridge) */ - readonly origin: SessionOrigin; - - execAsync(code: string, timeout?: number): Promise; - queryStateAsync(): Promise; - captureScreenshotAsync(): Promise; - queryLogsAsync(options?: LogOptions): Promise; - queryDataModelAsync(options: QueryDataModelOptions): Promise; - subscribeAsync(events: SubscribableEvent[]): Promise; - unsubscribeAsync(events: SubscribableEvent[]): Promise; - /** Closes the connection without killing Studio (safe for any session) */ - disconnectAsync(): Promise; - /** Sends shutdown, kills Studio if origin is 'managed', cleans up resources */ - stopAsync(): Promise; -} -``` - -- `disconnectAsync()` — closes the connection without killing Studio (safe for any session) -- `stopAsync()` — sends shutdown, kills Studio if origin is `'managed'`, cleans up resources - -## 8. CLI Adapter - -Each command definition generates a yargs `CommandModule`. The adapter uses output mode utilities from `@quenty/cli-output-helpers/output-modes` to select between table, JSON, and text formatting. This is the full implementation of the adapter: - -```typescript -// src/cli/adapters/cli-adapter.ts - -import type { CommandModule, Argv } from 'yargs'; -import type { CommandDefinition, CommandContext, CommandResult } from '../../commands/types.js'; -import type { StudioBridgeGlobalArgs } from '../args/global-args.js'; -import { resolveSessionAsync } from '../../commands/session-resolver.js'; -import { BridgeConnection } from '../../bridge/index.js'; -import { OutputHelper } from '@quenty/cli-output-helpers'; -import { formatTable, formatJson, resolveOutputMode, createWatchRenderer } from '@quenty/cli-output-helpers/output-modes'; - -export function createCliCommand( - definition: CommandDefinition -): CommandModule { - return { - command: buildYargsCommand(definition), // e.g., 'state [session-id]' - describe: definition.description, - builder: (yargs) => { - for (const arg of definition.args) { - if (arg.positional) { - yargs.positional(arg.name, { describe: arg.description, type: arg.type }); - } else { - yargs.option(arg.name, { - describe: arg.description, - type: arg.type, - alias: arg.alias, - default: arg.default, - }); - } - } - return yargs; - }, - handler: async (argv) => { - const connection = await BridgeConnection.connectAsync(); - const context: CommandContext = { connection, interactive: !!process.stdout.isTTY }; - - if (definition.requiresSession) { - const resolved = await resolveSessionAsync(connection, { - sessionId: argv.session, - instanceId: argv.instance, // --instance - context: argv.context, // --context edit|client|server - interactive: context.interactive, - placePath: argv.place, - timeoutMs: argv.timeout, - }); - context.session = resolved.session; - context.context = resolved.context; - } - - try { - const result = await definition.handler(argv as TInput, context); - const commandResult = result as CommandResult; - - // Output mode selection uses @quenty/cli-output-helpers/output-modes. - // The CLI adapter is the ONLY place that decides how to format output. - const mode = resolveOutputMode({ json: argv.json, isTTY: !!process.stdout.isTTY }); - - if (mode === 'json') { - console.log(formatJson(commandResult.data)); - } else if (mode === 'table' && definition.output?.table) { - const rows = Array.isArray(commandResult.data) ? commandResult.data : [commandResult.data]; - console.log(formatTable(rows, definition.output.table as any)); - } else { - console.log(commandResult.summary); - } - } catch (err) { - // Adapters catch and format errors — the handler throws, it does not - // call OutputHelper or process.exit. - OutputHelper.error(err instanceof Error ? err.message : String(err)); - process.exit(1); - } finally { - if (context.session?.origin === 'managed') { - await context.session.stopAsync(); - } else if (context.session) { - await context.session.disconnectAsync(); - } - } - }, - }; -} -``` - -### 8.1 Registration in cli.ts - -```typescript -// src/cli/cli.ts (updated) -import { allCommands } from '../commands/index.js'; -import { createCliCommand } from './adapters/cli-adapter.js'; - -for (const command of allCommands) { - yargs.command(createCliCommand(command)); -} - -// Legacy commands (exec, run, terminal) can be migrated incrementally -yargs.command(new TerminalCommand()); // kept as-is initially -``` - -## 9. Terminal Adapter and terminal-mode.ts Changes - -### 9.1 The adapter - -Terminal dot-commands are generated from the same definitions: - -```typescript -// src/cli/adapters/terminal-adapter.ts - -import type { CommandDefinition, CommandContext, CommandResult } from '../../commands/types.js'; -import type { BridgeSession } from '../../bridge/index.js'; -import type { BridgeConnection } from '../../bridge/index.js'; - -/** - * Creates a dispatcher that handles ALL dot-commands from a registry of - * command definitions. Adding a new command = adding it to the definitions - * array. No other code changes needed. - */ -export function createDotCommandHandler( - definitions: CommandDefinition[] -): (input: string, session: BridgeSession, connection: BridgeConnection) => Promise { - return async (input, session, connection) => { - const [commandName, ...rawArgs] = input.slice(1).split(/\s+/); - const definition = definitions.find(d => d.name === commandName); - if (!definition) return null; // not a recognized dot-command - - const parsedArgs = parseDotCommandArgs(definition.args, rawArgs); - const context: CommandContext = { session, connection, interactive: true }; - - try { - const result = await definition.handler(parsedArgs, context); - return (result as CommandResult).summary; - } catch (err) { - return `Error: ${err instanceof Error ? err.message : String(err)}`; - } - }; -} -``` - -### 9.2 How terminal-editor.ts changes - -The existing hard-coded dot-command switch in `terminal-editor.ts` (lines 342-403) is simplified. Only the commands that are intrinsic to the editor itself (`.help`, `.exit`, `.clear`) stay hard-coded. Everything else is dispatched to the adapter: - -```typescript -// In terminal-editor.ts, the _handleDotCommand method becomes: - -private _handleDotCommand(text: string): void { - const parts = text.split(/\s+/); - const cmd = parts[0].toLowerCase(); - - switch (cmd) { - // Editor-intrinsic commands stay here — they control the editor itself, - // not Studio. They don't go through the command system. - case '.help': - this._clearEditor(); - console.log(this._generateHelpText()); - this._render(); - break; - - case '.exit': - this._clearEditor(); - this.emit('exit'); - break; - - case '.clear': - this._lines = ['']; - this._cursorRow = 0; - this._cursorCol = 0; - this._render(); - break; - - default: - // All other dot-commands are dispatched to the adapter. - // This is where .state, .screenshot, .logs, .sessions, etc. go. - this._clearEditor(); - this.emit('dotcommand', text); - break; - } -} -``` - -### 9.3 How terminal-mode.ts changes - -`terminal-mode.ts` currently has no dot-command awareness -- it only handles `submit` (execute Luau code) and `exit`. With the adapter, it gains a `dotcommand` event handler that dispatches to the registry: - -```typescript -// terminal-mode.ts (updated) -import { allCommands } from '../../../commands/index.js'; -import { createDotCommandHandler } from '../../adapters/terminal-adapter.js'; - -// Build the dot-command dispatcher from the same allCommands used by CLI and MCP. -// Adding a new dot-command = adding one entry to allCommands in src/commands/index.ts. -// No switch statement to update, no new file to create, no change to this file. -const dotHandler = createDotCommandHandler(allCommands); - -// In runTerminalMode, after setting up the editor: -editor.on('dotcommand', async (buffer: string) => { - const output = await dotHandler(buffer, currentSession, connection); - if (output !== null) { - console.log(output); - } else { - console.log(`Unknown command: ${buffer}. Type .help for available commands.`); - } - console.log(''); - editor._render(); -}); -``` - -The `.help` output is auto-generated from the definitions list plus the hard-coded editor commands: - -```typescript -function generateHelpText(definitions: CommandDefinition[]): string { - const commandLines = definitions.map(d => ` .${d.name.padEnd(20)} ${d.description}`); - return [ - '', - 'Commands:', - ' .help Show this help message', - ' .exit Exit terminal mode', - ' .clear Clear the editor buffer', - ' .run Read and execute a Luau file', - ...commandLines, - '', - 'Keybindings:', - ' Enter New line', - ' Ctrl+Enter Execute buffer', - ' Ctrl+C Clear buffer (or exit if empty)', - ' Ctrl+D Exit', - ' Tab Insert 2 spaces', - ' Arrow keys Move cursor', - '', - ].join('\n'); -} -``` - -### 9.4 Dot-command syntax - -Terminal dot-commands use a minimal syntax: `.commandName [positional] [--flag value]`. The `parseDotCommandArgs` function in the terminal adapter handles this: - -``` -.state → { } -.state --watch → { watch: true } -.screenshot → { } -.screenshot --output /tmp/s.png → { output: '/tmp/s.png' } -.logs --tail 20 → { tail: 20 } -.logs --follow → { follow: true } -.logs --follow --level warn → { follow: true, level: 'warn' } -.query Workspace → { expression: 'Workspace' } -.query Workspace.SpawnLocation --properties Position,Anchored - → { expression: 'Workspace.SpawnLocation', properties: 'Position,Anchored' } -.sessions → { } -.run path/to/file.lua → { file: 'path/to/file.lua' } -``` - -The parser splits on whitespace, treats the first token (after `.`) as the command name, and maps remaining tokens to the command's `ArgSpec`. Positional arguments are consumed in order; `--flag` tokens are matched by name. Boolean flags (like `--watch`, `--follow`) do not consume the next token. This is intentionally simpler than yargs -- dot-commands do not need subcommands, aliases, or complex validation. If parsing fails, the adapter prints a one-line usage hint derived from the command's `ArgSpec`. - -Quoting rules: single or double quotes around a value preserve spaces (`--output "my file.png"` works). Unquoted values are split on whitespace as expected. There is no shell-style variable expansion or escaping -- this is a REPL, not a shell. - -## 10. MCP Adapter - -MCP tools are generated from the same definitions. The adapter uses `mcpName` and `mcpDescription` from the `CommandDefinition` when available, falling back to defaults. Only commands where `mcpEnabled` is not `false` are registered. Full MCP server design: `06-mcp-server.md`. - -```typescript -// src/mcp/adapters/mcp-adapter.ts - -import type { CommandDefinition, CommandContext, CommandResult } from '../../commands/types.js'; -import { resolveSessionAsync } from '../../commands/session-resolver.js'; -import type { BridgeConnection } from '../../bridge/index.js'; - -export function createMcpTool( - definition: CommandDefinition, - connection: BridgeConnection -): McpToolDefinition { - return { - name: definition.mcpName ?? `studio_${definition.name}`, - description: definition.mcpDescription ?? definition.description, - inputSchema: buildJsonSchema(definition.args), - handler: async (input: Record) => { - const context: CommandContext = { connection, interactive: false }; - - if (definition.requiresSession) { - const resolved = await resolveSessionAsync(connection, { - sessionId: input.sessionId as string | undefined, - context: input.context as SessionContext | undefined, - interactive: false, - }); - context.session = resolved.session; - context.context = resolved.context; - } - - try { - const result = await definition.handler(input as TInput, context); - return { - content: [{ - type: 'text', - text: JSON.stringify((result as CommandResult).data), - }], - }; - } catch (err) { - return { - content: [{ - type: 'text', - text: JSON.stringify({ error: err instanceof Error ? err.message : String(err) }), - }], - isError: true, - }; - } finally { - if (context.session && context.session.origin !== 'managed') { - await context.session.disconnectAsync(); - } - } - }, - }; -} -``` - -## 11. Adding a New Command: The Checklist - -To add a new command (e.g., `logs`), you touch exactly TWO files: - -1. **Create `src/commands/logs.ts`** -- define `LogsInput`, `LogsOutput`, and `logsCommand: CommandDefinition<...>` with the handler -2. **Add to `src/commands/index.ts`** -- import `logsCommand`, add it to the named exports and to the `allCommands` array - -That is it. The CLI, terminal, and MCP surfaces all loop over `allCommands`. They never change when commands are added. No other files need to be touched. - -You do NOT: -- Create a `logs-command.ts` yargs class -- Add a case to a switch statement in `terminal-editor.ts` -- Create a `studio-logs-tool.ts` MCP tool file -- Add a line to `cli.ts`, `terminal-mode.ts`, or `server.ts` -- Duplicate argument parsing, session resolution, error handling, or output formatting - -If you find yourself doing any of those things, re-read sections 2 and 3. - -## 12. File Layout - -### The `src/commands/` directory -- ALL command logic lives here - -``` -src/ - commands/ ← ALL command logic lives here - index.ts ← barrel + allCommands registry (THE single source of truth) - types.ts ← CommandDefinition, CommandContext, CommandResult, ArgSpec - session-resolver.ts ← shared session resolution utility - sessions.ts ← one file per command - state.ts - screenshot.ts - logs.ts - query.ts - exec.ts - run.ts - connect.ts - disconnect.ts - launch.ts - install-plugin.ts - serve.ts ← special: requiresSession=false, mcpEnabled=false - mcp.ts ← special: requiresSession=false, mcpEnabled=false -``` - -### Surfaces -- consumers of `allCommands`, not owners of command logic - -``` -src/ - cli/ - adapters/ - cli-adapter.ts ← createCliCommand (generic, operates on any CommandDefinition) - terminal-adapter.ts ← createDotCommandHandler (generic, operates on any CommandDefinition) - cli.ts ← registers allCommands via loop (never imports individual commands) - commands/terminal/ - terminal-mode.ts ← registers allCommands via loop (never imports individual commands) - terminal-editor.ts ← only .help/.exit/.clear (editor intrinsics, not commands) - mcp/ - adapters/ - mcp-adapter.ts ← createMcpTool (generic, operates on any CommandDefinition) - mcp-server.ts ← registers allCommands.filter(c => c.mcpEnabled !== false) via loop -``` - -### Other files - -The `BridgeSession` class is defined in the bridge network module (`src/bridge/bridge-session.ts`). -See `07-bridge-network.md` section 2.3 for the full interface definition. - -### Modified files - -| File | Change | -|------|--------| -| `src/cli/cli.ts` | Register all commands via `for (const cmd of allCommands)` loop | -| `src/cli/args/global-args.ts` | Add `session?: string`, `instance?: string`, `context?: SessionContext`, `json?: boolean`, `remote?: string`, and `local?: boolean` to `StudioBridgeGlobalArgs` | -| `src/cli/commands/terminal/terminal-editor.ts` | Emit `dotcommand` event for non-intrinsic dot-commands | -| `src/cli/commands/terminal/terminal-mode.ts` | Wire up `dotcommand` event to `createDotCommandHandler(allCommands)` | -| `src/index.ts` | Export command types (BridgeSession is exported from `src/bridge/index.ts`) | - -### What does NOT exist - -To be explicit about what this design avoids: - -- `src/cli/commands/state-command.ts` -- does not exist. No per-command CLI files for new commands. -- `src/cli/commands/logs-command.ts` -- does not exist. Same reason. -- `src/cli/commands/serve-command.ts` -- does not exist. The serve command lives in `src/commands/serve.ts` like all other commands. -- `src/server/daemon-server.ts`, `src/server/daemon-client.ts`, `src/server/daemon-protocol.ts` -- do not exist. The split server uses the same `bridge-host.ts` and `bridge-client.ts` from `src/bridge/internal/`. No separate daemon abstraction layer. -- `src/server/environment-detection.ts` -- does not exist at that path. Environment detection lives in `src/bridge/internal/environment-detection.ts` because it is part of the bridge connection logic. -- `src/mcp/tools/studio-state-tool.ts` -- does not exist. No per-command MCP files. -- `src/mcp/tools/studio-exec-tool.ts` -- does not exist. Same reason. -- `src/mcp/tools/index.ts` -- does not exist. Tools are registered in the loop in `mcp-server.ts`. -- Any dot-command logic in `terminal-editor.ts` beyond `.help`, `.exit`, `.clear` -- does not exist. -- Any individual command imports in `cli.ts`, `terminal-mode.ts`, or `mcp-server.ts` -- do not exist. These files import `allCommands` from `src/commands/index.js` and nothing else from that directory. - -### Special commands and MCP eligibility - -Several commands are excluded from the MCP surface via `mcpEnabled: false`: - -| Command | `requiresSession` | `mcpEnabled` | Reason excluded from MCP | -|---------|-------------------|-------------|-------------------------| -| `serve` | `false` | `false` | Process-level command that starts a bridge host | -| `install-plugin` | `false` | `false` | Local setup, requires user to restart Studio | -| `mcp` | `false` | `false` | IS the MCP server; cannot expose itself | -| `connect` | `true` | `false` | Enters interactive terminal mode | -| `disconnect` | `true` | `false` | Terminal session management | -| `launch` | `false` | `false` | Explicitly launches Studio; agents discover sessions instead | - -Commands without `mcpEnabled` set (or with `mcpEnabled: true`) are automatically exposed as MCP tools: `sessions`, `state`, `screenshot`, `logs`, `query`, `exec`, `run`. - -### Global `--context` flag - -All session-requiring commands (`requiresSession: true`) support the `--context edit|client|server` global flag. This flag selects which session context to target when a single Studio instance has multiple active sessions (i.e., during Play mode). The flag is passed through to `resolveSessionAsync` and is available on the MCP surface as an optional `context` parameter. - -| Command | Supports `--context` | Notes | -|---------|---------------------|-------| -| `state` | yes | Query state of a specific context | -| `screenshot` | yes | Capture viewport of a specific context | -| `logs` | yes | Read logs from a specific context | -| `query` | yes | Query DataModel in a specific context | -| `exec` | yes | Execute in Server context is common for Play mode debugging | -| `run` | yes | Same as exec | -| `connect` | yes | Connect terminal to a specific context | -| `sessions` | no | Lists all sessions/contexts | -| `serve` | no | Not session-targeting | -| `install-plugin` | no | Not session-targeting | -| `launch` | no | Not session-targeting | - -The `serve` command has `requiresSession: false` because it IS the bridge host. It does not go through session resolution. The terminal adapter and MCP adapter skip it (it is a process-level command that starts a long-running host, not a session-level action). The CLI adapter registers it normally but the adapter's session resolution branch is not entered because `requiresSession` is false. - -## 13. Concrete Example: Screenshot Command - -One more example to show the pattern scales. One handler, three surfaces: - -```typescript -// src/commands/screenshot.ts - -export interface ScreenshotInput { - output?: string; - open?: boolean; - base64?: boolean; -} - -export interface ScreenshotOutput { - filePath?: string; - base64Data?: string; - width: number; - height: number; -} - -export const screenshotCommand: CommandDefinition> = { - name: 'screenshot', - description: 'Capture a screenshot of the Studio viewport', - requiresSession: true, - args: [ - { name: 'output', alias: 'o', type: 'string', required: false, description: 'Output file path' }, - { name: 'open', type: 'boolean', required: false, default: false, description: 'Open after capture' }, - { name: 'base64', type: 'boolean', required: false, default: false, description: 'Print base64 to stdout' }, - ], - handler: async (input, context) => { - const result = await context.session!.captureScreenshotAsync(); - - if (input.base64) { - return { - data: { base64Data: result.data, width: result.width, height: result.height }, - summary: result.data, - }; - } - - const filePath = input.output ?? generateTempScreenshotPath(); - await writeFileAsync(filePath, Buffer.from(result.data, 'base64')); - - if (input.open) { - await openFileAsync(filePath); - } - - return { - data: { filePath, width: result.width, height: result.height }, - summary: `Screenshot saved to ${filePath} (${result.width}x${result.height})`, - }; - }, -}; -``` - -**CLI usage**: `studio-bridge screenshot --output ./capture.png --open` -**Terminal usage**: `.screenshot ./capture.png` -**MCP tool**: `studio_screenshot` returns `{ filePath, width, height }` or `{ base64Data, width, height }` - -## 14. Design Decision: Thin Adapters, Not a Framework - -This design does **not** propose replacing yargs or the terminal editor with a new framework. Both are well-tested and appropriate for their context. Instead, the approach is: - -- Each command has a **single handler function** with typed input and output -- **Adapters** for each surface (CLI, terminal, MCP) call the handler and format the result -- Adapters are thin -- they translate surface-specific concerns (yargs args, dot-command strings, MCP JSON) into a common input shape and the handler's output into surface-specific output - -The handler does not know which surface invoked it. - -## 15. Migration Path - -The migration is incremental -- each command is refactored independently: - -### Step 1: Infrastructure (Phase 1) -- Create `CommandDefinition`, `CommandContext`, `CommandResult` types -- Create `resolveSessionAsync` -- Create `BridgeSession` class in `src/bridge/bridge-session.ts` (see `07-bridge-network.md`) -- Create CLI and terminal adapters - -### Step 2: New commands first (Phase 2-3) -- All new commands (`sessions`, `state`, `screenshot`, `logs`, `query`, `install-plugin`) use the handler pattern from day one -- No migration needed -- they're born into the new system - -### Step 3: Existing commands (Phase 6, optional) -- `exec` and `run` can be refactored to extract their handler logic into `src/commands/exec.ts` and `src/commands/run.ts` -- The existing `ExecCommand` and `RunCommand` yargs classes become thin wrappers or are replaced by `createCliCommand` -- `terminal` is special (it's a mode, not a command) -- it stays as a yargs CommandModule but uses the adapter for dot-commands - -This is deliberately not a big-bang rewrite. The existing commands continue to work through their current code paths until explicitly migrated. - -## 16. Dependency: `@quenty/cli-output-helpers` Output Modes - -The CLI adapter depends on output mode utilities from `@quenty/cli-output-helpers/output-modes` for table formatting, JSON output, watch/follow mode, and output mode selection. These utilities are added to the existing `@quenty/cli-output-helpers` package (which studio-bridge already depends on) -- no new package is needed. - -The output modes provide: - -| Utility | Purpose | Used by | -|---------|---------|---------| -| `formatTable(rows, columns)` | Render an array of objects as an aligned terminal table | CLI adapter (table mode), handlers (for `summary` text) | -| `formatJson(data)` | Render structured data as JSON (pretty for TTY, compact for pipe) | CLI adapter (`--json` flag) | -| `createWatchRenderer(render)` | Live-updating terminal output with TTY rewrite / non-TTY append | CLI adapter (`--watch` / `--follow` flags) | -| `resolveOutputMode(options)` | Select `'table'` / `'json'` / `'text'` based on flags and environment | CLI adapter | - -The MCP adapter does NOT use any output mode utilities. It always returns raw structured data as JSON. - -The terminal adapter does NOT use output mode utilities directly. It prints the handler's `summary` string, which the handler may compose using `formatTable` internally. - -Full design: `../execution/output-modes-plan.md` diff --git a/studio-bridge/plans/tech-specs/03-persistent-plugin.md b/studio-bridge/plans/tech-specs/03-persistent-plugin.md deleted file mode 100644 index e87a442dbc..0000000000 --- a/studio-bridge/plans/tech-specs/03-persistent-plugin.md +++ /dev/null @@ -1,1818 +0,0 @@ -# Unified Plugin: Technical Specification - -This document describes the Luau architecture, boot mode detection, discovery protocol, reconnection logic, and installation flow for the studio-bridge unified plugin. It is the companion document referenced from `00-overview.md` section 5 and section 2.1. For protocol message definitions and TypeScript types, see `01-protocol.md`. - -## 1. Overview - -The studio-bridge plugin is a single Luau source that boots in one of two modes depending on whether build-time constants are present: - -- **Ephemeral mode**: The plugin is built with `IS_EPHEMERAL = true`, a numeric `PORT`, and a UUID `SESSION_ID`. Build constants are injected via a two-step pipeline: Handlebars template substitution (in TemplateHelper) replaces placeholders like `{{IS_EPHEMERAL}}`, `{{PORT}}`, and `{{SESSION_ID}}` in the Lua source, then Rojo builds the substituted sources into an `.rbxm` plugin file. It connects directly to the known server at the hardcoded port -- no discovery, no polling. This is used in CI environments and as a fallback when the persistent plugin is not installed. The plugin is injected per session by `StudioBridgeServer.startAsync()` and deleted on `stopAsync()`. -- **Persistent mode**: The plugin is built with `IS_EPHEMERAL = false`, `PORT = nil`, and `SESSION_ID = nil` (the same two-step pipeline runs, but with these default values). It is installed once to the user's local plugins folder via `studio-bridge install-plugin`. At startup, it checks `IS_EPHEMERAL` and enters the discovery loop: polls HTTP health endpoints on candidate ports, connects via WebSocket, and maintains that connection across server restarts through automatic reconnection. - -Both modes share the same action handlers, protocol logic, serialization, and log buffering. The plugin supports the full v2 protocol (execute, state queries, screenshots, DataModel inspection, log retrieval) and degrades gracefully to v1 when connected to an older server. Having one source eliminates code drift between the two modes and ensures that bug fixes and new capabilities apply everywhere. - -### 1.1 Multi-context plugin instances - -When Studio enters Play mode, Roblox creates 2 new separate plugin instances in addition to the already-running edit instance. Each runs in its own Luau execution environment with its own DataModel: - -- **Edit context**: The plugin instance attached to the edit DataModel. Always present while Studio is open. **It continues running unchanged during Play mode** -- it is never stopped or restarted by Play mode transitions. -- **Server context**: A new plugin instance created in the Play-mode server DataModel. Appears when Play starts, destroyed when Play stops. -- **Client context**: A new plugin instance created in the Play-mode client DataModel. Appears when Play starts, destroyed when Play stops. - -The edit instance is already connected to the bridge host before Play mode starts. The 2 new instances (server and client) independently detect their context (see section 4.4), open their own WebSocket connections to the bridge host, and send their own `register` messages. The bridge host sees the 2 new sessions join the existing edit session, all sharing the same `instanceId` but with different `context` values. No coordination between instances is needed -- this is natural behavior that falls out of how Roblox Studio creates plugin instances. - -The `instanceId` is stored in `PluginSettings` (per-installation), so all 3 instances within the same Studio installation share the same `instanceId`. This allows the bridge host to correlate contexts that belong to the same Studio. The `context` field in the `register` message distinguishes them. - -``` -Studio Installation A - ├── Edit plugin instance ──── ws://localhost:38741/plugin ──→ session "abc" (context=edit) - ├── Server plugin instance ── ws://localhost:38741/plugin ──→ session "def" (context=server) - └── Client plugin instance ── ws://localhost:38741/plugin ──→ session "ghi" (context=client) - -Studio Installation B - ├── Edit plugin instance ──── ws://localhost:38741/plugin ──→ session "jkl" (context=edit) - ├── Server plugin instance ── ws://localhost:38741/plugin ──→ session "mno" (context=server) - └── Client plugin instance ── ws://localhost:38741/plugin ──→ session "pqr" (context=client) -``` - -All 6 sessions connect to the same bridge host. The bridge host distinguishes them by session ID and can group them by `instanceId` and `context`. - -## 2. Plugin Management System (Universal) - -The plugin build, discovery, and installation system is a **general-purpose utility, not a feature specific to studio-bridge**. studio-bridge is its first consumer, but the system is designed so that future persistent plugins (e.g., a Rojo integration plugin, a testing plugin, a remote debugging plugin) can use the same infrastructure without modifying the manager itself. - -The key insight: plugin management (build from template, discover the Studio plugins folder, install, track versions, uninstall) is a reusable operation that any Nevermore tool might need. By making the API generic from the start, we avoid the common pattern of building a one-off installer and then painfully generalizing it later. - -### 2.0 PluginTemplate interface - -Every installable plugin is described by a `PluginTemplate`. This is the registration contract: a template declares its name, where its source lives, what build constants to substitute, and a human-readable description. The plugin manager operates entirely on `PluginTemplate` values -- it never hard-codes paths or names for any specific plugin. - -```typescript -/** - * Describes a plugin that can be built and installed into Roblox Studio. - * - * Each tool that ships a persistent plugin creates one of these and - * registers it with the PluginManager. The manager handles all - * build/install/uninstall operations generically. - */ -export interface PluginTemplate { - /** Unique identifier for this plugin, e.g., "studio-bridge" */ - name: string; - - /** Absolute path to the template source directory (contains default.project.json) */ - templateDir: string; - - /** - * Build constants substituted by Handlebars (via TemplateHelper) before Rojo builds the .rbxm. - * For persistent mode these are typically the "unsubstituted" defaults. - * For ephemeral mode, the caller overrides specific keys. - */ - buildConstants: Record; - - /** Human-readable description shown in CLI output */ - description: string; - - /** Output filename for the built .rbxm (e.g., "StudioBridgePlugin.rbxm") */ - outputFileName: string; - - /** - * Optional version string embedded in the plugin source. - * Used for upgrade detection without rebuilding. - */ - version?: string; -} -``` - -### 2.0.1 Plugin registry - -Plugin templates are registered, not hard-coded. Each tool that ships a persistent plugin registers its template with the plugin manager. studio-bridge registers its own: - -```typescript -import { resolveTemplatePath } from '@quenty/nevermore-template-helpers'; - -// studio-bridge registers its plugin template -const studioBridgePlugin: PluginTemplate = { - name: 'studio-bridge', - templateDir: resolveTemplatePath(import.meta.url, 'studio-bridge-plugin'), - buildConstants: { PORT: '{{PORT}}', SESSION_ID: '{{SESSION_ID}}' }, - description: 'Studio-bridge persistent connection plugin', - outputFileName: 'StudioBridgePlugin.rbxm', - version: '1.0.0', -}; - -// Future plugins would register their own templates: -// -// const rojoPlugin: PluginTemplate = { -// name: 'rojo-sync', -// templateDir: resolveTemplatePath(import.meta.url, 'rojo-sync-plugin'), -// buildConstants: { SYNC_MODE: 'automatic' }, -// description: 'Rojo live-sync integration for Studio', -// outputFileName: 'RojoSyncPlugin.rbxm', -// version: '0.1.0', -// }; -// -// const debugPlugin: PluginTemplate = { -// name: 'remote-debug', -// templateDir: resolveTemplatePath(import.meta.url, 'remote-debug-plugin'), -// buildConstants: { DEBUG_PORT: '9229' }, -// description: 'Remote debugging support for Studio', -// outputFileName: 'RemoteDebugPlugin.rbxm', -// version: '0.1.0', -// }; -``` - -The registry is a simple array or map of `PluginTemplate` values. The plugin manager iterates over registered templates when listing installed plugins, and individual commands reference templates by name. - -### 2.0.2 PluginManager API - -The `PluginManager` class provides all build, discover, install, and uninstall operations. It is parameterized by `PluginTemplate` -- it never assumes which plugin it is operating on. All methods accept a template (or plugin name to look up in the registry) and operate generically. - -```typescript -export interface InstalledPlugin { - /** The template name this was installed from */ - name: string; - /** Absolute path to the installed .rbxm file */ - pluginPath: string; - /** Version from the version tracking sidecar */ - version: string; - /** When the plugin was installed */ - installedAt: Date; - /** Hash of the built .rbxm for change detection */ - templateHash: string; -} - -export interface BuiltPlugin { - /** The template this was built from */ - template: PluginTemplate; - /** Absolute path to the built .rbxm file in a temp directory */ - builtPath: string; - /** SHA-256 hash of the built file */ - hash: string; - /** Cleanup function to remove the temp build directory */ - cleanupAsync: () => Promise; -} - -export interface BuildOverrides { - /** Override specific build constants (e.g., { PORT: '49201', SESSION_ID: 'abc-123' } for ephemeral mode) */ - constants?: Record; -} - -/** - * Manages the lifecycle of Roblox Studio plugins. - * - * This is a general-purpose utility. studio-bridge is its first consumer, - * but any tool that needs to install a persistent plugin into Studio can - * use this same manager by registering a PluginTemplate. - * - * The manager handles: - * - Discovering the Studio plugins folder (platform-specific) - * - Building plugins from templates via Rojo - * - Installing built plugins to the Studio folder with version tracking - * - Listing currently installed plugins - * - Uninstalling plugins cleanly - */ -export class PluginManager { - private _templates: Map = new Map(); - - /** Register a plugin template. Call this during tool initialization. */ - registerTemplate(template: PluginTemplate): void; - - /** Get a registered template by name. */ - getTemplate(name: string): PluginTemplate | undefined; - - /** List all registered templates. */ - listTemplates(): PluginTemplate[]; - - /** - * Discover the Roblox Studio plugins directory. - * - macOS: ~/Documents/Roblox/Plugins/ - * - Windows: %LOCALAPPDATA%/Roblox/Plugins/ - * Throws if the directory cannot be determined. - */ - async discoverPluginsDirAsync(): Promise; - - /** - * List all plugins installed by this manager (across all templates). - * Reads from the version tracking sidecar files. - */ - async listInstalledAsync(): Promise; - - /** - * Check whether a specific plugin is installed. - * Reads the sidecar metadata -- does not inspect the Studio folder directly. - */ - async isInstalledAsync(name: string): Promise; - - /** - * Build a plugin from its template. - * Returns a BuiltPlugin with the path to the .rbxm and a cleanup function. - * The caller is responsible for calling cleanupAsync() when done. - * - * @param template - The plugin template to build - * @param overrides - Optional constant overrides (for ephemeral mode builds) - */ - async buildAsync(template: PluginTemplate, overrides?: BuildOverrides): Promise; - - /** - * Install a built plugin to the Studio plugins folder. - * Writes the .rbxm and updates the version tracking sidecar. - * - * @param built - The built plugin (from buildAsync) - * @param options - Install options (force overwrite, etc.) - */ - async installAsync(built: BuiltPlugin, options?: { force?: boolean }): Promise; - - /** - * Uninstall a plugin by name. - * Removes the .rbxm from the Studio plugins folder and the version tracking sidecar. - */ - async uninstallAsync(name: string): Promise; -} -``` - -### 2.0.3 Extensibility contract - -The plugin management system is designed around these invariants: - -1. **Adding a new plugin never requires modifying PluginManager.** A new plugin is added by creating a `PluginTemplate` and calling `registerTemplate()`. The manager's build, install, and uninstall methods work unchanged. - -2. **Each plugin owns its template directory.** Templates live alongside the tool that defines them (e.g., `templates/studio-bridge-plugin/` for studio-bridge). The manager does not prescribe where templates are stored. - -3. **Version tracking is per-plugin.** Each installed plugin gets its own sidecar metadata at `~/.nevermore//plugin//version.json`. Plugins do not interfere with each other's version state. - -4. **The CLI surface is composable.** The `install-plugin` and `uninstall-plugin` commands accept a `--plugin` flag (or default to the tool's primary plugin). Future tools can expose their own install commands that delegate to the same `PluginManager`. - -Future plugins that could use this infrastructure include: -- **Rojo integration plugin**: A persistent plugin that syncs project state between Rojo and Studio, built from its own template directory. -- **Testing plugin**: A persistent plugin that provides in-Studio test running UI, installed via `nevermore install-plugin --plugin test-runner`. -- **Remote debugging plugin**: A persistent plugin that exposes a debug protocol endpoint inside Studio. - -Each of these would define a `PluginTemplate`, register it, and use the same `PluginManager` build/install/uninstall flow without any changes to the manager itself. - -### 2.1 Install command - -``` -studio-bridge install-plugin [--force] -``` - -This command applies to **persistent mode** only. In ephemeral mode, the plugin is injected automatically by the server (existing behavior) and no installation step is needed. - -The command delegates to `PluginManager` using the studio-bridge plugin template: - -1. Look up the `studio-bridge` template from the plugin registry. -2. Call `pluginManager.buildAsync(template)` to build the template into `StudioBridgePlugin.rbxm`. The build runs a two-step pipeline: first, Handlebars template substitution (via TemplateHelper) replaces placeholders in the Lua source; then Rojo builds the substituted sources into the `.rbxm`. The persistent-mode build uses the template's default `buildConstants` (which leave `{{PORT}}` and `{{SESSION_ID}}` as raw placeholders), causing the plugin to boot in persistent mode. -3. Call `pluginManager.installAsync(built, { force })` to copy the built file to the Studio plugins folder (discovered via `discoverPluginsDirAsync()`). -4. The install method checks for an existing installation: - - If present and `--force` is not set, compare hashes. Skip if already up to date; overwrite if outdated. - - If present and `--force` is set, overwrite unconditionally. - - If absent, copy the built file. -5. Print confirmation with the installed version and the plugins folder path. -6. Call `built.cleanupAsync()` to remove the temp build directory. - -### 2.2 Version tracking - -The plugin embeds a version string as a constant in `StudioBridgePlugin.server.lua`: - -```lua -local PLUGIN_VERSION = "1.0.0" -``` - -The `PluginManager` maintains a per-plugin sidecar file for version tracking. For studio-bridge, this is at `~/.nevermore/studio-bridge/plugin/studio-bridge/version.json`: - -```json -{ - "pluginName": "studio-bridge", - "version": "1.0.0", - "installedAt": "2026-02-20T10:30:00Z", - "templateHash": "sha256:abc123...", - "outputFileName": "StudioBridgePlugin.rbxm" -} -``` - -The `installAsync` method computes a SHA-256 hash of the built `.rbxm` and compares it to the stored hash. If they match, the plugin is already up to date. The sidecar path uses the plugin name as a subdirectory, so multiple plugins have independent version tracking. - -### 2.3 Uninstall command - -``` -studio-bridge uninstall-plugin -``` - -This command delegates to `pluginManager.uninstallAsync('studio-bridge')`: - -1. Look up the installed plugin metadata from the sidecar file. -2. Remove `StudioBridgePlugin.rbxm` from the Studio plugins folder. -3. Remove the version tracking sidecar at `~/.nevermore/studio-bridge/plugin/studio-bridge/version.json`. -4. Print confirmation. Note that Studio must be restarted for uninstallation to take effect. - -### 2.4 Plugin filename - -Each plugin template specifies its `outputFileName` (e.g., `StudioBridgePlugin.rbxm`). Using a fixed name per plugin ensures that reinstallation replaces the previous version rather than accumulating copies. Different plugins use different filenames, so they coexist in the Studio plugins folder without conflict. - -## 3. Discovery Mechanism - -Discovery only runs in **persistent mode**. In ephemeral mode, the plugin has hardcoded `PORT` and `SESSION_ID` constants and connects directly -- it skips the entire discovery mechanism described in this section. - -The persistent-mode plugin cannot read the file-based session registry (`~/.nevermore/studio-bridge/sessions/`) because Roblox Studio plugins have no filesystem access beyond `plugin:GetSetting`/`plugin:SetSetting`. Instead, it discovers servers by polling HTTP health endpoints. - -### 3.0 Discovery model: many-to-one - -Discovery is many-to-one, not many-to-many. There is exactly one bridge host. All plugins connect to it. All CLI/MCP processes either are the host or connect to it. - -The bridge host is the single WebSocket server running on port 38741. It is the rendezvous point for the entire system. Every participant connects to it: - -``` -Studio A ─── edit context ──────┐ - ├── server context ────┤ - └── client context ────┤ - ├──→ Bridge Host (:38741) ←──┬── CLI (host process) -Studio B ─── edit context ──────┤ ├── CLI (client) - ├── server context ────┤ └── MCP server (client) - └── client context ────┘ -``` - -- **Left side (plugin instances)**: Any number of Roblox Studio instances, each running up to 3 plugin contexts (edit is always present; server and client appear during Play mode). Each plugin instance independently polls `localhost:38741/health` to discover the bridge host. When it responds, the plugin connects via WebSocket on the `/plugin` path. -- **Center (bridge host)**: Exactly one process owns port 38741. This is the first CLI process that started (`BridgeConnection.connectAsync()` tries to bind the port; success = host). The bridge host accepts plugin connections and client connections, tracks all sessions, and routes commands between them. -- **Right side (CLI/MCP clients)**: Any number of CLI or MCP processes that connect to the bridge host on the `/client` path. They send commands (e.g., "execute this script on session X"), and the bridge host forwards them to the correct plugin. - -This topology means there is never a question of "which host should this plugin connect to?" -- there is only one. There is never a question of "which plugin handles this command?" -- the CLI specifies a session ID, and the bridge host looks it up in its session map. - -#### Why not many-to-many? - -A many-to-many model (multiple hosts, each with their own set of plugins) would require plugins to choose between hosts, hosts to coordinate session ownership, and CLI clients to know which host has their session. This adds complexity with no benefit: - -- Multiple CLI processes already coordinate through the single bridge host (one is the host, the rest are clients). -- Multiple Studio instances already coordinate through the single bridge host (each gets a unique session ID). -- If the bridge host crashes, the hand-off protocol (section 7.2 in `00-overview.md`) ensures a client takes over the port. Plugins reconnect to the new host automatically. The system self-heals without any multi-host coordination. - -#### Discovery flow (step-by-step) - -When a persistent plugin instance starts in Studio, it enters this flow. Each context (edit, client, server) runs this flow independently: - -1. **Poll health endpoint**: The plugin sends `HTTP GET localhost:38741/health` every 2 seconds. Each request has a 500ms timeout. -2. **Evaluate response**: If the response is HTTP 200 with valid JSON containing `status: "ok"`, the bridge host is alive. -3. **Open WebSocket**: The plugin connects to `ws://localhost:38741/plugin`. -4. **Send `register`**: On WebSocket open, the plugin generates a UUID (via `HttpService:GenerateGUID()`) and sends a `register` message with this UUID as the proposed `sessionId`, along with its instance ID, context (`"edit"`, `"client"`, or `"server"`), place name, place ID, game ID, Studio state, and capabilities. -5. **Receive `welcome`**: The bridge host accepts the plugin's proposed session ID (or overrides it if there is a collision), stores the session in its in-memory tracker, and responds with a `welcome` message containing the authoritative `sessionId` and negotiated capabilities. The plugin must use the `sessionId` from the `welcome` response for all subsequent messages. -6. **Enter connected state**: The plugin stops polling, adopts the `sessionId` from the `welcome` response, starts processing commands, and begins sending heartbeats. - -If the bridge host is not running (no response to health checks), the plugin stays in step 1, polling indefinitely with the 2-second interval. When a CLI process eventually starts and binds port 38741, the plugin discovers it on the next poll cycle. - -#### Race conditions - -**Multiple plugin contexts connect simultaneously**: When Studio enters Play mode, 2 new plugin instances (server and client) are created alongside the already-connected edit instance. The 2 new instances may discover the bridge host and connect within the same poll cycle. Each plugin context generates its own UUID as the proposed session ID. The bridge host processes WebSocket connections serially (Node.js event loop) and accepts each proposed session ID (collisions are astronomically unlikely with UUIDs, but the server overrides on collision). All 3 enter the connected state independently. There is no conflict -- the bridge host's session tracker is a simple map keyed by session ID, and it uses the shared `instanceId` plus distinct `context` field to group them. - -**Bridge host crashes (kill -9, unhandled exception)**: All plugins detect the WebSocket disconnect via the `Closed` or `Error` event. Each plugin enters the `reconnecting` state with exponential backoff, then returns to `searching` (polling the health endpoint). Meanwhile, a connected CLI client detects the disconnect, waits a random jitter (0-500ms), and attempts to bind port 38741 to become the new host. When the new host is up, plugins discover it on their next poll cycle, connect, and re-register. The bridge host treats them as new sessions -- previous session state (subscriptions, in-flight requests) is lost, which is correct because the host that held that state is gone. - -**Bridge host restarts (graceful stop + new CLI start)**: The bridge host sends `shutdown` to all plugins before closing. Plugins receive `shutdown`, disconnect cleanly, and return to `searching` with no backoff (clean disconnect). When the new CLI process starts and binds the port, plugins discover it immediately on the next 2-second poll cycle. - -**No bridge host is running**: Plugins poll `localhost:38741/health` every 2 seconds. Each health check is a lightweight HTTP GET with a 500ms timeout. The request fails immediately (connection refused). The plugin stays in `searching` state indefinitely, waiting for a CLI process to start. This is by design -- the plugin is dormant until someone starts a CLI. - -#### Session disambiguation - -Sessions are identified by session ID (a UUID generated by the plugin and proposed in the `register` message, then confirmed or overridden by the bridge host in the `welcome` response). The bridge host maintains a map of session ID to WebSocket connection. There is no routing ambiguity: - -- When a CLI consumer calls `session.execAsync(...)`, the command includes the session ID. -- The bridge host looks up the session ID in its tracker and forwards the command over the corresponding plugin WebSocket. -- The plugin's response includes the same session ID, and the bridge host routes it back to the requesting CLI client. - -If multiple Studios are connected, the CLI uses `bridge.listSessionsAsync()` to see all sessions and `bridge.getSession(id)` to target a specific one. Sessions can also be filtered by `context` (e.g., show only server contexts) or grouped by `instanceId` (show all contexts for a specific Studio installation). The auto-selection heuristic (if exactly one session exists, use it; if multiple, prompt) is in the CLI adapter, not the bridge host. - -The plugin persists an **instance ID** (UUID stored in `plugin:SetSetting("StudioBridge_InstanceId")`) that survives across Studio restarts. This is sent in the `register` message along with the **context** (`"edit"`, `"client"`, or `"server"`). The bridge host uses `instanceId` to group contexts from the same Studio installation and `context` to distinguish them. For routing purposes, only the session ID matters -- `instanceId` and `context` are metadata for display and filtering. - -#### Debugging discovery - -When discovery is not working, use these tools: - -- **`studio-bridge sessions`**: Shows all currently connected sessions (session ID, place name, Studio state, plugin version, connected duration). If no sessions appear, either no Studio is running or the plugin is not connecting. -- **`studio-bridge sessions --watch`**: Streams connection and disconnection events in real time. Useful for seeing whether a plugin connects and immediately disconnects (handshake failure) vs. never connects at all (network issue). -- **Health endpoint**: `curl localhost:38741/health` returns the bridge host status and connected session count. If this returns connection refused, no bridge host is running. If it returns 200 but shows 0 sessions, the bridge host is up but no plugins have connected. -- **Plugin output in Studio**: The plugin logs all state transitions to Studio's Output window with a `[StudioBridge]` prefix: - - `[StudioBridge] Persistent mode, searching for server...` -- plugin started, polling for host - - `[StudioBridge] searching -> connecting` -- health check succeeded, opening WebSocket - - `[StudioBridge] connecting -> connected` -- handshake complete - - `[StudioBridge] connected -> reconnecting` -- connection lost - - `[StudioBridge] reconnecting -> searching` -- backoff expired, resuming poll -- **Bridge host debug logs**: The bridge host logs connection events at debug level. Set `DEBUG=studio-bridge:*` (or the equivalent log level flag) to see: - - `plugin connected from /plugin (instanceId=xxx)` -- WebSocket opened - - `session registered: sessionId=xxx, placeName=xxx` -- `register` processed - - `plugin disconnected: sessionId=xxx` -- WebSocket closed - - `client connected from /client` -- CLI client joined - -Common issues: -- **Plugin says "searching" forever**: The bridge host is not running. Start a CLI process (`studio-bridge exec`, `studio-bridge terminal`, etc.) to create the host. -- **Plugin connects then immediately disconnects**: Check the Output window for errors. Common cause: protocol version mismatch (old server that does not understand `register`; the plugin should fall back to `hello` after 3 seconds). -- **Sessions show in CLI but commands time out**: The plugin is connected but not responding. Check Studio's Output for errors in the plugin's action handlers. The plugin may be blocked (e.g., a long-running script holding the Luau thread). -- **Health endpoint returns 200 but plugin is not connecting**: Check that Studio's `HttpService` is enabled (Game Settings > Security > Allow HTTP Requests). The persistent plugin uses `HttpService:RequestAsync` for health checks. - -### 3.1 Health endpoint - -The bridge host exposes an HTTP GET `/health` endpoint on port 38741. The response is JSON: - -```json -{ - "status": "ok", - "port": 38741, - "protocolVersion": 2, - "serverVersion": "0.5.0", - "sessions": 2, - "uptime": 45230 -} -``` - -The plugin uses `HttpService:RequestAsync` to poll this endpoint. A successful 200 response with valid JSON indicates a live bridge host. Note: the health endpoint does not include a `sessionId` -- the plugin generates its own session ID when sending `register`. - -### 3.2 Port scanning strategy - -The plugin maintains an ordered list of candidate ports and tries them sequentially: - -1. **Well-known port 38741** -- tried first. In split-server mode, the daemon binds to this port. In single-process mode, the server may also prefer this port if available. -2. **Known ports from plugin settings** -- `plugin:SetSetting("StudioBridge_KnownPorts")` stores a JSON-encoded array of ports that have previously been seen. The server tells the plugin about its port during the `welcome` handshake, and the plugin persists it for future discovery. -3. **Scan range** -- as a last resort, scan ports 38741-38760 (a 20-port window). This is narrow enough to complete quickly but wide enough to find multiple concurrent servers. - -### 3.3 Discovery loop pseudocode - -```lua -local WELL_KNOWN_PORT = 38741 -local SCAN_RANGE_START = 38741 -local SCAN_RANGE_END = 38760 -local POLL_INTERVAL = 2 -- seconds - -function Discovery.findServerAsync(plugin) - local knownPorts = Discovery._getKnownPorts(plugin) - local candidatePorts = Discovery._buildCandidateList(knownPorts) - - for _, port in ipairs(candidatePorts) do - local health = Discovery._tryHealthCheck(port) - if health and health.status == "ok" then - return { - port = port, - protocolVersion = health.protocolVersion or 1, - } - end - end - - return nil -- no server found this cycle -end - -function Discovery._buildCandidateList(knownPorts) - local seen = {} - local list = {} - - -- Well-known port first - table.insert(list, WELL_KNOWN_PORT) - seen[WELL_KNOWN_PORT] = true - - -- Known ports from previous connections - for _, port in ipairs(knownPorts) do - if not seen[port] then - table.insert(list, port) - seen[port] = true - end - end - - -- Scan range for anything we haven't tried - for port = SCAN_RANGE_START, SCAN_RANGE_END do - if not seen[port] then - table.insert(list, port) - seen[port] = true - end - end - - return list -end - -function Discovery._tryHealthCheck(port) - local url = "http://localhost:" .. tostring(port) .. "/health" - local ok, response = pcall(function() - return HttpService:RequestAsync({ - Url = url, - Method = "GET", - }) - end) - - if not ok or not response or response.StatusCode ~= 200 then - return nil - end - - local decodeOk, health = pcall(function() - return HttpService:JSONDecode(response.Body) - end) - - if not decodeOk or type(health) ~= "table" then - return nil - end - - return health -end - -function Discovery._getKnownPorts(plugin) - local raw = plugin:GetSetting("StudioBridge_KnownPorts") - if type(raw) ~= "string" then - return {} - end - local ok, ports = pcall(function() - return HttpService:JSONDecode(raw) - end) - if ok and type(ports) == "table" then - return ports - end - return {} -end - -function Discovery._saveKnownPort(plugin, port) - local ports = Discovery._getKnownPorts(plugin) - -- Avoid duplicates, keep most recent first - local filtered = { port } - for _, p in ipairs(ports) do - if p ~= port then - table.insert(filtered, p) - end - end - -- Cap at 20 entries - if #filtered > 20 then - filtered = { unpack(filtered, 1, 20) } - end - plugin:SetSetting("StudioBridge_KnownPorts", HttpService:JSONEncode(filtered)) -end -``` - -### 3.4 Polling interval - -- **Searching state**: Poll every 2 seconds. Each cycle iterates the full candidate list. Individual health checks time out after 500ms to avoid blocking. -- **Connected state**: Polling stops entirely. The connection is maintained via WebSocket events and heartbeat. -- **Reconnecting state**: Polling resumes after the backoff delay (see section 6). - -## 4. Plugin State Machine - -### 4.0 Boot mode detection - -The state machine begins with a mode branch. The build pipeline substitutes an `IS_EPHEMERAL` boolean constant directly (Handlebars replaces the `{{IS_EPHEMERAL}}` placeholder, then Rojo builds the result), so the plugin checks it without any string comparison tricks: - -``` -idle → check IS_EPHEMERAL - ├── IS_EPHEMERAL == true (PORT is a number, SESSION_ID is a UUID): - │ connect directly to PORT/SESSION_ID → connected - │ (no discovery, no reconnection -- plugin is deleted on stopAsync) - │ - └── IS_EPHEMERAL == false (PORT is nil, SESSION_ID is nil): - enter discovery loop → searching → connecting → connected - (full state machine below) -``` - -In ephemeral mode, the plugin connects directly and enters the `connected` state. If the connection drops, the plugin does not reconnect -- it was injected for a single session. In persistent mode, the full state machine below governs the lifecycle. - -### 4.1 States (persistent mode) - -Each plugin instance (edit, client, server) has its own independent state machine. The edit instance's state machine is already running (and connected, if a bridge host is available) before Play mode starts. When Studio enters Play mode and creates the server and client plugin instances, each new instance starts its own state machine from `idle`, discovers the bridge host independently, and connects with its own WebSocket. The edit instance is unaffected by Play mode transitions -- it continues running with its existing connection. No coordination between instances is needed -- they are fully independent Luau environments. - -| State | Description | Activity | -|-------|-------------|----------| -| `idle` | Plugin just loaded, not yet started | One-time initialization, mode detection | -| `searching` | Polling health endpoints for a server | Discovery loop running every 2s | -| `connecting` | Server found, WebSocket handshake in progress | Waiting for `Opened` event, then sending `register` | -| `connected` | Handshake complete, ready for actions | Processing messages, sending heartbeat | -| `reconnecting` | Connection lost, waiting before retry | Backoff timer, then return to `searching` | - -### 4.2 Transitions (persistent mode) - -``` - ┌─────────┐ - │ idle │ - └────┬────┘ - │ RunService:IsStudio() == true - │ AND mode == persistent - ┌────▼────┐ - │searching│◄──────────────────────────────────┐ - └────┬────┘ │ - │ Discovery._tryHealthCheck() succeeds │ - ┌────▼──────┐ │ - │ connecting │ │ - └────┬──┬───┘ │ - │ │ WebSocket open fails │ - │ └──────────────────────────────────────┘ - │ WebSocket opened + welcome received - ┌────▼────┐ - │connected│ - └────┬──┬─┘ - │ │ shutdown message received ───────────┐ - │ │ │ - │ │ WebSocket closed / error ┌──────▼──────┐ - │ └───────────────────────────────►│ searching │ - │ └─────────────┘ - │ WebSocket closed / error (NOT shutdown) - ┌────▼───────┐ - │reconnecting│ - └────┬───────┘ - │ backoff timer expires - │ - └─────────────────────────────────────────► searching -``` - -In ephemeral mode, the state machine is simplified: `idle → connected`. The `searching`, `connecting`, and `reconnecting` states are never entered. - -Key transition rules: - -- **idle to searching**: Immediate on plugin load, after verifying Studio environment. -- **searching to connecting**: When a health check returns a valid server. -- **connecting to connected**: When the WebSocket `Opened` event fires and the server responds to `register` (or `hello`) with `welcome`. -- **connecting to searching**: If the WebSocket fails to open within 5 seconds, or if the `Opened` event fires but no `welcome` arrives within 3 seconds of sending `register`. -- **connected to reconnecting**: On WebSocket `Closed` or `Error` event, unless the last received message was `shutdown`. -- **connected to searching**: On receiving a `shutdown` message. This is a clean disconnect -- no backoff needed. -- **reconnecting to searching**: After the backoff timer expires. - -### 4.3 State machine pseudocode - -```lua -local STATE_IDLE = "idle" -local STATE_SEARCHING = "searching" -local STATE_CONNECTING = "connecting" -local STATE_CONNECTED = "connected" -local STATE_RECONNECTING = "reconnecting" - -local currentState = STATE_IDLE -local backoffSeconds = 0 -local wsClient = nil -local sessionId = nil -local negotiatedVersion = 1 - -local function transitionTo(newState) - local prev = currentState - currentState = newState - print("[StudioBridge] " .. prev .. " -> " .. newState) -end - -local function runStateMachine(plugin) - transitionTo(STATE_SEARCHING) - - while true do - if currentState == STATE_SEARCHING then - local server = Discovery.findServerAsync(plugin) - if server then - -- sessionId will be set by handleWelcome() after the server - -- confirms or overrides the plugin-generated proposed ID - transitionTo(STATE_CONNECTING) - local success = attemptConnection(plugin, server) - if not success then - transitionTo(STATE_SEARCHING) - end - else - task.wait(POLL_INTERVAL) - end - - elseif currentState == STATE_CONNECTED then - -- Event-driven in this state; yield until disconnection - task.wait(1) - - elseif currentState == STATE_RECONNECTING then - task.wait(backoffSeconds) - transitionTo(STATE_SEARCHING) - end - end -end -``` - -### 4.4 Context detection - -Each plugin instance detects which context it is running in using `RunService` properties. In Play mode, Roblox creates separate DataModels for the server and client, each with its own plugin instance. The `RunService` properties differ per context: - -| Context | `IsServer()` | `IsClient()` | `IsRunning()` | -|---------|:------------:|:------------:|:--------------:| -| Edit | false | false | false | -| Server | true | false | true | -| Client | false | true | true | - -```lua -local RunService = game:GetService("RunService") - -local function detectContext(): string - -- In Play mode, RunService properties differ per context: - -- Edit DataModel: IsServer()=false, IsClient()=false, IsRunning()=false - -- Server context: IsServer()=true, IsRunning()=true - -- Client context: IsClient()=true, IsRunning()=true - if RunService:IsServer() and RunService:IsRunning() then - return "server" - elseif RunService:IsClient() and RunService:IsRunning() then - return "client" - else - return "edit" - end -end -``` - -This is a simplified heuristic. The exact detection may need refinement based on Studio's behavior (e.g., edge cases during Play mode transitions). The context is detected once at plugin startup and does not change for the lifetime of that plugin instance -- if Studio exits Play mode, the server and client plugin instances are destroyed entirely, and new ones are created if Play mode is entered again. - -The detected context is included in the `register` message (section 5.2) so the bridge host knows which DataModel environment each session represents. - -## 5. Connection Lifecycle - -### 5.1 WebSocket connection - -Once discovery finds a live server, the plugin opens a WebSocket: - -```lua -local function attemptConnection(plugin, server) - local url = "ws://localhost:" .. tostring(server.port) .. "/plugin" - - local ok, client = pcall(function() - return HttpService:CreateWebStreamClient( - Enum.WebStreamClientType.WebSocket, - { Url = url } - ) - end) - - if not ok or not client then - warn("[StudioBridge] WebSocket creation failed: " .. tostring(client)) - return false - end - - wsClient = client - -- Wire up event handlers (see section 5.3) - setupEventHandlers(plugin, client, server) - return true -end -``` - -### 5.2 Handshake: register with hello fallback - -After the WebSocket `Opened` event fires, the plugin generates a UUID (via `HttpService:GenerateGUID()`) as its proposed session ID and sends a `register` message. If the server is v1 and does not recognize `register`, it will ignore the message. The plugin waits 3 seconds for a `welcome` response. If none arrives, it falls back to sending `hello`. After receiving `welcome`, the plugin must use the `sessionId` from the `welcome` response for all subsequent messages (in case the server overrode the proposed ID). - -```lua -local function performHandshake(client, server) - local instanceId = getOrCreateInstanceId(plugin) - - -- Generate a proposed session ID - local proposedSessionId = HttpService:GenerateGUID(false) - - -- Try register first (v2) - Protocol.send(client, "register", proposedSessionId, { - pluginVersion = PLUGIN_VERSION, - instanceId = instanceId, - context = detectContext(), -- "edit", "client", or "server" - placeName = game.Name, - placeId = game.PlaceId, - gameId = game.GameId, - placeFile = nil, -- not available from plugin context - state = StateMonitor.getCurrentState(), - capabilities = { - "execute", "queryState", "captureScreenshot", - "queryDataModel", "queryLogs", "subscribe", "heartbeat", - }, - }, { protocolVersion = 2 }) - - -- Wait for welcome - local welcomeReceived = false - local startTime = os.clock() - - while not welcomeReceived and (os.clock() - startTime) < 3 do - task.wait(0.1) - if negotiatedVersion > 0 then - welcomeReceived = true - end - end - - if not welcomeReceived then - -- Fallback to hello (v1) - print("[StudioBridge] No response to register, falling back to hello") - Protocol.send(client, "hello", proposedSessionId, { - sessionId = proposedSessionId, - }) - - -- Wait again - startTime = os.clock() - while not welcomeReceived and (os.clock() - startTime) < 3 do - task.wait(0.1) - if negotiatedVersion > 0 then - welcomeReceived = true - end - end - end - - -- After welcome, sessionId is set from the welcome response (see handleWelcome). - -- The server may have accepted or overridden our proposed ID. - return welcomeReceived -end -``` - -### 5.3 Instance ID - -The plugin generates a UUID on first run and persists it in plugin settings. This ID uniquely identifies this plugin installation across sessions and Studio restarts. It is not the session ID -- the plugin generates a proposed session ID on each connection via `HttpService:GenerateGUID()`, and the server confirms or overrides it in the `welcome` response. - -```lua -local function getOrCreateInstanceId(plugin) - local id = plugin:GetSetting("StudioBridge_InstanceId") - if type(id) == "string" and #id > 0 then - return id - end - id = HttpService:GenerateGUID(false) - plugin:SetSetting("StudioBridge_InstanceId", id) - return id -end -``` - -### 5.4 Session ID handling - -Unlike the temporary plugin where SESSION_ID is baked in at build time, the persistent plugin generates its own proposed session ID (via `HttpService:GenerateGUID()`) and sends it in the `register` message. The server accepts this ID or overrides it (e.g., on collision). The `welcome` response contains the authoritative session ID, which the plugin must adopt. The plugin validates that every subsequent incoming message carries this authoritative session ID and drops messages that do not match. - -### 5.5 Welcome processing - -When the plugin receives a `welcome` message, it adopts the authoritative session ID from the response (which may differ from the proposed ID if the server overrode it), extracts the negotiated protocol version, and records the confirmed capabilities: - -```lua -local function handleWelcome(msg) - -- Adopt the authoritative session ID from the server's welcome response. - -- This may be the same as the proposed ID, or the server may have overridden it. - sessionId = msg.sessionId - negotiatedVersion = msg.protocolVersion or 1 - - if msg.payload and msg.payload.capabilities then - confirmedCapabilities = msg.payload.capabilities - else - confirmedCapabilities = { "execute" } - end - - transitionTo(STATE_CONNECTED) - print("[StudioBridge] Connected (v" .. tostring(negotiatedVersion) .. ", session=" .. tostring(sessionId) .. ")") -end -``` - -### 5.6 Session ID lifecycle - -The system uses two distinct identifiers with different lifetimes: - -**`instanceId`** (persistent, per-installation): -- Generated once on first plugin run and stored in `plugin:SetSetting("StudioBridge_InstanceId")`. -- Survives Studio restarts, plugin updates, and reconnections. -- Shared across all 3 plugin contexts (edit, client, server) within the same Studio installation because `PluginSettings` are per-installation, not per-context. -- The bridge host uses `instanceId` to group contexts that belong to the same Studio installation. - -**`sessionId`** (ephemeral, per-connection): -- Generated by the plugin (via `HttpService:GenerateGUID()`) and proposed in the `register` message. The bridge host accepts it or overrides it (on collision); the `welcome` response contains the authoritative value. -- Each context gets its own session ID. An edit context, a server context, and a client context from the same Studio installation will have 3 different session IDs. -- A new session ID is generated on every connection. If the plugin disconnects and reconnects (e.g., because the bridge host restarted), it generates a fresh UUID for the new connection. -- Session IDs are UUIDs used for routing commands to the correct plugin WebSocket. - -The relationship between these identifiers: - -``` -instanceId "abc-123" (stored in PluginSettings, survives restarts) - ├── sessionId "s1" (edit context, plugin-generated on connect, confirmed by server, lost on disconnect) - ├── sessionId "s2" (server context, plugin-generated on connect, confirmed by server, lost on disconnect) - └── sessionId "s3" (client context, plugin-generated on connect, confirmed by server, lost on disconnect) -``` - -When the bridge host receives a `register` message, it accepts or overrides the plugin's proposed `sessionId`, creates a new session entry keyed by that session ID, and records the `instanceId` and `context` as metadata. CLI clients can use this metadata to target specific contexts (e.g., "execute on the server context of Studio installation X"). - -## 6. Reconnection Strategy - -### 6.1 Triggers - -Reconnection is triggered by: -- WebSocket `Closed` event (server stopped, network interruption) -- WebSocket `Error` event (protocol error, abnormal close) -- Missing heartbeat acknowledgment is not a trigger (the plugin sends heartbeats, not the server) - -Reconnection is NOT triggered by: -- Receiving a `shutdown` message -- the plugin disconnects cleanly and returns to `searching` with no backoff - -### 6.2 Exponential backoff - -```lua -local BACKOFF_INITIAL = 1 -local BACKOFF_MULTIPLIER = 2 -local BACKOFF_MAX = 30 - -local function enterReconnecting(wasShutdown) - if wasShutdown then - -- Clean disconnect, go straight to searching - backoffSeconds = 0 - transitionTo(STATE_SEARCHING) - return - end - - -- Increase backoff - if backoffSeconds == 0 then - backoffSeconds = BACKOFF_INITIAL - else - backoffSeconds = math.min(backoffSeconds * BACKOFF_MULTIPLIER, BACKOFF_MAX) - end - - transitionTo(STATE_RECONNECTING) -end - -local function resetBackoff() - backoffSeconds = 0 -end -``` - -The backoff sequence is: 1s, 2s, 4s, 8s, 16s, 30s, 30s, 30s, ... - -`resetBackoff()` is called on every successful connection (when `welcome` is received). - -### 6.3 Behavior during reconnection - -**Heartbeat coroutine note**: The heartbeat loop runs as a `task.spawn` coroutine. When the WebSocket disconnects, the loop must exit cleanly. Use a `connected` boolean flag that the disconnect handler sets to `false`; the heartbeat coroutine checks it each iteration and returns when false. Do not use `task.cancel` on the heartbeat thread -- that can leave partially-sent WebSocket frames. The pattern is: `while connected do task.wait(15); if connected then send heartbeat end end`. - -While in the `reconnecting` or `searching` state: -- No action messages are processed (there is no WebSocket connection). -- The heartbeat timer is stopped. -- The log buffer continues to accumulate entries from `LogService.MessageOut` so that logs generated during the gap are not lost. -- The state monitor continues tracking Studio state so that a `stateChange` push can be sent after reconnection if the state changed while disconnected. - -### 6.4 Server restart scenario - -When a user stops and restarts `studio-bridge`, the plugin detects the server restart during the reconnection discovery loop: the `/health` endpoint responds again. The plugin treats this as a new connection -- it generates a fresh UUID as its proposed session ID, sends a new `register`, receives a `welcome` with the authoritative session ID, resets its internal state, and begins a new session. The old session's log buffer is cleared. - -## 7. Action Handlers - -Action handlers are **shared between both boot modes**. Whether the plugin is running in ephemeral mode (direct connect) or persistent mode (discovery loop), the same dispatch table and handler modules process incoming messages. This is the primary benefit of the unified plugin architecture: all action logic is validated once and works identically in both modes. - -Each v2 capability is implemented as a handler module. The `ActionHandler` dispatch table routes incoming messages by type: - -```lua --- ActionHandler.lua -local handlers = { - execute = require(script.Parent.Actions.ExecuteAction), - queryState = require(script.Parent.Actions.StateAction), - captureScreenshot = require(script.Parent.Actions.ScreenshotAction), - queryDataModel = require(script.Parent.Actions.DataModelAction), - queryLogs = require(script.Parent.Actions.LogAction), - subscribe = require(script.Parent.Actions.SubscribeHandler), - unsubscribe = require(script.Parent.Actions.SubscribeHandler), -} - -function ActionHandler.dispatch(client, msg, context) - if msg.sessionId ~= context.sessionId then - warn("[StudioBridge] Session mismatch, ignoring") - return - end - - local handler = handlers[msg.type] - if handler then - local ok, err = xpcall(function() - handler.handle(client, msg, context) - end, debug.traceback) - - if not ok then - Protocol.sendError(client, context.sessionId, msg.requestId, "INTERNAL_ERROR", tostring(err)) - end - elseif msg.type == "welcome" then - -- Handled by connection lifecycle, not dispatch - elseif msg.type == "shutdown" then - -- Handled by connection lifecycle - else - -- Unknown message type: ignore per protocol spec - end -end -``` - -### 7.1 ExecuteAction - -Runs a Luau string via `loadstring` + `xpcall`. Correlates with `requestId` if present. - -```lua --- Actions/ExecuteAction.lua -local ExecuteAction = {} - -function ExecuteAction.handle(client, msg, context) - local source = msg.payload and msg.payload.script - if type(source) ~= "string" then - Protocol.sendError(client, context.sessionId, msg.requestId, "INVALID_PAYLOAD", "Missing script field") - return - end - - local fn, loadErr = loadstring(source) - if not fn then - Protocol.send(client, "scriptComplete", context.sessionId, { - success = false, - error = "loadstring failed: " .. tostring(loadErr), - }, { requestId = msg.requestId }) - return - end - - local ok, runErr = xpcall(fn, debug.traceback) - - -- Let final prints flush through LogService - task.wait(0.2) - context.flushOutput() - - Protocol.send(client, "scriptComplete", context.sessionId, { - success = ok, - error = if ok then nil else tostring(runErr), - }, { requestId = msg.requestId }) -end - -return ExecuteAction -``` - -Execute requests are serialized: if a script is already running, the next `execute` is queued via `task.spawn` ordering. This matches the concurrency rule from `01-protocol.md` section 4.4. - -### 7.2 StateAction - -Reads the current Studio run mode and place metadata. - -```lua --- Actions/StateAction.lua -local RunService = game:GetService("RunService") - -local StateAction = {} - -function StateAction.handle(client, msg, context) - Protocol.send(client, "stateResult", context.sessionId, { - state = StateMonitor.getCurrentState(), - placeId = game.PlaceId, - placeName = game.Name, - gameId = game.GameId, - }, { requestId = msg.requestId }) -end - -return StateAction -``` - -### 7.3 ScreenshotAction - -Captures the 3D viewport using `CaptureService` and extracts image bytes via `EditableImage`. - -The confirmed API call chain: -1. `CaptureService:CaptureScreenshot(callback)` -- callback receives a `contentId` string -2. Load the `contentId` into an `EditableImage` (e.g., `AssetService:CreateEditableImageAsync(contentId)`) -3. Read the pixel/image bytes from the `EditableImage` (e.g., `editableImage:ReadPixels(...)`) -4. Base64-encode the bytes -5. Read dimensions from `editableImage.Size` -6. Send over WebSocket as a base64 string in the `screenshotResult` message - -**Note for implementer**: The exact `EditableImage` constructor and pixel-read method names should be verified against the Roblox API at implementation time. The method may be `ReadPixels`, `GetPixels`, or similar, and the factory may be `AssetService:CreateEditableImageAsync` or a different constructor. - -```lua --- Actions/ScreenshotAction.lua -local AssetService = game:GetService("AssetService") -local CaptureService = game:GetService("CaptureService") - -local ScreenshotAction = {} - --- Base64 encoding helper (a simple implementation; may use a shared utility) -local BASE64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" - -local function base64Encode(data) - -- Implementation: encode raw bytes to base64 string - -- (full implementation omitted for brevity; use a standard base64 encoder) - return data -- placeholder -end - -function ScreenshotAction.handle(client, msg, context) - -- Step 1: Capture the screenshot. CaptureScreenshot is callback-based. - -- We use a BindableEvent or coroutine to bridge the callback into the - -- synchronous action handler flow. - local captureComplete = false - local capturedContentId = nil - local captureError = nil - - local captureOk, captureErr = pcall(function() - CaptureService:CaptureScreenshot(function(contentId) - capturedContentId = contentId - captureComplete = true - end) - end) - - if not captureOk then - Protocol.sendError(client, context.sessionId, msg.requestId, - "SCREENSHOT_FAILED", "CaptureService error: " .. tostring(captureErr)) - return - end - - -- Wait for the callback to fire (with a timeout) - local startTime = os.clock() - while not captureComplete and (os.clock() - startTime) < 10 do - task.wait(0.1) - end - - if not captureComplete then - Protocol.sendError(client, context.sessionId, msg.requestId, - "SCREENSHOT_FAILED", "CaptureService callback did not fire within 10 seconds") - return - end - - -- Step 2: Load the contentId into an EditableImage - -- NOTE: Verify exact method name at implementation time. - local imageOk, editableImage = pcall(function() - return AssetService:CreateEditableImageAsync(capturedContentId) - end) - - if not imageOk or not editableImage then - Protocol.sendError(client, context.sessionId, msg.requestId, - "SCREENSHOT_FAILED", "Could not create EditableImage: " .. tostring(editableImage)) - return - end - - -- Step 3: Read pixel bytes from the EditableImage - -- NOTE: Verify exact method name (ReadPixels, GetPixels, etc.) at implementation time. - local imageSize = editableImage.Size - local readOk, pixelData = pcall(function() - return editableImage:ReadPixels(Vector2.new(0, 0), imageSize) - end) - - if not readOk then - Protocol.sendError(client, context.sessionId, msg.requestId, - "SCREENSHOT_FAILED", "Could not read image data: " .. tostring(pixelData)) - return - end - - -- Step 4: Base64-encode the pixel data - local base64Data = base64Encode(pixelData) - - -- Steps 5-6: Read dimensions and send the result - Protocol.send(client, "screenshotResult", context.sessionId, { - data = base64Data, - format = "png", - width = imageSize.X, - height = imageSize.Y, - }, { requestId = msg.requestId }) -end - -return ScreenshotAction -``` - -The CaptureService API is confirmed to work in Studio plugins. The call chain is: `CaptureScreenshot` delivers a `contentId` string via callback, which is loaded into an `EditableImage` to extract pixel bytes, then base64-encoded for transmission. If any step fails at runtime (capture, EditableImage creation, or pixel read), the handler returns an error with code `SCREENSHOT_FAILED` and a descriptive message. - -### 7.4 DataModelAction - -Resolves an instance path, reads properties, and optionally traverses children. - -```lua --- Actions/DataModelAction.lua -local DataModelAction = {} - -function DataModelAction.handle(client, msg, context) - local payload = msg.payload - - -- Handle listServices - if payload.listServices then - local services = {} - for _, child in ipairs(game:GetChildren()) do - table.insert(services, ValueSerializer.serializeInstance(child, 0, {}, false)) - end - Protocol.send(client, "dataModelResult", context.sessionId, { - instance = { - name = "Game", - className = "DataModel", - path = "game", - properties = {}, - attributes = {}, - childCount = #services, - children = services, - }, - }, { requestId = msg.requestId }) - return - end - - -- Resolve path - local instance, resolvedPath, failedSegment = DataModelAction._resolvePath(payload.path) - if not instance then - Protocol.sendError(client, context.sessionId, msg.requestId, "INSTANCE_NOT_FOUND", - "No instance found at path: " .. tostring(payload.path), { - resolvedTo = resolvedPath, - failedSegment = failedSegment, - }) - return - end - - -- Handle find - if payload.find then - local target - if payload.find.recursive then - target = instance:FindFirstChild(payload.find.name, true) - else - target = instance:FindFirstChild(payload.find.name) - end - if not target then - Protocol.sendError(client, context.sessionId, msg.requestId, "INSTANCE_NOT_FOUND", - "Child not found: " .. payload.find.name) - return - end - instance = target - end - - local depth = payload.depth or 0 - local properties = payload.properties or { "Name", "ClassName" } - local includeAttributes = payload.includeAttributes or false - - local serialized = ValueSerializer.serializeInstance(instance, depth, properties, includeAttributes) - Protocol.send(client, "dataModelResult", context.sessionId, { - instance = serialized, - }, { requestId = msg.requestId }) -end - -function DataModelAction._resolvePath(path) - -- Path format: "game.Workspace.SpawnLocation" - local segments = string.split(path, ".") - if segments[1] ~= "game" then - return nil, "", segments[1] - end - - local current = game - local resolvedPath = "game" - - for i = 2, #segments do - local child = current:FindFirstChild(segments[i]) - if not child then - return nil, resolvedPath, segments[i] - end - current = child - resolvedPath = resolvedPath .. "." .. segments[i] - end - - return current, resolvedPath, nil -end - -return DataModelAction -``` - -### 7.5 LogAction - -Reads from the ring buffer maintained by `LogBuffer`. - -```lua --- Actions/LogAction.lua -local LogAction = {} - -function LogAction.handle(client, msg, context) - local payload = msg.payload - local count = payload.count or 50 - local direction = payload.direction or "tail" - local levels = payload.levels -- nil means all - local includeInternal = payload.includeInternal or false - - local entries = context.logBuffer:query(count, direction, levels, includeInternal) - - Protocol.send(client, "logsResult", context.sessionId, { - entries = entries, - total = context.logBuffer:size(), - bufferCapacity = context.logBuffer.capacity, - }, { requestId = msg.requestId }) -end - -return LogAction -``` - -### 7.6 StateMonitor - -Detects state transitions for this plugin context and pushes `stateChange` messages when subscribed. - -Each plugin instance has its own `StateMonitor` that reports the state of its own context, not the state of the whole Studio. Because each context runs in a separate Luau environment with its own `RunService`, `getCurrentState()` naturally returns the correct state for that context: - -- **Edit context**: Always reports `"Edit"`. The edit DataModel is never in a running state. -- **Server context**: Reports `"Run"` when the Play-mode server is active, or `"Paused"` if paused. -- **Client context**: Reports `"Play"` when the Play-mode client is active, or `"Paused"` if paused. - -```lua --- StateMonitor.lua -local RunService = game:GetService("RunService") - -local StateMonitor = {} -StateMonitor._currentState = "Edit" -StateMonitor._onStateChanged = nil -- callback - --- Reports the state of THIS context's DataModel, not the whole Studio. --- Each plugin instance (edit, client, server) has its own StateMonitor. -function StateMonitor.getCurrentState() - if not RunService:IsRunning() then - return "Edit" - end - - -- We are in a running context (server or client). - -- Detect pause state. Note: detecting Paused requires checking if - -- the game is actively ticking, which may need refinement. - if RunService:IsServer() then - return "Run" -- or "Paused" if pause detection is available - elseif RunService:IsClient() then - return "Play" -- or "Paused" if pause detection is available - else - return "Edit" - end -end - -function StateMonitor.start(onStateChanged) - StateMonitor._onStateChanged = onStateChanged - StateMonitor._currentState = StateMonitor.getCurrentState() - - -- Poll periodically since there is no single event for all transitions - task.spawn(function() - while true do - task.wait(0.5) - local newState = StateMonitor.getCurrentState() - if newState ~= StateMonitor._currentState then - local prev = StateMonitor._currentState - StateMonitor._currentState = newState - if StateMonitor._onStateChanged then - StateMonitor._onStateChanged(prev, newState) - end - end - end - end) -end - -return StateMonitor -``` - -The `onStateChanged` callback is wired by the main plugin script to send `stateChange` push messages via WebSocket push when the server has an active `stateChange` subscription. The bridge host forwards these push messages to all subscribed clients (see `07-bridge-network.md` section 5.3 for the subscription routing mechanism). Since each context has its own `StateMonitor`, state changes are reported per-context: the bridge host receives separate `stateChange` notifications for the edit, server, and client sessions. - -Similarly, when a `logPush` subscription is active, the plugin pushes individual `logPush` messages for each new `LogService.MessageOut` entry as it occurs. Each `logPush` message contains a single `{ level, body, timestamp }` entry. The bridge host forwards these to subscribed clients. The `SubscribeHandler` module (section 8) manages the active subscription set and gates whether push messages are sent over the WebSocket. - -## 8. Plugin Luau Module Structure - -The unified plugin lives in the existing template directory. There is no separate `studio-bridge-plugin` directory. - -``` -templates/studio-bridge-plugin/ (unified -- same directory as before, upgraded in-place) - default.project.json - src/ - StudioBridgePlugin.server.lua -- entry point, boot mode detection, state machine - Discovery.lua -- HTTP health polling, port scanning (persistent mode only) - Protocol.lua -- JSON encode/decode, send helpers - ActionHandler.lua -- dispatch table, routes messages to handlers - Actions/ - ExecuteAction.lua -- loadstring + xpcall, requestId correlation - StateAction.lua -- RunService state query - ScreenshotAction.lua -- CaptureService viewport capture - DataModelAction.lua -- path resolution, property reading, depth traversal - LogAction.lua -- ring buffer query - SubscribeHandler.lua -- subscribe/unsubscribe management - LogBuffer.lua -- ring buffer implementation (1000 entries) - StateMonitor.lua -- RunService state change detection - ValueSerializer.lua -- Roblox type to JSON serialization -``` - -### 8.1 Entry point structure - -`StudioBridgePlugin.server.lua` is the top-level script. Each plugin instance (edit, client, server) runs the same entry point code independently. No special Play mode handling is needed -- the edit instance is already running and connected; when Studio enters Play mode and creates new server and client plugin instances, each new instance boots, detects its context, discovers the bridge host, and connects on its own. - -The entry point: - -1. Guards against non-Studio contexts. -2. Detects the boot mode by checking whether build-time constants are present. -3. Detects the plugin context (edit, client, or server) via `detectContext()`. -4. Requires all modules. -5. Initializes the log buffer and state monitor. -6. Hooks `LogService.MessageOut` to feed the log buffer (this runs continuously, independent of connection state). -7. Branches based on boot mode: ephemeral (direct connect) or persistent (discovery state machine). - -```lua --- StudioBridgePlugin.server.lua -local HttpService = game:GetService("HttpService") -local LogService = game:GetService("LogService") -local RunService = game:GetService("RunService") -local Workspace = game:GetService("Workspace") - -if not RunService:IsStudio() then - return -end - -local PLUGIN_VERSION = "1.0.0" - --- --------------------------------------------------------------------------- --- Context detection --- --- Each plugin instance detects whether it is running in the edit, server, or --- client context. See section 4.4 for details. --- --------------------------------------------------------------------------- - -local function detectContext(): string - if RunService:IsServer() and RunService:IsRunning() then - return "server" - elseif RunService:IsClient() and RunService:IsRunning() then - return "client" - else - return "edit" - end -end - -local PLUGIN_CONTEXT = detectContext() - --- --------------------------------------------------------------------------- --- Boot mode detection --- --- These constants are injected via a two-step build pipeline: --- 1. Handlebars template substitution (TemplateHelper) replaces {{IS_EPHEMERAL}}, --- {{PORT}}, and {{SESSION_ID}} placeholders in the Lua source. --- 2. Rojo builds the substituted sources into the .rbxm plugin file. --- --- Result after substitution: --- Ephemeral build: IS_EPHEMERAL = true, PORT = , SESSION_ID = "" --- Persistent build: IS_EPHEMERAL = false, PORT = nil, SESSION_ID = nil --- No string comparison needed -- IS_EPHEMERAL is a plain boolean. --- --------------------------------------------------------------------------- - -local IS_EPHEMERAL = {{IS_EPHEMERAL}} -- replaced at build time with `true` or `false` -local PORT = {{PORT}} -- replaced with number (ephemeral) or nil (persistent) -local SESSION_ID = "{{SESSION_ID}}" -- replaced with UUID (ephemeral) or nil/empty (persistent) - --- In ephemeral mode, validate the session ID guard (same as the old temporary plugin) -if IS_EPHEMERAL then - if RunService:IsRunning() then - return - end - local thisPlaceSessionId = Workspace:GetAttribute("StudioBridgeSessionId") - if thisPlaceSessionId ~= SESSION_ID then - return - end -end - -local Discovery = require(script.Parent.Discovery) -local Protocol = require(script.Parent.Protocol) -local ActionHandler = require(script.Parent.ActionHandler) -local LogBuffer = require(script.Parent.LogBuffer) -local StateMonitor = require(script.Parent.StateMonitor) - --- Initialize log buffer (persists across connections for this context) -local logBuffer = LogBuffer.new(1000) - --- Map Roblox MessageType enum to string levels -local LEVEL_MAP = { - [Enum.MessageType.MessageOutput] = "Print", - [Enum.MessageType.MessageInfo] = "Info", - [Enum.MessageType.MessageWarning] = "Warning", - [Enum.MessageType.MessageError] = "Error", -} - --- Always capture logs, even when not connected -LogService.MessageOut:Connect(function(message, messageType) - local isInternal = string.sub(message, 1, 14) == "[StudioBridge]" - local level = LEVEL_MAP[messageType] or "Print" - logBuffer:push({ - level = level, - body = message, - timestamp = os.clock() * 1000, - isInternal = isInternal, - }) -end) - --- Start state monitor (monitors this context's state only) -StateMonitor.start(function(prevState, newState) - -- Push stateChange if connected and subscribed (wired in main loop) -end) - --- --------------------------------------------------------------------------- --- Branch by boot mode --- --------------------------------------------------------------------------- - -if IS_EPHEMERAL then - -- Ephemeral mode: connect directly to the known server, no discovery. - -- This path behaves identically to the old temporary plugin. - print("[StudioBridge] Ephemeral mode (port=" .. tostring(PORT) .. ", session=" .. tostring(SESSION_ID) .. ")") - task.spawn(function() - connectDirectly(PORT, SESSION_ID) - end) -else - -- Persistent mode: enter discovery loop, poll for servers. - -- Each context (edit, client, server) runs this independently. - print("[StudioBridge] Persistent mode (" .. PLUGIN_CONTEXT .. " context), searching for server...") - task.spawn(function() - runStateMachine(plugin) - end) -end -``` - -The `connectDirectly` function opens a WebSocket to `ws://localhost:{PORT}/{SESSION_ID}`, sends `hello`, and enters the `connected` state. It reuses the same `Protocol`, `ActionHandler`, and output batching logic as the persistent mode's `connected` state. The `runStateMachine` function implements the full persistent-mode state machine described in section 4. - -In persistent mode, the entry point does not guard against `RunService:IsRunning()`. Unlike ephemeral mode (which exits early if the game is running, since the ephemeral plugin is only meant for the edit DataModel), each persistent-mode plugin instance is expected to connect regardless of context. The edit instance connects during edit; the server and client instances connect during Play mode. Each instance runs the same state machine independently. - -### 8.2 Protocol module - -`Protocol.lua` handles JSON encoding, decoding, and typed message sending: - -```lua --- Protocol.lua -local HttpService = game:GetService("HttpService") - -local Protocol = {} - -function Protocol.send(client, msgType, sessionId, payload, options) - options = options or {} - local message = { - type = msgType, - sessionId = sessionId, - payload = payload, - } - - if options.requestId then - message.requestId = options.requestId - end - if options.protocolVersion then - message.protocolVersion = options.protocolVersion - end - - local ok, err = pcall(function() - client:Send(HttpService:JSONEncode(message)) - end) - if not ok then - warn("[StudioBridge] Send failed: " .. tostring(err)) - end -end - -function Protocol.sendError(client, sessionId, requestId, code, message, details) - Protocol.send(client, "error", sessionId, { - code = code, - message = message, - details = details, - }, { requestId = requestId }) -end - -function Protocol.decode(rawData) - local ok, msg = pcall(function() - return HttpService:JSONDecode(rawData) - end) - if not ok or type(msg) ~= "table" or type(msg.type) ~= "string" then - return nil - end - return msg -end - -return Protocol -``` - -### 8.3 LogBuffer module - -A fixed-capacity ring buffer that stores log entries. Entries are never removed except by overflow (oldest entries are dropped when the buffer is full). - -```lua --- LogBuffer.lua -local LogBuffer = {} -LogBuffer.__index = LogBuffer - -function LogBuffer.new(capacity) - return setmetatable({ - capacity = capacity, - _buffer = table.create(capacity), - _head = 1, -- next write position - _count = 0, -- number of entries currently stored - }, LogBuffer) -end - -function LogBuffer:push(entry) - self._buffer[self._head] = entry - self._head = (self._head % self.capacity) + 1 - if self._count < self.capacity then - self._count = self._count + 1 - end -end - -function LogBuffer:size() - return self._count -end - -function LogBuffer:query(count, direction, levels, includeInternal) - local all = self:_toArray() - - -- Filter - local filtered = {} - for _, entry in ipairs(all) do - if not includeInternal and entry.isInternal then - continue - end - if levels then - local match = false - for _, level in ipairs(levels) do - if entry.level == level then - match = true - break - end - end - if not match then - continue - end - end - table.insert(filtered, { - level = entry.level, - body = entry.body, - timestamp = entry.timestamp, - }) - end - - -- Apply direction and count - if direction == "head" then - local result = {} - for i = 1, math.min(count, #filtered) do - table.insert(result, filtered[i]) - end - return result - else -- tail - local result = {} - local start = math.max(1, #filtered - count + 1) - for i = start, #filtered do - table.insert(result, filtered[i]) - end - return result - end -end - -function LogBuffer:_toArray() - local result = {} - if self._count < self.capacity then - for i = 1, self._count do - table.insert(result, self._buffer[i]) - end - else - -- Ring buffer is full; read from head (oldest) to end, then start to head-1 - for i = self._head, self.capacity do - table.insert(result, self._buffer[i]) - end - for i = 1, self._head - 1 do - table.insert(result, self._buffer[i]) - end - end - return result -end - -function LogBuffer:clear() - self._buffer = table.create(self.capacity) - self._head = 1 - self._count = 0 -end - -return LogBuffer -``` - -### 8.4 ValueSerializer module - -Serializes Roblox types to the JSON-compatible `SerializedValue` format defined in `01-protocol.md`. Primitive types (string, number, boolean) are passed through as bare values. Complex Roblox types use a `type` discriminant field and a flat `value` array containing the numeric components. - -```lua --- ValueSerializer.lua -local ValueSerializer = {} - -local SERIALIZERS = { - ["string"] = function(v) return v end, - ["number"] = function(v) return v end, - ["boolean"] = function(v) return v end, - ["nil"] = function() return nil end, -} - -function ValueSerializer.serialize(value) - local luaType = typeof(value) - - -- Primitives pass through as bare values - local simple = SERIALIZERS[luaType] - if simple then - return simple(value) - end - - -- Roblox types use { type = "...", value = [...] } format - if luaType == "Vector3" then - return { type = "Vector3", value = { value.X, value.Y, value.Z } } - elseif luaType == "Vector2" then - return { type = "Vector2", value = { value.X, value.Y } } - elseif luaType == "CFrame" then - -- GetComponents() returns: x, y, z, r00, r01, r02, r10, r11, r12, r20, r21, r22 - return { type = "CFrame", value = { value:GetComponents() } } - elseif luaType == "Color3" then - return { type = "Color3", value = { value.R, value.G, value.B } } - elseif luaType == "UDim2" then - return { type = "UDim2", value = { value.X.Scale, value.X.Offset, value.Y.Scale, value.Y.Offset } } - elseif luaType == "UDim" then - return { type = "UDim", value = { value.Scale, value.Offset } } - elseif luaType == "BrickColor" then - return { type = "BrickColor", name = value.Name, value = value.Number } - elseif luaType == "EnumItem" then - return { - type = "EnumItem", - enum = tostring(value.EnumType), - name = value.Name, - value = value.Value, - } - elseif luaType == "Instance" then - return { - type = "Instance", - className = value.ClassName, - path = ValueSerializer.getInstancePath(value), - } - else - return { - type = "Unsupported", - typeName = luaType, - toString = tostring(value), - } - end -end - -function ValueSerializer.getInstancePath(instance) - local parts = {} - local current = instance - while current and current ~= game do - table.insert(parts, 1, current.Name) - current = current.Parent - end - return "game." .. table.concat(parts, ".") -end - -function ValueSerializer.serializeInstance(instance, depth, propertyNames, includeAttributes) - local properties = {} - for _, propName in ipairs(propertyNames) do - local ok, value = pcall(function() - return (instance :: any)[propName] - end) - if ok then - properties[propName] = ValueSerializer.serialize(value) - end - end - - local attributes = {} - if includeAttributes then - for key, value in pairs(instance:GetAttributes()) do - attributes[key] = ValueSerializer.serialize(value) - end - end - - local children = nil - if depth > 0 then - children = {} - for _, child in ipairs(instance:GetChildren()) do - table.insert(children, ValueSerializer.serializeInstance(child, depth - 1, propertyNames, includeAttributes)) - end - end - - return { - name = instance.Name, - className = instance.ClassName, - path = ValueSerializer.getInstancePath(instance), - properties = properties, - attributes = attributes, - childCount = #instance:GetChildren(), - children = children, - } -end - -return ValueSerializer -``` - -## 9. Backward Compatibility - -### 9.1 Unified plugin replaces the old temporary plugin - -The unified plugin source at `templates/studio-bridge-plugin/` replaces the old single-purpose temporary plugin. There is no separate persistent plugin directory. In ephemeral mode (build-time constants present), the unified plugin behaves identically to the old temporary plugin: - -- It checks `Workspace:GetAttribute("StudioBridgeSessionId")` against the hardcoded `SESSION_ID`. -- It connects directly to `ws://localhost:{PORT}/{SESSION_ID}`. -- It sends `hello`, receives `welcome`, processes `execute` and `shutdown` messages. -- It is deleted on `stopAsync()`. - -All action handlers, protocol logic, and serialization are shared between modes, so ephemeral mode gains v2 capabilities (state queries, screenshots, DataModel inspection, log retrieval) for free. - -### 9.2 Server-side mode detection - -`StudioBridgeServer.startAsync()` checks whether the persistent plugin is installed before deciding which connection strategy to use. This check delegates to the universal `PluginManager`: - -```typescript -// Pseudocode for mode selection in studio-bridge-server.ts -async startAsync(): Promise { - const usePersistent = this.options.preferPersistentPlugin !== false - && await this._pluginManager.isInstalledAsync('studio-bridge'); - - if (usePersistent) { - // Start WebSocket server, expose /health endpoint, wait for plugin discovery - await this.startPersistentModeAsync(); - } else { - // Build unified plugin WITH PORT/SESSION_ID substitution (ephemeral mode), - // inject into plugins folder using PluginManager.buildAsync with overrides - await this.startTemporaryModeAsync(); - } -} -``` - -`pluginManager.isInstalledAsync('studio-bridge')` checks for the existence of the version tracking sidecar at `~/.nevermore/studio-bridge/plugin/studio-bridge/version.json`. It does not inspect the Studio plugins folder directly (which may be in a platform-specific location that the server process cannot easily verify). - -### 9.3 Coexistence behavior - -Both a persistent-mode and an ephemeral-mode copy of the unified plugin can technically be present in the Studio plugins folder at the same time (e.g., the persistent copy installed globally, and an ephemeral copy injected for a specific session). They will never both connect to the same server because: - -1. The ephemeral copy's `SESSION_ID` is hardcoded and validated via `Workspace:GetAttribute("StudioBridgeSessionId")`. It connects only to the server that injected it. -2. The persistent copy discovers servers via health endpoints, generates its own session ID, and connects to the `/plugin` WebSocket path. -3. The WebSocket server accepts only one plugin connection per session. The first plugin to complete the handshake wins; subsequent connection attempts are rejected. - -In practice, when the persistent plugin is installed, the server skips ephemeral injection entirely, so there is no overlap. - -## 10. Security Considerations - -### 10.1 Localhost only - -The plugin only makes HTTP and WebSocket connections to `localhost`. Roblox Studio's `HttpService` enforces this for plugin contexts -- `CreateWebStreamClient` and `RequestAsync` are restricted to loopback addresses. No configuration in the plugin can override this. - -### 10.2 Session ID as token - -The session ID (UUIDv4) in the WebSocket URL path (`ws://localhost:{port}/{sessionId}`) acts as an unguessable token. A process on the same machine would need to guess the UUID to connect. The server rejects WebSocket upgrade requests with an incorrect session ID at the HTTP level. - -### 10.3 Welcome validation - -After connecting and sending `register` with a plugin-generated session ID, the plugin adopts the `sessionId` from the server's `welcome` response as authoritative. The server may confirm the plugin's proposed ID or override it. In either case, the plugin uses the `welcome.sessionId` for all subsequent messages. The plugin validates that the `welcome` response is well-formed (has a non-empty `sessionId`, valid JSON structure). If the `welcome` is malformed, the plugin disconnects immediately. - -### 10.4 Dormancy when no servers exist - -If the plugin completes a full scan of candidate ports and finds no health endpoints, it continues polling at the 2-second interval. However, the HTTP requests are lightweight (GET with 500ms timeout) and the scan covers at most ~20 ports, so the CPU and network cost is negligible. - -If the user uninstalls studio-bridge entirely (removing `~/.nevermore/studio-bridge/`), the plugin continues polling but never finds a server. This is acceptable because the polling is cheap and because uninstallation of the plugin itself (`studio-bridge uninstall-plugin`) is the proper way to stop the plugin entirely. - -### 10.5 No arbitrary code in discovery - -The plugin never executes code from the health endpoint response. It only reads structured JSON fields (`sessionId`, `port`, `protocolVersion`). The `sessionId` is used as a URL path component and a string comparison target, never as executable input. - -### 10.6 Settings versioning - -The plugin uses `plugin:SetSetting` to persist `StudioBridge_InstanceId` and `StudioBridge_KnownPorts`. If future versions add or rename settings keys, the plugin must not crash on stale values left by an older version. Each setting key should be read with a safe default: if the value is `nil` or the wrong type, fall back to the default and overwrite the stale value. A `StudioBridge_SettingsVersion` integer key (starting at 1) should be stored alongside the other settings. On load, if the stored version is less than the current version, the plugin runs a migration function that clears or transforms incompatible keys. This keeps the migration logic forward-only and avoids silent data corruption from schema drift. diff --git a/studio-bridge/plans/tech-specs/04-action-specs.md b/studio-bridge/plans/tech-specs/04-action-specs.md deleted file mode 100644 index 2a19468616..0000000000 --- a/studio-bridge/plans/tech-specs/04-action-specs.md +++ /dev/null @@ -1,1583 +0,0 @@ -# Action Specifications - -This document specifies each studio-bridge action end-to-end: CLI surface, terminal dot-command, MCP tool, wire protocol, server handler, plugin handler, error cases, and timeout. It is the companion to `01-protocol.md` (which defines the message types) and `00-overview.md` (which defines the architecture). - -References: -- PRD: `../prd/main.md` (features F1-F7) -- Tech spec: `00-overview.md` (component map, server modes) -- Protocol: `01-protocol.md` (message types, error codes, timeouts) - ---- - -## 1. sessions -- List running sessions - -**Summary**: Enumerate all Studio sessions that have a connected (or recently connected) persistent plugin. - -### CLI - -**Command**: `studio-bridge sessions` - -| Flag | Alias | Type | Default | Description | -|------|-------|------|---------|-------------| -| `--json` | | boolean | `false` | Output as JSON array | -| `--watch` | | boolean | `false` | Continuously update the session list | - -**Example** (single Studio instance in Edit mode): - -``` -$ studio-bridge sessions - SESSION ID PLACE CONTEXT STATE PLACE ID ORIGIN CONNECTED - a1b2c3d4-e5f6-7890-abcd-ef1234567890 TestPlace.rbxl Edit Edit 1234567890 user 2m 30s - -1 session connected. -``` - -**Example** (single Studio instance in Play mode -- 3 sessions, grouped by instance): - -``` -$ studio-bridge sessions - Instance: TestPlace.rbxl (inst-001) - - SESSION ID PLACE CONTEXT STATE PLACE ID ORIGIN CONNECTED - a1b2c3d4-e5f6-7890-abcd-ef1234567890 TestPlace.rbxl Edit Play 1234567890 user 15m 42s - b2c3d4e5-f6a7-8901-bcde-f12345678901 TestPlace.rbxl Server Play 1234567890 user 15m 40s - c3d4e5f6-a7b8-9012-cdef-123456789012 TestPlace.rbxl Client Play 1234567890 user 15m 40s - -3 sessions connected (1 instance). -``` - -**Example** (multiple Studio instances): - -``` -$ studio-bridge sessions - Instance: TestPlace.rbxl (inst-001) - - SESSION ID PLACE CONTEXT STATE PLACE ID ORIGIN CONNECTED - a1b2c3d4-e5f6-7890-abcd-ef1234567890 TestPlace.rbxl Edit Edit 1234567890 user 2m 30s - - Instance: MyGame.rbxl (inst-002) - - SESSION ID PLACE CONTEXT STATE PLACE ID ORIGIN CONNECTED - f9e8d7c6-b5a4-3210-fedc-ba0987654321 MyGame.rbxl Edit Play 9876543210 managed 15m 42s - e8d7c6b5-a432-10fe-dcba-098765432101 MyGame.rbxl Server Play 9876543210 managed 15m 40s - d7c6b5a4-3210-fedc-ba09-876543210123 MyGame.rbxl Client Play 9876543210 managed 15m 40s - -4 sessions connected (2 instances). -``` - -``` -$ studio-bridge sessions --json -[ - { - "sessionId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - "placeName": "TestPlace.rbxl", - "context": "Edit", - "state": "Edit", - "instanceId": "inst-001", - "placeId": 1234567890, - "gameId": 9876543210, - "origin": "user", - "uptimeMs": 150000 - } -] -``` - -When no sessions exist: -``` -$ studio-bridge sessions -No active sessions. Is Studio running with the studio-bridge plugin installed? -``` - -### Terminal - -**Dot-command**: `.sessions` - -No arguments. Prints the same table as the CLI (minus the `--json` and `--watch` flags). - -### MCP - -**Tool name**: `studio_sessions` - -**Input schema**: -```typescript -{} // no parameters -``` - -**Output schema**: -```typescript -type SessionContext = 'edit' | 'client' | 'server'; - -interface SessionsResult { - sessions: Array<{ - sessionId: string; - placeName: string; - placeFile?: string; - context: SessionContext; - state: StudioState; - instanceId: string; - placeId: number; - gameId: number; - origin: SessionOrigin; - uptimeMs: number; - }>; -} -``` - -### Protocol - -No wire protocol message. The session list is read from the bridge host's in-memory session tracking. The server does not need to ask the plugin for this information. - -### Server handler - -Calls `BridgeConnection.listSessionsAsync()`. Formats the result for the requested output mode (table, JSON, or MCP response). - -### Plugin handler - -None. The plugin does not participate in session listing -- session tracking is bridge host state. - -### Error cases - -| Condition | Message | -|-----------|---------| -| No bridge host running | `No bridge host running. Start one with 'studio-bridge terminal' or 'studio-bridge exec'.` | -| Bridge host running but no plugins connected | `No active sessions. Is Studio running with the studio-bridge plugin installed?` | - -### Timeout - -Not applicable (in-memory lookup). - -### Return type - -```typescript -type SessionContext = 'edit' | 'client' | 'server'; - -interface SessionInfo { - sessionId: string; - placeFile?: string; - placeName: string; - context: SessionContext; - instanceId: string; - placeId: number; - gameId: number; - origin: SessionOrigin; // 'user' | 'managed' - connectedAt: string; // ISO 8601 -- serialized from the Date in the public API. - // The wire protocol carries this as a millisecond timestamp (number); - // the server converts to Date, and CLI/JSON output serializes as ISO 8601. - state: string; -} -``` - -A single Studio instance produces 1-3 sessions that share the same `instanceId`. In Edit mode, there is one session with `context: 'edit'`. When the user enters Play mode, the instance produces up to two additional sessions: `context: 'server'` and `context: 'client'`. All sessions from the same instance share the same `instanceId`, `placeId`, and `gameId`. - ---- - -## 2. connect -- Connect to existing session - -**Summary**: Attach an interactive terminal REPL to a running Studio session. This is an alias for `studio-bridge terminal --session ` with intent-clarifying semantics. - -### CLI - -**Command**: `studio-bridge connect ` - -| Positional | Type | Required | Description | -|------------|------|----------|-------------| -| `session-id` | string | yes | Session ID to connect to | - -No additional flags beyond the global args (`--verbose`, `--timeout`). Once connected, the user enters terminal mode with all dot-commands available. - -**Example**: - -``` -$ studio-bridge connect a1b2c3d4-e5f6-7890-abcd-ef1234567890 -Connected to TestPlace.rbxl (Edit mode) -Type .help for commands, .exit to disconnect. - -> -``` - -### Terminal - -**Dot-command**: `.connect ` - -Switches the current terminal session to a different Studio session. The previous session is disconnected (not killed). - -``` -> .connect f9e8d7c6-b5a4-3210-fedc-ba0987654321 -Disconnected from TestPlace.rbxl -Connected to MyGame.rbxl (Play mode) -``` - -### MCP - -No dedicated MCP tool. Session targeting is handled by the `sessionId` parameter on each individual tool. - -### Protocol - -No new protocol message. Connection uses the existing WebSocket handshake (`hello`/`welcome` or `register`/`welcome`). - -### Server handler - -Looks up the session by ID via `BridgeConnection.getSession(sessionId)`. If found, enters terminal mode attached to that session. - -### Plugin handler - -None beyond the standard handshake. The plugin is already connected to the server; the CLI is connecting to the server, not directly to the plugin. - -### Error cases - -| Condition | Message | -|-----------|---------| -| Session ID not found | `Session not found: {sessionId}. Run 'studio-bridge sessions' to see available sessions.` | -| Session exists but plugin is disconnected | `Session {sessionId} exists but the plugin is not connected. Studio may have been closed.` | -| WebSocket connection refused | `Cannot connect to session {sessionId}. The bridge host may have crashed.` | - -### Timeout - -Inherits the global `--timeout` (default: 30000ms) for the initial WebSocket handshake. - -### Return type - -No structured return. Enters interactive mode. - ---- - -## 3. state -- Query Studio state - -**Summary**: Get the current run mode, place name, place ID, and game ID of a Studio session. - -### CLI - -**Command**: `studio-bridge state [session-id]` - -| Flag | Alias | Type | Default | Description | -|------|-------|------|---------|-------------| -| `--session` | `-s` | string | (auto) | Target session ID | -| `--json` | | boolean | `false` | Output as JSON | -| `--watch` | | boolean | `false` | Subscribe to state changes and print updates | - -**Example**: - -``` -$ studio-bridge state -Place: TestPlace -PlaceId: 1234567890 -GameId: 9876543210 -Mode: Edit -``` - -``` -$ studio-bridge state --json -{ - "state": "Edit", - "placeName": "TestPlace", - "placeId": 1234567890, - "gameId": 9876543210 -} -``` - -``` -$ studio-bridge state --watch -[14:30:22] Mode: Edit -[14:30:45] Mode: Play -[14:31:02] Mode: Paused -^C -``` - -### Terminal - -**Dot-command**: `.state` - -No arguments. Prints the state in the same format as the CLI default (human-readable). - -``` -> .state -Place: TestPlace -PlaceId: 1234567890 -GameId: 9876543210 -Mode: Edit -``` - -### MCP - -**Tool name**: `studio_state` - -**Input schema**: -```typescript -interface StudioStateInput { - sessionId?: string; -} -``` - -**Output schema**: -```typescript -interface StudioStateOutput { - state: StudioState; // 'Edit' | 'Play' | 'Paused' | 'Run' | 'Server' | 'Client' - placeName: string; - placeId: number; // 0 if unpublished - gameId: number; // 0 if unpublished -} -``` - -### Protocol - -**Request**: `queryState` (server to plugin) -```json -{ "type": "queryState", "sessionId": "...", "requestId": "req-001", "payload": {} } -``` - -**Response**: `stateResult` (plugin to server) -```json -{ - "type": "stateResult", "sessionId": "...", "requestId": "req-001", - "payload": { "state": "Edit", "placeId": 1234567890, "placeName": "TestPlace", "gameId": 9876543210 } -} -``` - -For `--watch` mode, the server sends a `subscribe { events: ['stateChange'] }` message to the plugin via WebSocket push. The plugin confirms with `subscribeResult` and then pushes `stateChange` messages on each Studio state transition (Edit <-> Play <-> Pause). These push messages are forwarded by the bridge host to all subscribed clients. When the user interrupts (Ctrl+C), the server sends `unsubscribe { events: ['stateChange'] }` to stop the push stream. See `01-protocol.md` section 5.2 for the full subscribe/unsubscribe protocol and `07-bridge-network.md` section 5.3 for the host subscription routing mechanism. - -### Server handler - -File: `src/server/actions/query-state.ts` - -1. Calls `performActionAsync` with a `queryState` message. -2. Awaits the `stateResult` response. -3. Formats the payload for the requested output mode. -4. For `--watch`: sends `subscribe { events: ['stateChange'] }` to the plugin via WebSocket push. The plugin confirms with `subscribeResult`, then pushes `stateChange` messages on each Studio state transition. These are forwarded by the bridge host to the subscribed client (see `07-bridge-network.md` section 5.3). The CLI prints each transition until the user interrupts with Ctrl+C, at which point it sends `unsubscribe { events: ['stateChange'] }`. - -### Plugin handler - -File: `templates/studio-bridge-plugin/src/Actions/StateAction.lua` - -1. Reads `RunService:IsEdit()`, `RunService:IsRunMode()`, `RunService:IsClient()`, `RunService:IsServer()`, `RunService:IsRunning()` to determine `StudioState`. -2. Reads `game.PlaceId`, `game.Name`, `game.GameId`. -3. Sends `stateResult` with the gathered data. - -### Error cases - -| Condition | Error code | Message | -|-----------|-----------|---------| -| Plugin does not support `queryState` | `CAPABILITY_NOT_SUPPORTED` | `This Studio session does not support state queries. Update the studio-bridge plugin.` | -| Plugin did not respond in time | `TIMEOUT` | `State query timed out after 5 seconds.` | -| No sessions available | (CLI-level) | `No active sessions. Is Studio running with the studio-bridge plugin installed?` | - -### Timeout - -5 seconds (per 01-protocol.md). - -### Retry safety - -**Safe to retry.** `queryState` is a read-only query with no side effects. Retrying after a timeout or transient error is always safe. - -### Return type - -```typescript -interface StateResult { - state: StudioState; - placeName: string; - placeId: number; - gameId: number; -} -``` - ---- - -## 4. screenshot -- Capture viewport - -**Summary**: Capture a PNG screenshot of the Studio 3D viewport. - -### CLI - -**Command**: `studio-bridge screenshot [session-id]` - -| Flag | Alias | Type | Default | Description | -|------|-------|------|---------|-------------| -| `--session` | `-s` | string | (auto) | Target session ID | -| `--output` | `-o` | string | (temp dir) | Output file path | -| `--open` | | boolean | `false` | Open the image in the default viewer after capture | -| `--base64` | | boolean | `false` | Print base64-encoded PNG to stdout instead of writing a file | - -**Example**: - -``` -$ studio-bridge screenshot -Screenshot saved to /tmp/studio-bridge/screenshot-2026-02-20-143022.png -``` - -``` -$ studio-bridge screenshot -o ./capture.png -Screenshot saved to ./capture.png -``` - -``` -$ studio-bridge screenshot --base64 -iVBORw0KGgoAAAANSUhEUgAA... -``` - -### Terminal - -**Dot-command**: `.screenshot [path]` - -Optional path argument. If omitted, writes to the temp directory and prints the path. - -``` -> .screenshot -Screenshot saved to /tmp/studio-bridge/screenshot-2026-02-20-143055.png - -> .screenshot ./my-capture.png -Screenshot saved to ./my-capture.png -``` - -### MCP - -**Tool name**: `studio_screenshot` - -**Input schema**: -```typescript -interface StudioScreenshotInput { - sessionId?: string; -} -``` - -**Output schema**: -```typescript -interface StudioScreenshotOutput { - data: string; // base64-encoded PNG - format: 'png'; - width: number; - height: number; -} -``` - -MCP always returns base64 data inline (not a file path) so agents can process the image directly. - -### Protocol - -**Request**: `captureScreenshot` (server to plugin) -```json -{ "type": "captureScreenshot", "sessionId": "...", "requestId": "req-002", "payload": {} } -``` - -**Response**: `screenshotResult` (plugin to server) -```json -{ - "type": "screenshotResult", "sessionId": "...", "requestId": "req-002", - "payload": { "data": "iVBORw0KGgoAAAANSUhEUgAA...", "format": "png", "width": 1920, "height": 1080 } -} -``` - -### Server handler - -File: `src/server/actions/capture-screenshot.ts` - -1. Calls `performActionAsync` with a `captureScreenshot` message. -2. Awaits the `screenshotResult` response. -3. For CLI default mode: decodes the base64 data, writes to a temp file (`/tmp/studio-bridge/screenshot-{timestamp}.png`), prints the path. -4. For `--output`: writes to the specified path. -5. For `--base64`: prints raw base64 to stdout. -6. For `--open`: after writing the file, spawns `open` (macOS) or `xdg-open` (Linux) with the file path. -7. For MCP: returns the raw `screenshotResult` payload. - -### Plugin handler - -File: `templates/studio-bridge-plugin/src/Actions/ScreenshotAction.lua` - -The confirmed API call chain for capturing a screenshot and extracting image bytes: - -1. Call `CaptureService:CaptureScreenshot(function(contentId) ... end)`. The callback receives a `contentId` string (a temporary content URL pointing to the captured image). -2. Inside the callback, load the `contentId` into an `EditableImage` via `AssetService:CreateEditableImageAsync(contentId)` (or equivalent `EditableImage` constructor that accepts a content ID). **Note for implementer**: verify the exact method name against the Roblox API at implementation time, as the `EditableImage` API may use a different constructor or factory method. -3. Read the raw pixel bytes from the `EditableImage` (e.g., `editableImage:ReadPixels(Vector2.new(0, 0), editableImage.Size)`). **Note for implementer**: verify the exact method name (`ReadPixels`, `GetPixels`, or similar) and return type against the Roblox API at implementation time. -4. Base64-encode the pixel/image bytes. -5. Read the image dimensions from `editableImage.Size` (a `Vector2` with width and height). -6. Send the `screenshotResult` message over the WebSocket with the base64-encoded data, format, width, and height. - -### Error cases - -| Condition | Error code | Message | -|-----------|-----------|---------| -| `CaptureService:CaptureScreenshot()` call fails | `SCREENSHOT_FAILED` | `Screenshot capture failed: {error detail}` | -| `EditableImage` creation or pixel read fails | `SCREENSHOT_FAILED` | `Screenshot capture failed: could not read image data: {error detail}` | -| Viewport not available (Studio minimized) | `SCREENSHOT_FAILED` | `Cannot capture screenshot: viewport is not available. Is Studio minimized?` | -| Plugin does not support screenshots | `CAPABILITY_NOT_SUPPORTED` | `This Studio session does not support screenshots. Update the studio-bridge plugin.` | -| Timeout | `TIMEOUT` | `Screenshot capture timed out after 15 seconds.` | -| Output path not writable | (CLI-level) | `Cannot write screenshot to {path}: {os error}` | - -### Timeout - -15 seconds (per 01-protocol.md). - -### Retry safety - -**Safe to retry.** `captureScreenshot` is a read-only capture with no side effects. Retrying after a timeout or transient error is always safe, though the viewport contents may differ between attempts. - -### Return type - -```typescript -interface ScreenshotResult { - data: string; // base64-encoded PNG - format: 'png'; - width: number; - height: number; -} -``` - ---- - -## 5. logs -- Retrieve/follow output logs - -**Summary**: Retrieve buffered output log lines from a Studio session, or stream new lines in real time. - -### CLI - -**Command**: `studio-bridge logs [session-id]` - -| Flag | Alias | Type | Default | Description | -|------|-------|------|---------|-------------| -| `--session` | `-s` | string | (auto) | Target session ID | -| `--tail` | | number | `50` | Show last N lines | -| `--head` | | number | | Show first N lines since plugin connected | -| `--follow` | `-f` | boolean | `false` | Stream new output in real time | -| `--level` | | string | (all) | Comma-separated level filter: `Print`, `Info`, `Warning`, `Error` | -| `--all` | | boolean | `false` | Include internal `[StudioBridge]` messages | -| `--json` | | boolean | `false` | Output each line as a JSON object | - -`--tail` and `--head` are mutually exclusive. If neither is provided, `--tail 50` is the default. `--follow` can be combined with `--level` and `--all` but not with `--head` or `--tail`. - -**Example**: - -``` -$ studio-bridge logs -14:30:01 [Print] Hello from script -14:30:01 [Print] Player count: 0 -14:30:02 [Warning] Infinite yield possible on 'Players:WaitForChild("LocalPlayer")' -``` - -``` -$ studio-bridge logs --tail 100 --level Error,Warning -14:30:02 [Warning] Infinite yield possible on 'Players:WaitForChild("LocalPlayer")' -14:31:15 [Error] Script 'Workspace.Script': attempt to index nil with 'Name' -``` - -``` -$ studio-bridge logs --follow -(streaming, Ctrl+C to stop) -14:30:01 [Print] Hello from script -14:30:05 [Print] Tick -14:30:06 [Print] Tick -^C -``` - -``` -$ studio-bridge logs --json --tail 2 -[ - { "timestamp": 12340, "level": "Print", "body": "Hello from script" }, - { "timestamp": 12341, "level": "Print", "body": "Player count: 0" } -] -``` - -### Terminal - -**Dot-command**: `.logs [--tail N | --head N | --follow]` - -Accepts the same flags as the CLI in a simplified form. - -``` -> .logs -(last 50 lines) - -> .logs --tail 10 -(last 10 lines) - -> .logs --follow -(streaming, press Enter to stop) -``` - -### MCP - -**Tool name**: `studio_logs` - -**Input schema**: -```typescript -interface StudioLogsInput { - sessionId?: string; - count?: number; // default: 50 - direction?: 'head' | 'tail'; // default: 'tail' - levels?: string[]; // e.g. ['Error', 'Warning'] - includeInternal?: boolean; // default: false -} -``` - -**Output schema**: -```typescript -interface StudioLogsOutput { - entries: Array<{ - level: OutputLevel; - body: string; - timestamp: number; - }>; - total: number; - bufferCapacity: number; -} -``` - -MCP does not support follow mode. It returns a snapshot of the log buffer per invocation. - -### Protocol - -**Request**: `queryLogs` (server to plugin) -```json -{ - "type": "queryLogs", "sessionId": "...", "requestId": "req-003", - "payload": { "count": 50, "direction": "tail", "levels": ["Print", "Warning", "Error"], "includeInternal": false } -} -``` - -**Response**: `logsResult` (plugin to server) -```json -{ - "type": "logsResult", "sessionId": "...", "requestId": "req-003", - "payload": { - "entries": [ - { "level": "Print", "body": "Hello from script", "timestamp": 12340 }, - { "level": "Warning", "body": "Infinite yield possible", "timestamp": 12345 } - ], - "total": 847, - "bufferCapacity": 1000 - } -} -``` - -For `--follow` mode: the server sends `subscribe { events: ['logPush'] }` to the plugin via WebSocket push. The plugin confirms with `subscribeResult` and then pushes individual `logPush` messages for each new LogService entry as it occurs. Each `logPush` message contains a single `{ level, body, timestamp }` entry. These push messages are forwarded by the bridge host to all subscribed clients (see `07-bridge-network.md` section 5.3 for the host subscription routing mechanism). The CLI streams these entries to stdout, applying level and internal-message filters. On Ctrl+C (SIGINT), the server sends `unsubscribe { events: ['logPush'] }` to stop the push stream. Note: `logPush` is distinct from `output` -- `output` messages are batched and scoped to a single `execute` request, while `logPush` streams individual entries continuously from all sources. See `01-protocol.md` section 5.2 for the full subscribe/unsubscribe protocol. - -### Server handler - -File: `src/server/actions/query-logs.ts` - -1. Translates CLI flags to a `queryLogs` payload: - - `--tail N` maps to `{ count: N, direction: 'tail' }`. - - `--head N` maps to `{ count: N, direction: 'head' }`. - - `--level X,Y` maps to `{ levels: ['X', 'Y'] }`. - - `--all` maps to `{ includeInternal: true }`. -2. Calls `performActionAsync` with the `queryLogs` message. -3. Awaits the `logsResult` response. -4. Formats entries for display: `{timestamp} [{level}] {body}` or JSON objects. -5. For `--follow`: sends `subscribe { events: ['logPush'] }`, then pipes incoming `logPush` push messages through the level filter and internal-message filter, printing each entry to stdout as it arrives. On Ctrl+C (SIGINT), sends `unsubscribe { events: ['logPush'] }` and exits. Push messages are delivered via WebSocket push from the plugin through the bridge host (see `07-bridge-network.md` section 5.3). - -### Plugin handler - -File: `templates/studio-bridge-plugin/src/Actions/LogAction.lua` - -1. Reads from the ring buffer (capacity: 1000 entries, maintained by `LogBuffer.lua`). -2. Applies `direction`: `tail` slices from the end, `head` slices from the start. -3. Applies `count`: limits the number of returned entries. -4. Applies `levels` filter: only includes entries matching the requested levels. -5. Applies `includeInternal`: if false, filters out entries whose body starts with `[StudioBridge]`. -6. Sends `logsResult` with the filtered entries, total buffer count, and buffer capacity. - -The ring buffer is populated by hooking `LogService.MessageOut` when the plugin loads. Each entry stores `{ level, body, timestamp }` where `timestamp` is `os.clock() * 1000` relative to plugin connection time. - -### Error cases - -| Condition | Error code | Message | -|-----------|-----------|---------| -| Plugin does not support `queryLogs` | `CAPABILITY_NOT_SUPPORTED` | `This Studio session does not support log queries. Update the studio-bridge plugin.` | -| Timeout | `TIMEOUT` | `Log query timed out after 10 seconds.` | -| Both `--tail` and `--head` specified | (CLI validation) | `Cannot use --tail and --head together.` | -| `--follow` with `--head` or `--tail` | (CLI validation) | `Cannot use --follow with --tail or --head.` | - -### Timeout - -10 seconds for `queryLogs`. No timeout for `--follow` mode (runs until interrupted). - -### Retry safety - -**Safe to retry.** `queryLogs` is a read-only query with no side effects. Retrying after a timeout is always safe. Note that the log buffer contents may differ between attempts (new entries added, old entries evicted). - -### Return type - -```typescript -interface LogsResult { - entries: Array<{ - level: OutputLevel; - body: string; - timestamp: number; // monotonic ms since plugin connection - }>; - total: number; - bufferCapacity: number; -} -``` - ---- - -## 6. query -- Query DataModel - -**Summary**: Inspect instances, properties, attributes, children, and services in the Roblox DataModel using dot-path expressions. - -### CLI - -**Command**: `studio-bridge query [session-id]` - -| Positional | Type | Required | Description | -|------------|------|----------|-------------| -| `expression` | string | yes | Dot-separated instance path (e.g., `Workspace.SpawnLocation`) | - -| Flag | Alias | Type | Default | Description | -|------|-------|------|---------|-------------| -| `--session` | `-s` | string | (auto) | Target session ID | -| `--children` | | boolean | `false` | List immediate children instead of the instance itself | -| `--descendants` | | boolean | `false` | List all descendants as a tree | -| `--properties` | | string | (default set) | Comma-separated property names to include | -| `--attributes` | | boolean | `false` | Include all attributes | -| `--depth` | | number | `1` | Max depth for `--descendants` | -| `--json` | | boolean | `true` | Output as JSON (default) | -| `--pretty` | | boolean | `true` | Pretty-print JSON (default; `--no-pretty` for compact) | - -The CLI prepends `game.` to the expression before sending the wire message, unless the expression already starts with `game.`. This keeps user-facing paths ergonomic (`Workspace.SpawnLocation`) while the wire protocol is unambiguous (`game.Workspace.SpawnLocation`). - -**Example**: - -``` -$ studio-bridge query Workspace.SpawnLocation -{ - "name": "SpawnLocation", - "className": "SpawnLocation", - "path": "game.Workspace.SpawnLocation", - "childCount": 0, - "properties": { - "Position": { "type": "Vector3", "value": [0, 4, 0] }, - "Anchored": true, - "Duration": 0 - }, - "attributes": {} -} -``` - -``` -$ studio-bridge query Workspace --children -[ - { "name": "Camera", "className": "Camera" }, - { "name": "Terrain", "className": "Terrain" }, - { "name": "SpawnLocation", "className": "SpawnLocation" } -] -``` - -``` -$ studio-bridge query Workspace.SpawnLocation --properties Position,Size,Anchored -{ - "name": "SpawnLocation", - "className": "SpawnLocation", - "path": "game.Workspace.SpawnLocation", - "childCount": 0, - "properties": { - "Position": { "type": "Vector3", "value": [0, 4, 0] }, - "Size": { "type": "Vector3", "value": [8, 1, 8] }, - "Anchored": true - }, - "attributes": {} -} -``` - -``` -$ studio-bridge query --services -[ - { "name": "Workspace", "className": "Workspace" }, - { "name": "ReplicatedStorage", "className": "ReplicatedStorage" }, - { "name": "ServerScriptService", "className": "ServerScriptService" }, - ... -] -``` - -### Terminal - -**Dot-command**: `.query ` - -Accepts the expression as a positional argument. Does not support flags; always returns the default property set in pretty-printed JSON. - -``` -> .query Workspace.SpawnLocation -{ - "name": "SpawnLocation", - "className": "SpawnLocation", - ... -} -``` - -### MCP - -**Tool name**: `studio_query` - -**Input schema**: -```typescript -interface StudioQueryInput { - sessionId?: string; - path: string; // dot-separated, without 'game.' prefix - depth?: number; // default: 0 - properties?: string[]; // specific property names - includeAttributes?: boolean; // default: false - children?: boolean; // default: false (list children instead of instance) - listServices?: boolean; // default: false -} -``` - -**Output schema**: -```typescript -interface StudioQueryOutput { - instance: DataModelInstance; -} - -// or, when children/listServices mode: -interface StudioQueryChildrenOutput { - children: Array<{ name: string; className: string; path: string }>; -} -``` - -### Protocol - -**Request**: `queryDataModel` (server to plugin) -```json -{ - "type": "queryDataModel", "sessionId": "...", "requestId": "req-004", - "payload": { - "path": "game.Workspace.SpawnLocation", - "depth": 0, - "properties": ["Position", "Anchored", "Size"], - "includeAttributes": false - } -} -``` - -**Response**: `dataModelResult` (plugin to server) -```json -{ - "type": "dataModelResult", "sessionId": "...", "requestId": "req-004", - "payload": { - "instance": { - "name": "SpawnLocation", - "className": "SpawnLocation", - "path": "game.Workspace.SpawnLocation", - "properties": { - "Position": { "type": "Vector3", "value": [0, 4, 0] }, - "Anchored": true, - "Size": { "type": "Vector3", "value": [8, 1, 8] } - }, - "attributes": {}, - "childCount": 0 - } - } -} -``` - -For `--children` mode: the CLI sets `depth: 1` and the server extracts the `children` array from the result. For `--descendants`: the CLI sets `depth` to the `--depth` flag value. For `--services`: the CLI sets `listServices: true` and omits the `path`. - -### Path format - -Paths are **dot-separated**, matching Roblox convention. All paths on the wire start from `game` (the DataModel root). - -**Examples**: -- `game.Workspace` -- the Workspace service -- `game.Workspace.Part1` -- a named child of Workspace -- `game.Workspace.Part1.Position` -- a property on Part1 (the plugin resolves up to the instance, then reads the property) -- `game.ReplicatedStorage.Modules.MyModule` -- nested path through multiple levels -- `game.StarterPlayer.StarterPlayerScripts` -- service child - -**Path resolution algorithm** (plugin side): -1. Split the path on `.` to get segments: `["game", "Workspace", "SpawnLocation"]`. -2. Start at `game` (the DataModel root). Skip the first segment (which must be `"game"`). -3. For each subsequent segment, call `current:FindFirstChild(segment)`. -4. If `FindFirstChild` returns `nil` at any point, return an `INSTANCE_NOT_FOUND` error with `resolvedTo` (the dot-path of the last successful instance) and `failedSegment` (the segment that failed). -5. The final resolved instance is the target for property reads, child enumeration, etc. - -**CLI path translation**: The CLI accepts user-facing paths without the `game.` prefix (e.g., `Workspace.SpawnLocation`). The CLI prepends `game.` before sending the `queryDataModel` message. If the user explicitly includes `game.`, the CLI does not double-prefix. - -**Edge case -- instance names containing dots**: Instance names containing literal dots (e.g., a Part named `"my.part"`) are rare in practice. The current path format does not support escaping dots. If an instance name contains a dot, `FindFirstChild` will fail to resolve it because the dot is treated as a path separator. This is a known limitation. Implementers may choose to document this as unsupported, or add escaping support (e.g., backslash-dot `\.`) in a future protocol version. - -### SerializedValue format - -Property and attribute values are serialized for JSON transport using the `SerializedValue` type. Primitive types (string, number, boolean) are passed as bare JSON values without wrapping. Complex Roblox types use a `type` discriminant field and a flat `value` array containing the numeric components. - -**All supported types with wire examples**: - -```json -// Primitives -- passed as-is, no wrapping -"hello" -42 -true - -// Vector3 -- [x, y, z] -{ "type": "Vector3", "value": [1, 2, 3] } - -// Vector2 -- [x, y] -{ "type": "Vector2", "value": [1, 2] } - -// CFrame -- [posX, posY, posZ, r00, r01, r02, r10, r11, r12, r20, r21, r22] -// Position xyz followed by 9 rotation matrix components (row-major) -{ "type": "CFrame", "value": [1, 2, 3, 1, 0, 0, 0, 1, 0, 0, 0, 1] } - -// Color3 -- [r, g, b] in 0-1 range -{ "type": "Color3", "value": [0.5, 0.2, 1.0] } - -// UDim2 -- [xScale, xOffset, yScale, yOffset] -{ "type": "UDim2", "value": [0.5, 100, 0.5, 200] } - -// UDim -- [scale, offset] -{ "type": "UDim", "value": [0.5, 100] } - -// BrickColor -- name string + numeric ID -{ "type": "BrickColor", "name": "Bright red", "value": 21 } - -// EnumItem -- enum type name, item name, numeric value -{ "type": "EnumItem", "enum": "Material", "name": "Plastic", "value": 256 } - -// Instance reference -- className and dot-separated path -{ "type": "Instance", "className": "Part", "path": "game.Workspace.Part1" } - -// Unsupported type -- fallback for types we cannot serialize -{ "type": "Unsupported", "typeName": "Ray", "toString": "Ray(0, 0, 0, 1, 0, 0)" } -``` - -**TypeScript type definition** (see `01-protocol.md` section 8 for the full definition): - -```typescript -type SerializedValue = - | string | number | boolean | null - | { type: 'Vector3'; value: [number, number, number] } - | { type: 'Vector2'; value: [number, number] } - | { type: 'CFrame'; value: [number, number, number, number, number, number, number, number, number, number, number, number] } - | { type: 'Color3'; value: [number, number, number] } - | { type: 'UDim2'; value: [number, number, number, number] } - | { type: 'UDim'; value: [number, number] } - | { type: 'BrickColor'; name: string; value: number } - | { type: 'EnumItem'; enum: string; name: string; value: number } - | { type: 'Instance'; className: string; path: string } - | { type: 'Unsupported'; typeName: string; toString: string }; -``` - -The `type` discriminant field allows the receiver to reconstruct or display Roblox-specific types. The flat `value` array format is compact and easy to destructure. The `Unsupported` variant ensures the plugin never fails to serialize a property -- it always produces a string representation as a last resort. - -### Server handler - -File: `src/server/actions/query-datamodel.ts` - -1. Translates CLI expression to wire path: - - If expression starts with `game.`, use as-is. - - Otherwise, prepend `game.` (e.g., `Workspace.SpawnLocation` becomes `game.Workspace.SpawnLocation`). -2. Builds the `queryDataModel` payload from CLI flags: - - `--children` sets `depth: 1` (server extracts children from result). - - `--descendants --depth N` sets `depth: N`. - - `--properties X,Y` sets `properties: ['X', 'Y']`. - - `--attributes` sets `includeAttributes: true`. - - `--services` sets `listServices: true`. -3. Calls `performActionAsync` with the message. -4. Awaits `dataModelResult`. -5. Formats output: - - Default: pretty-printed JSON of the full instance. - - `--children`: extracts `instance.children` and prints as array of `{ name, className }`. - - `--no-pretty`: compact single-line JSON. - -### Plugin handler - -File: `templates/studio-bridge-plugin/src/Actions/DataModelAction.lua` - -1. If `listServices` is true: iterates `game:GetChildren()`, collects `{ name, className, path }` for each service, returns as children of a synthetic root instance. -2. Otherwise: resolves `path` by splitting on `.` and calling `FindFirstChild` at each segment starting from `game`. -3. If any segment fails to resolve, sends an `error` with code `INSTANCE_NOT_FOUND`, including `resolvedTo` (last successful path) and `failedSegment`. -4. Reads the requested `properties` from the resolved instance. For each property: - - Primitive types (string, number, boolean) pass through as bare JSON values. - - Roblox types (Vector3, CFrame, Color3, UDim2, etc.) are serialized using `ValueSerializer.lua` with the `type` discriminant and flat `value` arrays (see the SerializedValue format section above). - - If a property does not exist on the instance, sends an `error` with code `PROPERTY_NOT_FOUND`. -5. If `includeAttributes` is true, reads all attributes via `instance:GetAttributes()`. -6. If `depth > 0`, recursively processes children up to the requested depth. -7. Sends `dataModelResult` with the assembled `DataModelInstance`. - -### Error cases - -| Condition | Error code | Message | -|-----------|-----------|---------| -| Instance path does not resolve | `INSTANCE_NOT_FOUND` | `No instance found at path: game.Workspace.NonExistent` | -| Property does not exist | `PROPERTY_NOT_FOUND` | `Property 'Foo' does not exist on SpawnLocation (SpawnLocation)` | -| Plugin does not support `queryDataModel` | `CAPABILITY_NOT_SUPPORTED` | `This Studio session does not support DataModel queries. Update the studio-bridge plugin.` | -| Timeout | `TIMEOUT` | `DataModel query timed out after 10 seconds.` | -| Expression is empty | (CLI validation) | `Expression is required. Example: studio-bridge query Workspace.SpawnLocation` | - -### Timeout - -10 seconds (per 01-protocol.md). - -### Retry safety - -**Safe to retry.** `queryDataModel` is a read-only query with no side effects. Retrying after a timeout is always safe, though DataModel state may differ between attempts. - -### Return type - -```typescript -interface DataModelResult { - instance: DataModelInstance; -} - -interface DataModelInstance { - name: string; - className: string; - path: string; // full dot-separated path from game (e.g. "game.Workspace.SpawnLocation") - properties: Record; - attributes: Record; - childCount: number; - children?: DataModelInstance[]; // present only if depth > 0 was requested -} -``` - -See the `SerializedValue` format section above for the full type definition and wire examples, and `01-protocol.md` section 8 for the canonical TypeScript types. - ---- - -## 7. exec -- Execute Luau code - -**Summary**: Execute an inline Luau string in a Studio session. Enhanced from the existing command to support persistent sessions. - -### CLI - -**Command**: `studio-bridge exec [session-id]` - -| Positional | Type | Required | Description | -|------------|------|----------|-------------| -| `code` | string | yes | Luau code to execute | - -| Flag | Alias | Type | Default | Description | -|------|-------|------|---------|-------------| -| `--session` | `-s` | string | (auto) | Target session ID | -| `--json` | | boolean | `false` | Output result as JSON `{ success, logs }` | - -Global flags (`--verbose`, `--place`, `--timeout`, `--logs`, `--context`) remain available. - -**Session resolution** (applies to `exec`, `run`, and `terminal`): -1. If `--session` is provided, use that session directly. -2. If no `--session`, group sessions by `instanceId`: - a. **1 instance, Edit mode**: auto-select the Edit session (zero-config). - b. **1 instance, Play mode**: default to the Server context for mutating commands (`exec`, `run`), or Edit context for read-only commands (`state`, `logs`, `query`, `screenshot`). Use `--context` to override. See the "Context default by command category" table below for the full rule. - c. **0 instances**: fall back to the current behavior: launch a new Studio instance. - d. **N instances**: error with list, require `--session` or instance selection. -3. If `--context` is provided alongside a single instance, select the session matching that context within the instance. - -**Example**: - -``` -$ studio-bridge exec 'print("Hello from Studio")' -Hello from Studio -``` - -``` -$ studio-bridge exec --session a1b2c3d4 'print(workspace:GetChildren())' -Camera Terrain SpawnLocation -``` - -``` -$ studio-bridge exec --json 'print("hi"); error("oops")' -{ - "success": false, - "error": "Script:2: oops", - "logs": [ - { "level": "Print", "body": "hi" } - ] -} -``` - -### Terminal - -The terminal REPL is the primary exec surface. Any input that is not a dot-command is treated as Luau code and executed via the same path. - -``` -> print("Hello") -Hello - -> workspace:GetChildren() -{Camera, Terrain, SpawnLocation} -``` - -### MCP - -**Tool name**: `studio_exec` - -**Input schema**: -```typescript -interface StudioExecInput { - sessionId?: string; - script: string; -} -``` - -**Output schema**: -```typescript -interface StudioExecOutput { - success: boolean; - error?: string; - logs: Array<{ - level: OutputLevel; - body: string; - }>; -} -``` - -### Protocol - -**Request**: `execute` (server to plugin) -```json -{ - "type": "execute", "sessionId": "...", "requestId": "req-005", - "payload": { "script": "print('Hello from Studio')" } -} -``` - -**Intermediate**: `output` (plugin to server, zero or more) -```json -{ - "type": "output", "sessionId": "...", - "payload": { "messages": [{ "level": "Print", "body": "Hello from Studio" }] } -} -``` - -**Response**: `scriptComplete` (plugin to server) -```json -{ - "type": "scriptComplete", "sessionId": "...", "requestId": "req-005", - "payload": { "success": true } -} -``` - -The `requestId` on `execute` and `scriptComplete` is optional for backward compatibility with v1 plugins. When present, it enables concurrent request correlation. - -### Server handler - -The existing `executeAsync` method in `StudioBridgeServer` handles this. Changes for persistent sessions: - -1. If connected to a persistent session, sends `execute` with a `requestId`. -2. Collects `output` messages into a log array. -3. Awaits `scriptComplete` with the matching `requestId`. -4. Returns `StudioBridgeResult` with success status, error string, and collected logs. - -When no persistent session is available and no `--session` is provided, the server falls back to the existing flow: launch Studio, inject temporary plugin, execute, tear down. - -### Plugin handler - -File: `templates/studio-bridge-plugin/src/Actions/ExecuteAction.lua` - -1. Receives `execute` message. -2. Calls `loadstring(script)`. If this fails (syntax error), sends `scriptComplete` with `success: false` and the error. -3. Executes the compiled function. Output from `print()` is captured via the log hook and sent as `output` messages. -4. If the function throws, captures the error and sends `scriptComplete` with `success: false`. -5. On success, sends `scriptComplete` with `success: true`. -6. Echoes `requestId` on `scriptComplete` if it was present on the `execute` message. -7. If a second `execute` arrives while the first is in progress, it is queued and executed after the first completes. - -### Error cases - -| Condition | Error code | Message | -|-----------|-----------|---------| -| Syntax error in code | `SCRIPT_LOAD_ERROR` | `Script error: {loadstring error message}` | -| Runtime error in code | `SCRIPT_RUNTIME_ERROR` | `Script error: {error message}` | -| Plugin busy with another execute | `BUSY` | `Plugin is busy executing another script. Please wait.` | -| Timeout | `TIMEOUT` | `Script execution timed out after {timeout} seconds.` | -| Multiple sessions, none specified | (CLI-level) | `Multiple sessions available. Use --session or --instance to specify one:\n{session list}` | - -### Timeout - -120 seconds default (configurable via `--timeout`). - -### Retry safety - -**NOT safe to retry blindly.** `exec` runs arbitrary Luau code that may have side effects (creating instances, modifying properties, firing events). A timed-out `exec` may still be running in the plugin -- the timeout is client-side only. The caller must understand the script's idempotency before deciding whether to retry. The MCP adapter should NOT auto-retry `exec` or `run`. - -### Return type - -```typescript -interface ExecResult { - success: boolean; - error?: string; - logs: Array<{ - level: OutputLevel; - body: string; - }>; -} -``` - ---- - -## 8. run -- Run Luau file - -**Summary**: Execute a Luau script file in a Studio session. Reads the file from disk and delegates to the same execution path as `exec`. - -### CLI - -**Command**: `studio-bridge run [session-id]` - -| Positional | Type | Required | Description | -|------------|------|----------|-------------| -| `file` | string | yes | Path to a `.lua` or `.luau` file | - -| Flag | Alias | Type | Default | Description | -|------|-------|------|---------|-------------| -| `--session` | `-s` | string | (auto) | Target session ID | -| `--json` | | boolean | `false` | Output result as JSON | - -Global flags remain available. - -**Example**: - -``` -$ studio-bridge run ./scripts/setup.lua -Setting up workspace... -Done. -``` - -``` -$ studio-bridge run --session a1b2c3d4 ./scripts/test.lua -Running tests... -All 12 tests passed. -``` - -### Terminal - -**Dot-command**: `.run ` - -Already exists in the current terminal mode. Reads the file and executes its contents. - -``` -> .run ./scripts/setup.lua -Setting up workspace... -Done. -``` - -### MCP - -No dedicated MCP tool. Agents should read the file themselves and pass the content to `studio_exec`. - -### Protocol - -Same as `exec`. The CLI reads the file content and sends it as the `script` field in the `execute` message. The plugin does not know or care whether the script came from a file or inline. - -### Server handler - -1. Reads the file from disk via `fs.readFile`. -2. Delegates to the same `executeAsync` path used by `exec`. - -### Plugin handler - -Same as `exec`. The plugin receives an `execute` message with the script content. - -### Error cases - -| Condition | Error code | Message | -|-----------|-----------|---------| -| File not found | (CLI-level) | `Could not read script file: {path}` | -| File not readable | (CLI-level) | `Could not read script file: {path}: {os error}` | -| All `exec` errors | (same as exec) | (same as exec) | - -### Timeout - -120 seconds default (configurable via `--timeout`). - -### Retry safety - -**NOT safe to retry blindly.** Same as `exec` -- `run` delegates to the same execution path. See `exec` retry safety notes. - -### Return type - -Same as `exec`: - -```typescript -interface ExecResult { - success: boolean; - error?: string; - logs: Array<{ - level: OutputLevel; - body: string; - }>; -} -``` - ---- - -## 9. install-plugin -- Install persistent plugin - -**Summary**: Build and install the persistent studio-bridge plugin into Roblox Studio's plugins folder. One-time setup that enables all persistent session features. - -### CLI - -**Command**: `studio-bridge install-plugin` - -| Flag | Alias | Type | Default | Description | -|------|-------|------|---------|-------------| -| `--force` | | boolean | `false` | Overwrite existing plugin without prompting | - -**Example**: - -``` -$ studio-bridge install-plugin -Building persistent plugin... -Plugin installed to /Users/dev/Library/Application Support/Roblox/Plugins/StudioBridgePlugin.rbxm -Restart Studio for the plugin to take effect. -``` - -``` -$ studio-bridge install-plugin -Plugin already installed at /Users/dev/Library/Application Support/Roblox/Plugins/StudioBridgePlugin.rbxm -Use --force to overwrite. -``` - -``` -$ studio-bridge install-plugin --force -Building persistent plugin... -Plugin updated at /Users/dev/Library/Application Support/Roblox/Plugins/StudioBridgePlugin.rbxm -Restart Studio for changes to take effect. -``` - -### Terminal - -No dot-command. Plugin installation is a one-time setup step, not something done during an interactive session. - -### MCP - -No MCP tool. Plugin installation requires user action (restarting Studio) and is not suitable for automated agent use. - -### Protocol - -No wire protocol involvement. This is a local file operation. - -### Server handler - -File: `src/plugin/persistent-plugin-installer.ts` - -1. Locates the Studio plugins folder using `findPluginsFolder()` from `studio-process-manager.ts`. -2. Builds the persistent plugin template via Rojo (`rojo build`). -3. Copies the resulting `.rbxm` to the plugins folder. -4. If the file already exists and `--force` is not set, prompts the user or prints the "already installed" message. - -### Plugin handler - -None. This action does not interact with a running plugin. - -### Error cases - -| Condition | Message | -|-----------|---------| -| Studio plugins folder not found | `Could not find Roblox Studio plugins folder. Is Studio installed?` | -| Rojo not installed | `Rojo is required to build the plugin. Run 'aftman install' to install it.` | -| Rojo build failed | `Failed to build plugin: {rojo error}` | -| Write permission denied | `Cannot write to {path}: Permission denied` | - -### Timeout - -Not applicable (local build and copy). - -### Return type - -```typescript -interface InstallPluginResult { - installed: boolean; - path: string; - updated: boolean; // true if overwrote an existing plugin -} -``` - ---- - -## 10. launch -- Launch new Studio session - -**Summary**: Explicitly launch a new Roblox Studio instance with the studio-bridge plugin active. This preserves the current `exec` behavior as a dedicated command, useful when no sessions exist or when a fresh session is needed. - -### CLI - -**Command**: `studio-bridge launch [place]` - -| Positional | Type | Required | Description | -|------------|------|----------|-------------| -| `place` | string | no | Path to `.rbxl` place file. If omitted, uses a default empty place. | - -| Flag | Alias | Type | Default | Description | -|------|-------|------|---------|-------------| -| `--wait` | | boolean | `true` | Wait for the plugin to connect before returning | -| `--json` | | boolean | `false` | Output session info as JSON when connected | - -**Example**: - -``` -$ studio-bridge launch ./MyGame.rbxl -Launching Studio with MyGame.rbxl... -Session a1b2c3d4-e5f6-7890-abcd-ef1234567890 connected. -``` - -``` -$ studio-bridge launch --json -{ - "sessionId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - "placeName": "Empty", - "state": "Edit" -} -``` - -When `exec` or `run` is called with no sessions available, this is the flow that executes internally. `launch` makes it explicit as a standalone command. - -### Terminal - -No dot-command. Launching Studio is the entry point to a session, not an action within one. - -### MCP - -No dedicated MCP tool. Agents discover existing sessions via `studio_sessions`. If no sessions exist, the agent should inform the user to launch Studio. - -### Protocol - -Uses the existing `hello`/`welcome` (or `register`/`welcome`) handshake. If the persistent plugin is installed, the server starts and waits for the plugin to discover it. If not, the server injects the temporary plugin as in the current implementation. - -### Server handler - -1. Creates a `StudioBridgeServer` with the specified place path. -2. Calls `startAsync()`, which: - - Starts the WebSocket server on a random port. - - Registers in the session registry. - - If persistent plugin is installed: launches Studio and waits for the plugin to connect. - - If not: builds and injects the temporary plugin, launches Studio, waits for handshake. -3. If `--wait` is true (default), blocks until the handshake completes, then prints session info. -4. If `--wait` is false, returns immediately after starting the server (useful for scripts that launch Studio in the background). - -### Plugin handler - -Standard handshake (same as any other session connection). - -### Error cases - -| Condition | Message | -|-----------|---------| -| Studio not installed | `Roblox Studio not found. Is it installed?` | -| Place file not found | `Place file not found: {path}` | -| Plugin handshake timeout | `Studio launched but plugin did not connect within {timeout}ms. Check Studio's output for errors.` | -| Port allocation failed | `Could not allocate a port for the WebSocket server.` | - -### Timeout - -Inherits global `--timeout` (default: 30000ms) for plugin handshake. - -### Return type - -```typescript -interface LaunchResult { - sessionId: string; - placeName: string; - state: StudioState; -} -``` - ---- - -## Context default by command category - -When a Studio instance is in Play mode and no `--context` flag is provided, the default context depends on the command category: - -| Category | Commands | Default context | Rationale | -|----------|----------|----------------|-----------| -| Read-only | `state`, `logs`, `query`, `screenshot` | `edit` | The Edit context always exists and provides a stable view. | -| Mutating | `exec`, `run` | `server` | Script execution and file runs target the server VM, which is the most common debugging target during Play mode. | -| Non-session | `sessions`, `install-plugin`, `serve` | N/A | These commands do not target a session context. | - -This table is the single source of truth. The `resolveSession` utility applies these defaults based on the `CommandDefinition`'s `defaultContext` field (or falls back to `'edit'` when unset). - ---- - -## Session resolution logic - -This section documents the shared heuristic used by all session-targeting commands (`state`, `screenshot`, `logs`, `query`, `exec`, `run`). It is implemented once in a shared utility and referenced by each command handler. - -### Algorithm (instance-aware) - -A single Studio instance produces 1-3 sessions that share an `instanceId`. In Edit mode there is one session (`context: 'edit'`). In Play mode the instance may produce up to three sessions (`context: 'edit'`, `context: 'server'`, `context: 'client'`). The resolution algorithm groups sessions by instance before selecting: - -``` -1. If --session is provided: - a. Look up via bridge connection. - b. If found and connected: use it. - c. If found but disconnected: error "Session exists but plugin is not connected." - d. If not found: error "Session not found." - -2. If [session-id] positional argument is provided: - Same as step 1. - -3. If neither --session nor positional is provided: - a. List all connected sessions from the bridge. - b. Group sessions by instanceId. - c. If zero instances: fall back to launch behavior (for exec/run) or error (for state/screenshot/logs/query). - d. If exactly one instance: - i. If --context is provided: select the session matching that context within the instance. - Error if no session matches (e.g., --context server when Studio is in Edit mode). - ii. If --context is NOT provided and instance is in Edit mode (1 session): auto-select it. - iii. If --context is NOT provided and instance is in Play mode (2-3 sessions): - default to the Edit context (safe default -- the Edit context always exists). - e. If multiple instances: - i. Error: "Multiple Studio instances connected. Use --session to specify one:" + grouped list. -``` - -### Context selection summary - -| Instance state | `--context` flag | Behavior | -|---------------|-----------------|----------| -| Edit mode (1 session) | not set | Auto-select Edit session | -| Edit mode (1 session) | `edit` | Select Edit session | -| Edit mode (1 session) | `server` or `client` | Error: "No server/client context. Studio is in Edit mode." | -| Play mode (2-3 sessions) | not set | Default to Edit context | -| Play mode (2-3 sessions) | `edit` | Select Edit session | -| Play mode (2-3 sessions) | `server` | Select Server session | -| Play mode (2-3 sessions) | `client` | Select Client session | - -### Zero-instance behavior by command - -| Command | When zero instances exist | -|---------|-------------------------| -| `exec` | Launch a new Studio session, then execute (current behavior preserved) | -| `run` | Launch a new Studio session, then execute (current behavior preserved) | -| `terminal` | Launch a new Studio session, then enter REPL (current behavior preserved) | -| `state` | Error: "No active sessions." | -| `screenshot` | Error: "No active sessions." | -| `logs` | Error: "No active sessions." | -| `query` | Error: "No active sessions." | - -### Multiple-instance behavior - -| Instances | `--session` flag | `--instance` flag | Behavior | -|-----------|-----------------|-------------------|----------| -| N > 1 | not set | not set | Error: "Multiple Studio instances connected. Use --session or --instance to specify one:" + grouped session list | -| N > 1 | set | any | Use specified session directly | -| N > 1 | not set | set | Select that instance, apply context selection | - ---- - -## Timeout summary - -| Action | Protocol message | Default timeout | -|--------|-----------------|----------------| -| state | `queryState` | 5s | -| screenshot | `captureScreenshot` | 15s | -| logs | `queryLogs` | 10s | -| query | `queryDataModel` | 10s | -| exec | `execute` | 120s | -| run | `execute` | 120s | -| subscribe | `subscribe` | 5s | -| unsubscribe | `unsubscribe` | 5s | - -All timeouts are server-side. The server rejects the pending promise after the timeout period. No cancellation message is sent to the plugin. - ---- - -## Error code reference - -This is the complete mapping from error codes (defined in `01-protocol.md`) to the actions that can produce them. - -| Error code | Actions | Meaning | -|-----------|---------|---------| -| `UNKNOWN_REQUEST` | any | Plugin received a message type it does not recognize | -| `INVALID_PAYLOAD` | any | Message payload failed validation | -| `TIMEOUT` | state, screenshot, logs, query, exec | Operation timed out (server-side) | -| `CAPABILITY_NOT_SUPPORTED` | state, screenshot, logs, query | Plugin does not support the requested capability | -| `INSTANCE_NOT_FOUND` | query | Dot-path did not resolve to an instance | -| `PROPERTY_NOT_FOUND` | query | Requested property does not exist on the instance | -| `SCREENSHOT_FAILED` | screenshot | CaptureService call failed | -| `SCRIPT_LOAD_ERROR` | exec, run | `loadstring` failed (syntax error) | -| `SCRIPT_RUNTIME_ERROR` | exec, run | Script threw during execution | -| `BUSY` | exec, run | Plugin is already executing another script | -| `SESSION_MISMATCH` | any | Session ID in message does not match the connection | -| `INTERNAL_ERROR` | any | Unexpected error inside the plugin | diff --git a/studio-bridge/plans/tech-specs/05-split-server.md b/studio-bridge/plans/tech-specs/05-split-server.md deleted file mode 100644 index 64231693e7..0000000000 --- a/studio-bridge/plans/tech-specs/05-split-server.md +++ /dev/null @@ -1,367 +0,0 @@ -# Split Server Mode: Technical Specification - -This document describes the split-server architecture for running studio-bridge across a devcontainer boundary. It is the companion document referenced from `00-overview.md` section 7.4 ("Split-server mode, devcontainer port forwarding"). - -## 1. Consumer Invariant - -**The split server is an operational concern, not an API concern.** Consumer code (commands, MCP tools, terminal) never imports from server-specific modules. They use `BridgeConnection` which auto-discovers the host regardless of how it was started. - -The `serve` command is just one way to start a bridge host. From a consumer's perspective: - -- `BridgeConnection` works identically whether the host is implicit (first CLI process) or explicit (`studio-bridge serve`) -- No code outside `src/bridge/internal/` knows or cares whether the host is a dedicated process -- The same `BridgeSession` methods produce the same results regardless of how the host was started -- Session multiplicity is transparent: a Studio instance in Play mode produces 3 sessions (edit/client/server contexts), but this is the same whether the host is local or split - -This is a direct consequence of the API boundary described in `00-overview.md` section 1.1. Any change that would require a consumer to be aware of whether the host is implicit or explicit is a design violation. - -## 2. Problem - -AI coding tools (Claude Code, Cursor, GitHub Copilot) increasingly run inside devcontainers -- Docker-based environments with full Linux toolchains. Roblox Studio only runs on Windows and macOS. This creates a gap: - -- The CLI, MCP server, and build tools run inside the devcontainer -- Studio runs on the host OS -- There is no way for the devcontainer to communicate with Studio - -The default bridge host behavior (first CLI process to bind port 38741 becomes the host) does not work when the CLI and Studio are on different machines. The CLI inside the devcontainer cannot launch Studio, inject plugins, or accept WebSocket connections from plugins running on the host OS. - -## 3. Architecture - -Split-server mode separates the bridge host and CLI into two processes on two machines. The bridge host runs on the machine with Studio; the CLI runs in the devcontainer. Port forwarding bridges the gap. - -### Topology: implicit host vs. explicit host - -``` -Option A: Implicit host (default -- single machine) -┌─────────────────────────────┐ -│ CLI process (first started) │ -│ ┌─────────────┐ │ -│ │ Bridge Host │<── Studio plugins connect via /plugin -│ └─────────────┘ │ -│ + CLI commands │ -└─────────────────────────────┘ - -Option B: Explicit host (studio-bridge serve -- devcontainer workflow) -┌─────────────────────────────┐ -│ studio-bridge serve │ -│ ┌─────────────┐ │ -│ │ Bridge Host │<── Studio plugins connect via /plugin -│ └─────────────┘ │ -└──────────────┬──────────────┘ - │ port 38741 (forwarded into container) -┌──────────────┴──────────────┐ -│ CLI process (client mode) │ -│ CLI commands, MCP, terminal │ -└─────────────────────────────┘ -``` - -In both cases, CLI commands use `BridgeConnection` identically. The consumer code is the same. The only difference is where the bridge host process runs and how it was started. - -### Detailed devcontainer layout - -``` -┌──────────────────────────────────┐ ┌────────────────────────────────────┐ -│ Devcontainer │ │ Host OS │ -│ │ │ │ -│ ┌────────────────────────┐ │ │ ┌──────────────────────────────┐ │ -│ │ studio-bridge exec │ │ │ │ studio-bridge serve │ │ -│ │ studio-bridge mcp │ │ │ │ (bridge host on 38741) │ │ -│ │ nevermore test │ │ TCP │ │ │ │ -│ │ ├──────┼──────┤ │ Bridge Host (internal) │ │ -│ │ (bridge client) │ │ │ │ - plugin connections │ │ -│ └────────────────────────┘ │ │ │ - client connections │ │ -│ │ │ │ - session tracking │ │ -│ │ │ │ │ │ -│ │ │ │ WebSocket <-> Plugin(s) │ │ -│ │ │ └──────────┬───────────────────┘ │ -│ │ │ │ │ -│ │ │ ┌────────v──────────┐ │ -│ │ │ │ Roblox Studio │ │ -│ │ │ │ + Persistent │ │ -│ │ │ │ Plugin │ │ -│ │ │ └───────────────────┘ │ -│ │ │ │ -└──────────────────────────────────┘ └────────────────────────────────────┘ - ^ ^ - │ Port forwarding (38741) │ - └────────────────────────────────────────┘ -``` - -**Bridge Host**: Runs on the machine with Studio. This is the same `bridge-host.ts` from `src/bridge/internal/` -- the `serve` command just instantiates and runs it. Hosts the WebSocket server, manages plugin connections, handles the session tracker. A single Studio instance may produce 1-3 sessions (one per context: `edit`, and optionally `client`/`server` during Play mode), all grouped by `instanceId`. - -**Bridge Client**: Runs in the devcontainer. This is the same `bridge-client.ts` from `src/bridge/internal/` -- `BridgeConnection.connectAsync()` uses it automatically when a host is already running (or when `--remote` is specified). Sends commands to the host over a relayed WebSocket connection. Formats output for the user. - -## 4. `studio-bridge serve` Command - -The `serve` command is a thin CLI wrapper that calls into `src/bridge/internal/bridge-host.ts` directly. It starts a headless bridge host (no terminal UI) that stays alive indefinitely. This is the same bridge host that any CLI process creates when it is the first to bind port 38741 -- the only difference is that `serve` always becomes the host (never a client) and never exits on idle. - -### CLI interface - -``` -studio-bridge serve [options] - -Options: - --port Port to listen on (default: 38741) - --log-level Log verbosity: silent, error, warn, info, debug (default: info) - --json Print structured status to stdout on startup and on events - --timeout Auto-shutdown after idle period with no connections (default: none) -``` - -### How it differs from the implicit host - -| Aspect | Implicit host (first CLI) | Explicit host (`studio-bridge serve`) | -|--------|---------------------------|--------------------------------------| -| How it starts | `BridgeConnection.connectAsync()` binds port, process happened to be first | User runs `studio-bridge serve` explicitly | -| Idle behavior | Exits after 5s grace period when no clients/commands | Stays alive indefinitely (or until `--timeout`) | -| Terminal UI | Yes (if started via `terminal`), No (if `exec`/`run`) | No (headless, logs to stdout) | -| Hand-off on exit | Transfers to a connected client | Transfers to a connected client (same protocol) | -| Port contention | Falls back to client mode if port taken | Errors with clear message if port taken | -| Signal handling | Standard CLI cleanup | SIGTERM/SIGINT trigger graceful shutdown + hand-off | - -### When to use `studio-bridge serve` - -- **Devcontainer workflow**: Studio runs on the host OS, CLI runs in a container. Run `serve` on the host so the container CLI can connect. -- **CI environments**: A long-running bridge host that multiple CI jobs connect to as clients. -- **Shared development server**: A team member runs `serve` on a shared machine; others connect their CLIs as clients. -- **Long-running daemon**: When you want the bridge host to outlive any individual CLI session. - -For local single-machine development, `serve` is unnecessary. The first CLI process becomes the host automatically. - -### Implementation - -The `serve` command is just `BridgeConnection.connectAsync({ keepAlive: true })` with signal handling and status logging: - -```typescript -// src/commands/serve.ts (the command definition) -export const serveCommand: CommandDefinition> = { - name: 'serve', - description: 'Start a dedicated bridge host process', - requiresSession: false, // serve IS the host, it doesn't need a session - - handler: async (input, context) => { - // BridgeConnection with keepAlive prevents idle shutdown. - // The bridge host is created internally by bridge-connection.ts - // via bridge-host.ts from src/bridge/internal/. - const connection = await BridgeConnection.connectAsync({ - port: input.port, - keepAlive: true, - }); - - // Log status - const sessions = await connection.listSessionsAsync(); - return { - data: { port: input.port ?? 38741, sessions }, - summary: `Bridge host listening on port ${input.port ?? 38741}`, - }; - }, -}; -``` - -The actual bridge host logic (WebSocket server, plugin management, client multiplexing, session tracking) all lives in `src/bridge/internal/bridge-host.ts`. The `serve` command does not duplicate or extend that logic. - -### Daemon lifecycle - -1. **Start**: `BridgeConnection.connectAsync({ keepAlive: true })` binds the port and creates a bridge host -2. **Listen**: Accept connections from plugins (`/plugin`) and CLI clients (`/client`) -3. **Run**: Route commands between clients and plugins (standard bridge host behavior) -4. **Status**: If `--json`, print session connect/disconnect events as JSON lines to stdout -5. **Stop**: On SIGTERM/SIGINT, run `disconnectAsync()` which triggers the hand-off protocol (transfer to a connected client, or shut down cleanly) - -### Error on port contention - -Unlike the implicit host (which falls back to client mode on EADDRINUSE), `serve` fails with a clear error if the port is already in use: - -``` -Error: Port 38741 is already in use. -A bridge host is already running. Connect as a client with any studio-bridge command, -or use --port to start on a different port. -``` - -This is intentional: `serve` is an explicit request to BE the host. Silent fallback to client mode would be confusing. - -## 5. File Layout - -The split server has minimal footprint. It follows the same pattern as every other command: one file in `src/commands/`, using existing infrastructure from `src/bridge/internal/`. - -``` -src/ - commands/ - serve.ts serve command definition (like any other command) - bridge/ - internal/ - bridge-host.ts THE bridge host implementation (already exists from Phase 1) - bridge-client.ts THE bridge client implementation (already exists from Phase 1) - environment-detection.ts isDevcontainer(), getDefaultRemoteHost() - bridge-connection.ts Handles remoteHost option, devcontainer auto-detection -``` - -There is no `src/server/` directory for split-server-specific code. The bridge host itself lives in `src/bridge/internal/`. The `serve` command just instantiates and runs it. Environment detection lives alongside the other bridge internals because it is part of the connection logic. - -### Why no separate `src/server/` directory - -The split server does not introduce new abstractions. It is the same bridge host, started a different way. The concerns that might justify a separate directory -- daemonization, PID files, log rotation, auth token management -- are either unnecessary or handled by existing mechanisms: - -- **Daemonization**: Not needed. Run `serve` in a terminal, tmux, systemd, or Docker. The command stays in the foreground. -- **PID files**: Not needed. Port binding IS the lock. Only one process can bind 38741. -- **Log rotation**: Not needed. Stdout goes wherever the user directs it (`serve > bridge.log 2>&1`). -- **Auth tokens**: Not needed for the initial implementation. All connections are localhost or port-forwarded localhost. The bridge host validates plugin connections via session ID (unguessable UUIDv4) and client connections via the `/client` WebSocket path. If auth tokens become necessary later, they would live in `src/bridge/internal/` as part of the transport layer -- still not a separate directory. - -## 6. Client Connection (CLI in Devcontainer) - -### `--remote` CLI flag - -Users can explicitly specify a remote bridge host: - -```bash -# Force remote mode -- connect to bridge host at the specified address -studio-bridge exec --remote localhost:38741 'print("hi")' - -# Force local mode -- disable auto-detection, always try to become host -studio-bridge exec --local 'print("hi")' -``` - -The `--remote` flag sets `remoteHost` on `BridgeConnectionOptions`. When set, `BridgeConnection.connectAsync()` skips the local port-bind attempt and connects directly as a client: - -```typescript -// In BridgeConnectionOptions (from src/bridge/types.ts) -export interface BridgeConnectionOptions { - port?: number; - timeoutMs?: number; - keepAlive?: boolean; - remoteHost?: string; // e.g., 'localhost:38741' -- skip local bind, connect as client -} -``` - -### Devcontainer auto-detection - -When the CLI detects it is running inside a devcontainer, it automatically tries connecting to a remote bridge host before falling back to local mode: - -```typescript -// src/bridge/internal/environment-detection.ts - -export function isDevcontainer(): boolean { - return !!( - process.env.REMOTE_CONTAINERS || - process.env.CODESPACES || - process.env.CONTAINER || - existsSync('/.dockerenv') - ); -} - -export function getDefaultRemoteHost(): string | null { - if (isDevcontainer()) { - return `localhost:${DEFAULT_BRIDGE_PORT}`; - } - return null; -} -``` - -### Decision flow in `BridgeConnection.connectAsync()` - -``` -connectAsync() called - | - +-- remoteHost option provided? - | YES -> connect to host at remoteHost as client - | - +-- isDevcontainer()? - | YES -> try connecting to localhost:38741 - | +-- success -> use as client (bridge host is on host OS, port-forwarded) - | +-- failure -> warn, fall back to local mode - | - +-- NO -> try binding port 38741 - +-- success -> become bridge host - +-- EADDRINUSE -> connect as client to existing host -``` - -This decision flow is entirely within `BridgeConnection`. Consumer code never sees it. The same `BridgeSession` methods work regardless of which path was taken. - -## 7. Port Forwarding - -### VS Code Dev Containers - -VS Code automatically forwards ports from the host to the devcontainer when it detects a listening socket. However, port 38741 may not be auto-detected since the daemon starts independently. - -**Recommended configuration** in `.devcontainer/devcontainer.json`: - -```json -{ - "forwardPorts": [38741], - "portsAttributes": { - "38741": { - "label": "Studio Bridge", - "onAutoForward": "silent" - } - } -} -``` - -### GitHub Codespaces - -Codespaces forwards all ports by default. The bridge host on the host is accessible from within the Codespace at `localhost:38741` when port forwarding is configured. - -**Note**: GitHub Codespaces runs in the cloud, not on the user's local machine. The user must run a tunnel or use VS Code's Remote SSH to bridge between Codespaces and their local machine where Studio runs. This is a more advanced setup documented in the migration guide. - -### Docker Compose - -For Docker Compose-based dev environments: - -```yaml -services: - dev: - ports: - - "38741:38741" # Studio Bridge host -``` - -### Port direction - -Port forwarding goes **from host into container**: -- Bridge host listens on host port 38741 -- Container accesses it at `localhost:38741` (forwarded) -- Plugin connects to bridge host on localhost (no forwarding needed, same machine) - -## 8. Unified Interface - -The bridge host pattern described in `00-overview.md` already handles all the complexity. The `BridgeConnection` and `BridgeSession` classes work identically regardless of how the host was started. There is no separate "daemon session" or "remote session" type. - -```typescript -// This code is identical in all scenarios: -// - Implicit host (first CLI becomes host) -// - Explicit host (studio-bridge serve) -// - Local (same machine) -// - Remote (devcontainer with port forwarding) - -const bridge = await BridgeConnection.connectAsync(); -const session = await bridge.waitForSessionAsync(); -const result = await session.execAsync({ scriptContent: 'print("hello")' }); -console.log(result.output); -``` - -In split-server mode, `disconnectAsync()` on a client closes the client connection but does NOT stop the bridge host or kill Studio. The bridge host continues serving other clients and maintaining plugin connections. This is the same behavior as any bridge client disconnecting -- the host is independent. - -## 9. Security - -### All connections are localhost - -All connections are localhost-only (or port-forwarded localhost). TLS adds complexity without security benefit when both endpoints are on the same machine or connected via secure port forwarding (SSH tunnel, VS Code forwarding). - -### Plugin authentication - -Plugin connections are validated by session ID in the WebSocket path -- the same mechanism as single-process mode. Session IDs are UUIDv4 (128 bits of entropy), unguessable by other processes. - -### Client authentication - -In the initial implementation, bridge client connections on `/client` are unauthenticated. This is acceptable because: -- All connections are localhost (or port-forwarded localhost through a secure tunnel) -- The threat model is preventing accidental cross-user access, not sandboxing within a single user session -- Any process running as the same user could already read the plugin source and discover the port - -If a future requirement demands non-localhost connections or stricter isolation, a bearer token mechanism can be added to the bridge host's client connection handler in `src/bridge/internal/bridge-host.ts`. This would be an internal change -- consumers using `BridgeConnection` would not be affected. - -## 10. Limitations and Future Work - -- **One host per port**: Only one bridge host can bind a given port. Use `--port` to run multiple hosts on different ports. -- **No multi-user support**: The bridge host serves one user's Studio sessions. Shared machines with multiple users each need their own host on different ports. -- **No remote-over-internet**: All connections are localhost or port-forwarded localhost. Direct remote connections would require TLS, auth improvements, and NAT traversal. -- **Codespaces cloud gap**: Codespaces runs in the cloud, not on the user's machine. Bridging to local Studio requires an SSH tunnel or VS Code's built-in port forwarding from local to Codespace. This is documented but not automated. -- **No daemonization**: `serve` runs in the foreground. Use tmux, systemd, or Docker to run it as a background daemon if needed. A `--detach` flag could be added later if there is demand. diff --git a/studio-bridge/plans/tech-specs/06-mcp-server.md b/studio-bridge/plans/tech-specs/06-mcp-server.md deleted file mode 100644 index 49d3698be6..0000000000 --- a/studio-bridge/plans/tech-specs/06-mcp-server.md +++ /dev/null @@ -1,911 +0,0 @@ -# MCP Server: Technical Specification - -This document describes how studio-bridge exposes its capabilities as MCP (Model Context Protocol) tools for AI agents. The MCP server is a thin adapter over the same `CommandDefinition` handlers that the CLI and terminal use -- it does not contain its own business logic. This is the companion document referenced from `00-overview.md` and `02-command-system.md`. - -References: -- PRD: `../prd/main.md` (feature F7: MCP Integration) -- Command system: `02-command-system.md` (unified handler pattern, adapter architecture) -- Protocol: `01-protocol.md` (wire protocol message types) -- Action specs: `04-action-specs.md` (per-action MCP tool schemas) - -## 1. Purpose - -The MCP server exposes studio-bridge capabilities as MCP tools so that AI agents (Claude Code, Cursor, etc.) can discover running Studio sessions, query state, capture screenshots, read logs, inspect the DataModel, and execute Luau scripts -- all through the standard MCP tool-calling interface. - -The MCP server is one of three surfaces that consume the shared `CommandDefinition` handlers. It does not implement any business logic of its own. The architecture: - -``` -CommandDefinition (shared handler in src/commands/*.ts) - |-- CLI adapter -> yargs commands, formatted terminal output - |-- Terminal adapter -> dot-commands, REPL inline output - |-- MCP adapter -> MCP tools, structured JSON responses -``` - -Adding a new command to `src/commands/` and registering it in `allCommands` automatically makes it available as an MCP tool (unless explicitly opted out via `mcpEnabled: false`). No MCP-specific handler code is needed. - -## 2. Architecture - -### 2.1 Three-surface model - -The MCP server follows the same adapter pattern as the CLI and terminal. Each surface is a thin translation layer between the surface-specific protocol and the shared handler: - -| Concern | CLI adapter | Terminal adapter | MCP adapter | -|---------|------------|-----------------|-------------| -| Input parsing | yargs argv | dot-command string split | MCP tool input JSON | -| Session resolution | `resolveSessionAsync` with `interactive: process.stdout.isTTY` | Session already attached | `resolveSessionAsync` with `interactive: false` | -| Handler invocation | `cmd.handler(input, context)` | `cmd.handler(input, context)` | `cmd.handler(input, context)` | -| Output formatting | `summary` text or `JSON.stringify(data)` with `--json` | `summary` text | `JSON.stringify(data)` always (structured JSON) | -| Error handling | `OutputHelper.error()` + `process.exit(1)` | Inline error string | MCP error response with `isError: true` | -| Image handling | Write to file, print path | Write to file, print path | Return base64 in MCP image content block | - -### 2.2 No business logic in the MCP layer - -The MCP adapter (`src/mcp/adapters/mcp-adapter.ts`) is a generic function that operates on any `CommandDefinition`. It does not know what `queryStateAsync` or `captureScreenshotAsync` does. It: - -1. Receives MCP tool input as JSON -2. Calls `resolveSessionAsync` if the command requires a session -3. Calls the command handler -4. Returns `result.data` as the MCP tool response - -If you find yourself writing Studio-specific logic in `src/mcp/`, you are violating the golden rule from `02-command-system.md` section 2. - -### 2.3 Relationship to BridgeConnection - -The MCP server connects to the bridge network via `BridgeConnection.connectAsync()`, just like any other CLI process. It either becomes the bridge host (if no host is running) or connects as a client. This is transparent -- the MCP server does not know or care which role it has. - -``` -AI Agent (Claude Code) - | - | stdio (MCP protocol) - | -MCP Server (studio-bridge mcp) - | - | BridgeConnection (host or client, transparent) - | -Bridge Host (:38741) - | - +-- Plugin A (Studio 1) - +-- Plugin B (Studio 2) -``` - -The MCP server is a long-lived process. It maintains a single `BridgeConnection` for its entire lifetime, reusing it across tool invocations. This means sessions discovered by one tool call are immediately available to subsequent calls without reconnection overhead. - -## 3. MCP Tool Definitions - -Each MCP-eligible command in `allCommands` generates one MCP tool. The tool name is `studio_${cmd.name}` by default (overridable via `mcpName` on the `CommandDefinition`). Commands with `mcpEnabled: false` are excluded. - -### 3.1 `studio_sessions` -- List running sessions - -**Wraps**: `sessionsCommand` from `src/commands/sessions.ts` - -**Description**: List all running Roblox Studio sessions connected to studio-bridge. Returns session IDs, place names, Studio state, and connection metadata. Call this first to discover available sessions. - -**Input schema**: -```json -{ - "type": "object", - "properties": {}, - "additionalProperties": false -} -``` - -**Output format** (JSON in MCP text content block): - -Single instance in Edit mode: -```json -{ - "sessions": [ - { - "sessionId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - "placeName": "TestPlace.rbxl", - "placeFile": "/Users/dev/game/TestPlace.rbxl", - "context": "edit", - "state": "Edit", - "instanceId": "inst-001", - "placeId": 1234567890, - "gameId": 9876543210, - "origin": "user", - "uptimeMs": 150000 - } - ] -} -``` - -Single instance in Play mode (3 sessions sharing an instanceId): -```json -{ - "sessions": [ - { - "sessionId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - "placeName": "TestPlace.rbxl", - "placeFile": "/Users/dev/game/TestPlace.rbxl", - "context": "edit", - "state": "Play", - "instanceId": "inst-001", - "placeId": 1234567890, - "gameId": 9876543210, - "origin": "user", - "uptimeMs": 150000 - }, - { - "sessionId": "b2c3d4e5-f6a7-8901-bcde-f12345678901", - "placeName": "TestPlace.rbxl", - "placeFile": "/Users/dev/game/TestPlace.rbxl", - "context": "server", - "state": "Play", - "instanceId": "inst-001", - "placeId": 1234567890, - "gameId": 9876543210, - "origin": "user", - "uptimeMs": 149800 - }, - { - "sessionId": "c3d4e5f6-a7b8-9012-cdef-123456789012", - "placeName": "TestPlace.rbxl", - "placeFile": "/Users/dev/game/TestPlace.rbxl", - "context": "client", - "state": "Play", - "instanceId": "inst-001", - "placeId": 1234567890, - "gameId": 9876543210, - "origin": "user", - "uptimeMs": 149800 - } - ] -} -``` - -Sessions from the same Studio instance share an `instanceId`. In Play mode, the instance produces up to three sessions with different `context` values: `edit` (always present), `server`, and `client`. - -**Error cases**: -- No bridge host running: descriptive error with guidance ("No bridge host running. Start Studio with the studio-bridge plugin installed, then try again.") -- Bridge host running, no plugins connected: descriptive error ("No active sessions. Is Studio running with the studio-bridge plugin installed?") - -### 3.2 `studio_state` -- Query Studio state - -**Wraps**: `stateCommand` from `src/commands/state.ts` - -**Description**: Get the current state of a Roblox Studio session: run mode (Edit, Play, Paused, Run, Server, Client), place name, place ID, and game ID. - -**Input schema**: -```json -{ - "type": "object", - "properties": { - "sessionId": { - "type": "string", - "description": "Target session ID. Optional if only one Studio instance is connected." - }, - "context": { - "type": "string", - "enum": ["edit", "client", "server"], - "description": "Target session context. Optional. Defaults to edit. Use server or client to target Play mode contexts." - } - }, - "additionalProperties": false -} -``` - -**Output format**: -```json -{ - "state": "Edit", - "placeName": "TestPlace", - "placeId": 1234567890, - "gameId": 9876543210 -} -``` - -**Error cases**: -- No sessions available: descriptive error with guidance -- Session not found: MCP `InvalidParams` error -- Plugin timeout: MCP `InternalError` error ("State query timed out after 5 seconds.") - -### 3.3 `studio_screenshot` -- Capture viewport screenshot - -**Wraps**: `screenshotCommand` from `src/commands/screenshot.ts` - -**Description**: Capture a screenshot of the Roblox Studio 3D viewport. Returns the image as base64-encoded PNG data. Use this to see what the user sees in Studio. - -**Input schema**: -```json -{ - "type": "object", - "properties": { - "sessionId": { - "type": "string", - "description": "Target session ID. Optional if only one Studio instance is connected." - }, - "context": { - "type": "string", - "enum": ["edit", "client", "server"], - "description": "Target session context. Optional. Defaults to edit. Use server or client to target Play mode contexts." - } - }, - "additionalProperties": false -} -``` - -**Output format**: MCP image content block (not a text content block): -```json -{ - "content": [ - { - "type": "image", - "data": "iVBORw0KGgoAAAANSUhEUgAA...", - "mimeType": "image/png" - } - ] -} -``` - -The MCP adapter detects that the command is `screenshot` and returns an image content block instead of a text block. This allows MCP clients that support multimodal input (like Claude) to process the image directly. - -**Error cases**: -- CaptureService call fails at runtime: tool result with `isError: true` -- Viewport not available: tool result with `isError: true` -- Plugin timeout: MCP `InternalError` error ("Screenshot capture timed out after 15 seconds.") - -### 3.4 `studio_logs` -- Retrieve output logs - -**Wraps**: `logsCommand` from `src/commands/logs.ts` - -**Description**: Retrieve buffered output log lines from a Roblox Studio session. Returns recent log entries with timestamps and severity levels. Use this to check for errors, warnings, or print output. - -**Input schema**: -```json -{ - "type": "object", - "properties": { - "sessionId": { - "type": "string", - "description": "Target session ID. Optional if only one Studio instance is connected." - }, - "context": { - "type": "string", - "enum": ["edit", "client", "server"], - "description": "Target session context. Optional. Defaults to edit. Use server or client to target Play mode contexts." - }, - "count": { - "type": "number", - "description": "Maximum number of log entries to return. Default: 50.", - "default": 50 - }, - "direction": { - "type": "string", - "enum": ["head", "tail"], - "description": "Return oldest entries first ('head') or newest first ('tail'). Default: 'tail'.", - "default": "tail" - }, - "levels": { - "type": "array", - "items": { "type": "string", "enum": ["Print", "Info", "Warning", "Error"] }, - "description": "Filter by log level. Default: all levels." - }, - "includeInternal": { - "type": "boolean", - "description": "Include internal [StudioBridge] messages. Default: false.", - "default": false - } - }, - "additionalProperties": false -} -``` - -**Output format**: -```json -{ - "entries": [ - { "level": "Print", "body": "Hello from script", "timestamp": 12340 }, - { "level": "Warning", "body": "Infinite yield possible", "timestamp": 12345 } - ], - "total": 847, - "bufferCapacity": 1000 -} -``` - -MCP does not support follow/streaming mode. Each invocation returns a snapshot of the log buffer. Agents that need to monitor logs should poll `studio_logs` periodically. - -**Error cases**: -- Plugin timeout: MCP `InternalError` error ("Log query timed out after 10 seconds.") - -### 3.5 `studio_query` -- Query the DataModel - -**Wraps**: `queryCommand` from `src/commands/query.ts` - -**Description**: Query the Roblox DataModel to inspect instances, properties, attributes, and children. Use dot-separated paths like "Workspace.SpawnLocation" to navigate the instance tree. Returns structured JSON with class names, properties, and child counts. - -**Input schema**: -```json -{ - "type": "object", - "properties": { - "sessionId": { - "type": "string", - "description": "Target session ID. Optional if only one Studio instance is connected." - }, - "context": { - "type": "string", - "enum": ["edit", "client", "server"], - "description": "Target session context. Optional. Defaults to edit. Use server or client to target Play mode contexts." - }, - "path": { - "type": "string", - "description": "Dot-separated instance path, e.g. 'Workspace.SpawnLocation'. The 'game.' prefix is optional." - }, - "depth": { - "type": "number", - "description": "Max child traversal depth. 0 = instance only, 1 = include children, etc. Default: 0.", - "default": 0 - }, - "properties": { - "type": "array", - "items": { "type": "string" }, - "description": "Specific property names to include. Default: Name, ClassName, Parent." - }, - "includeAttributes": { - "type": "boolean", - "description": "Include all attributes on the instance. Default: false.", - "default": false - }, - "children": { - "type": "boolean", - "description": "List immediate children instead of querying the instance itself. Default: false.", - "default": false - }, - "listServices": { - "type": "boolean", - "description": "List all loaded services in the DataModel. Ignores path. Default: false.", - "default": false - } - }, - "required": ["path"], - "additionalProperties": false -} -``` - -**Output format**: -```json -{ - "instance": { - "name": "SpawnLocation", - "className": "SpawnLocation", - "path": "game.Workspace.SpawnLocation", - "properties": { - "Position": { "type": "Vector3", "value": [0, 4, 0] }, - "Anchored": true - }, - "attributes": {}, - "childCount": 0 - } -} -``` - -**Error cases**: -- Instance not found: tool result with `isError: true` ("No instance found at path: game.Workspace.NonExistent") -- Property not found: tool result with `isError: true` -- Plugin timeout: MCP `InternalError` error ("DataModel query timed out after 10 seconds.") - -### 3.6 `studio_exec` -- Execute Luau script - -**Wraps**: `execCommand` from `src/commands/exec.ts` - -**Description**: Execute a Luau script in a Roblox Studio session. Returns the script's success status, any error message, and captured log output. Use this to run code, modify the game state, or perform actions that other tools cannot express. - -**Input schema**: -```json -{ - "type": "object", - "properties": { - "sessionId": { - "type": "string", - "description": "Target session ID. Optional if only one Studio instance is connected." - }, - "context": { - "type": "string", - "enum": ["edit", "client", "server"], - "description": "Target session context. Optional. Defaults to server for exec (mutating command). Use edit or client to target other contexts in Play mode." - }, - "script": { - "type": "string", - "description": "Luau code to execute in Studio." - } - }, - "required": ["script"], - "additionalProperties": false -} -``` - -**Output format**: -```json -{ - "success": true, - "logs": [ - { "level": "Print", "body": "Hello from Studio" } - ] -} -``` - -On script error: -```json -{ - "success": false, - "error": "Script:2: attempt to index nil with 'Name'", - "logs": [ - { "level": "Print", "body": "Starting..." } - ] -} -``` - -Script execution errors are returned as successful tool results with `success: false` in the data (not as MCP errors). This allows the agent to see the error message and partial output, then decide how to proceed. - -**Error cases**: -- Plugin busy: tool result with `isError: true` ("Plugin is busy executing another script.") -- Plugin timeout: MCP `InternalError` error ("Script execution timed out after 120 seconds.") - -## 4. MCP Adapter Implementation - -The MCP adapter creates MCP tools from `CommandDefinition`s. It is a generic function that operates on any command -- it does not contain command-specific logic. - -### 4.1 Core adapter function - -```typescript -// src/mcp/adapters/mcp-adapter.ts - -import type { CommandDefinition, CommandContext, CommandResult } from '../../commands/types.js'; -import { resolveSessionAsync } from '../../commands/session-resolver.js'; -import type { BridgeConnection } from '../../bridge/index.js'; - -export interface McpToolDefinition { - name: string; - description: string; - inputSchema: object; - handler: (input: Record) => Promise; -} - -export interface McpToolResult { - content: McpContentBlock[]; - isError?: boolean; -} - -export type McpContentBlock = - | { type: 'text'; text: string } - | { type: 'image'; data: string; mimeType: string }; - -export function createMcpTool( - definition: CommandDefinition, - connection: BridgeConnection -): McpToolDefinition { - return { - name: definition.mcpName ?? `studio_${definition.name}`, - description: definition.mcpDescription ?? definition.description, - inputSchema: buildJsonSchema(definition.args, definition.requiresSession), - handler: async (input: Record): Promise => { - const context: CommandContext = { connection, interactive: false }; - - if (definition.requiresSession) { - const resolved = await resolveSessionAsync(connection, { - sessionId: input.sessionId as string | undefined, - context: input.context as SessionContext | undefined, - interactive: false, - }); - context.session = resolved.session; - context.context = resolved.context; - } - - try { - const result = await definition.handler(input as TInput, context); - const commandResult = result as CommandResult; - - // Special case: screenshot returns an image content block - if (definition.name === 'screenshot' && commandResult.data) { - const data = commandResult.data as { base64Data?: string }; - if (data.base64Data) { - return { - content: [{ - type: 'image', - data: data.base64Data, - mimeType: 'image/png', - }], - }; - } - } - - return { - content: [{ - type: 'text', - text: JSON.stringify(commandResult.data), - }], - }; - } catch (err) { - return { - content: [{ - type: 'text', - text: JSON.stringify({ - error: err instanceof Error ? err.message : String(err), - }), - }], - isError: true, - }; - } finally { - // MCP tools disconnect from user sessions after each call. - // Managed sessions are not stopped (the MCP server does not own them). - if (context.session && context.session.origin !== 'managed') { - await context.session.disconnectAsync(); - } - } - }, - }; -} -``` - -### 4.2 JSON schema generation from ArgSpec - -The adapter generates MCP-compatible JSON Schema from the command's `ArgSpec` array. Session-requiring commands automatically receive optional `sessionId` and `context` parameters for session targeting. - -```typescript -function buildJsonSchema(args: ArgSpec[], requiresSession: boolean): object { - const properties: Record = {}; - - // Session-requiring commands get sessionId and context parameters automatically - if (requiresSession) { - properties.sessionId = { - type: 'string', - description: 'Target session ID. Optional if only one Studio instance is connected.', - }; - properties.context = { - type: 'string', - enum: ['edit', 'client', 'server'], - description: `Target session context. Optional. Defaults to ${cmd.defaultContext ?? 'edit'}. Use server or client to target Play mode contexts.`, - }; - } - - const required: string[] = []; - - for (const arg of args) { - properties[arg.name] = { - type: arg.type, - description: arg.description, - ...(arg.default !== undefined ? { default: arg.default } : {}), - }; - if (arg.required) { - required.push(arg.name); - } - } - - return { - type: 'object', - properties, - required: required.length > 0 ? required : undefined, - additionalProperties: false, - }; -} -``` - -### 4.3 Screenshot handling - -The `studio_screenshot` tool is the one case where the MCP adapter does something surface-specific: it returns an MCP image content block instead of a text content block. - -When the MCP adapter invokes the screenshot handler, the handler returns a `CommandResult` with `base64Data` in the data field. The adapter detects this (via the command name) and wraps it in an MCP image content block: - -```typescript -{ - content: [{ - type: 'image', - data: result.data.base64Data, // raw base64 PNG - mimeType: 'image/png', - }] -} -``` - -The screenshot handler must be invoked with `base64: true` semantics when called from MCP (it should not write to a file). The MCP adapter passes `{ base64: true }` as part of the input to ensure the handler returns base64 data rather than a file path. - -This is the ONLY command-specific behavior in the MCP adapter. It is a presentation concern (how to encode the response), not business logic. - -## 5. MCP Server Implementation - -### 5.1 Server lifecycle - -The MCP server is started via the `studio-bridge mcp` CLI command: - -``` -$ studio-bridge mcp -``` - -This starts a long-lived process that: - -1. Connects to the bridge network via `BridgeConnection.connectAsync({ keepAlive: true })` -2. Creates an MCP server instance using the MCP SDK (stdio transport) -3. Registers all MCP-eligible tools from `allCommands` -4. Listens for MCP tool invocations over stdio -5. Stays alive until the MCP client disconnects or the process is killed - -The `mcp` command is itself a `CommandDefinition` in `src/commands/` with `requiresSession: false` and `mcpEnabled: false` (it would be nonsensical for the MCP server to expose itself as a tool). - -### 5.2 Server entry point - -```typescript -// src/mcp/mcp-server.ts - -import { Server } from '@modelcontextprotocol/sdk/server/index.js'; -import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; -import { allCommands } from '../commands/index.js'; -import { createMcpTool } from './adapters/mcp-adapter.js'; -import { BridgeConnection } from '../bridge/index.js'; - -export async function startMcpServerAsync(): Promise { - const connection = await BridgeConnection.connectAsync({ keepAlive: true }); - - const server = new Server( - { name: 'studio-bridge', version: '1.0.0' }, - { capabilities: { tools: {} } } - ); - - // Register all MCP-eligible commands as tools - const tools: McpToolDefinition[] = []; - for (const cmd of allCommands.filter(c => c.mcpEnabled !== false)) { - tools.push(createMcpTool(cmd, connection)); - } - - server.setRequestHandler('tools/list', async () => ({ - tools: tools.map(t => ({ - name: t.name, - description: t.description, - inputSchema: t.inputSchema, - })), - })); - - server.setRequestHandler('tools/call', async (request) => { - const tool = tools.find(t => t.name === request.params.name); - if (!tool) { - throw new Error(`Unknown tool: ${request.params.name}`); - } - return tool.handler(request.params.arguments ?? {}); - }); - - const transport = new StdioServerTransport(); - await server.connect(transport); - - // The server runs until the transport closes (MCP client disconnects) - // or the process receives SIGTERM/SIGINT. -} -``` - -### 5.3 Registration loop - -The registration loop filters `allCommands` by `mcpEnabled`: - -```typescript -for (const cmd of allCommands.filter(c => c.mcpEnabled !== false)) { - tools.push(createMcpTool(cmd, connection)); -} -``` - -Commands excluded from MCP (`mcpEnabled: false`): -- `serve` -- process-level command (starts a bridge host). Not a session action. -- `install-plugin` -- local setup command. Requires user action (restarting Studio). -- `mcp` -- the MCP server itself. Cannot expose itself as a tool. -- `connect` -- enters interactive terminal mode. Not meaningful for MCP. -- `disconnect` -- terminal session management. Not meaningful for MCP. -- `launch` -- explicitly launches Studio. Agents should discover existing sessions instead. - -Commands included in MCP (`mcpEnabled: true` or default): -- `sessions`, `state`, `screenshot`, `logs`, `query`, `exec` - -### 5.4 Transport - -The primary transport is **stdio** (standard input/output). This is the transport used by Claude Code and most MCP clients: - -``` -Claude Code <--stdio--> studio-bridge mcp <--BridgeConnection--> Bridge Host <--WebSocket--> Studio Plugin -``` - -The stdio transport reads JSON-RPC messages from stdin and writes responses to stdout. The MCP SDK handles framing and JSON-RPC protocol details. - -An optional **SSE (Server-Sent Events)** transport could be added later for web-based MCP clients, but it is not required for the initial implementation. The architecture supports it because the MCP server and bridge connection are decoupled from the transport. - -### 5.5 Shared bridge connection - -The MCP server shares the bridge network with any co-running CLI processes. If the user has `studio-bridge terminal` open in one tab and Claude Code using the MCP server in another, both see the same sessions because they both connect to the same bridge host on port 38741. - -The MCP server's `BridgeConnection` is created with `keepAlive: true` so the bridge host does not idle-exit while the MCP server is connected. This ensures sessions remain discoverable between tool invocations. - -## 6. Session Auto-Selection - -MCP tools accept optional `sessionId` and `context` parameters. The auto-selection heuristic matches the CLI behavior via `resolveSessionAsync`, using **instance-aware resolution**. Sessions are grouped by `instanceId` before applying the heuristic: - -| Instances | `sessionId` | `context` | Behavior | -|-----------|------------|-----------|----------| -| 0 | no | any | Error: "No active sessions. Is Studio running with the studio-bridge plugin installed?" | -| 0 | yes | any | Error: "Session not found: {id}" | -| 1 (Edit mode) | no | not set | Auto-select the Edit session (zero-config) | -| 1 (Edit mode) | no | `edit` | Select Edit session | -| 1 (Edit mode) | no | `server`/`client` | Error: "No server/client context available. Studio is in Edit mode." | -| 1 (Play mode) | no | not set | Default to command's `defaultContext` (Edit for read-only commands, Server for mutating; see context default table in `04-action-specs.md`) | -| 1 (Play mode) | no | `server` | Select Server session | -| 1 (Play mode) | no | `client` | Select Client session | -| 1 | yes | any | Use specified session directly | -| N > 1 | no | any | Error: "Multiple Studio instances connected. Specify a sessionId." + session list in error details | -| N > 1 | yes | any | Use specified session directly | - -When zero instances are available, the MCP server does NOT launch Studio (unlike the CLI's `exec` and `run` commands, which fall back to launching). Launching Studio is an action that requires user intent. The agent should inform the user to launch Studio instead. - -The `interactive` flag is always `false` for MCP. There is no prompt, no user input. Ambiguity results in a descriptive error. - -**Common MCP usage pattern for Play mode debugging**: -1. Agent calls `studio_sessions` to discover sessions and see their contexts. -2. Agent calls `studio_exec` with `context: "server"` to run server-side debugging code. -3. Agent calls `studio_logs` with `context: "client"` to check client-side output. - -## 7. Error Mapping - -Studio-bridge errors are mapped to MCP error responses. The MCP protocol uses `isError: true` on tool results for tool-level errors, and JSON-RPC error codes for protocol-level errors. - -### 7.1 Tool-level errors (returned as tool results with `isError: true`) - -These are errors that occur during tool execution. They are returned as normal tool results with `isError: true` so the agent can see the error message and decide how to proceed. - -| Studio-bridge error | MCP tool result | -|--------------------|------------------------------------| -| No sessions available | `{ isError: true, content: [{ type: 'text', text: '{"error": "No active sessions. Is Studio running with the studio-bridge plugin installed?"}' }] }` | -| Session not found | `{ isError: true, content: [{ type: 'text', text: '{"error": "Session not found: {id}. Call studio_sessions to see available sessions."}' }] }` | -| Multiple instances, none specified | `{ isError: true, content: [{ type: 'text', text: '{"error": "Multiple Studio instances connected. Specify a sessionId.", "sessions": [...]}' }] }` | -| Context not available | `{ isError: true, content: [{ type: 'text', text: '{"error": "No server context available. Studio is in Edit mode. Use context: edit or omit context."}' }] }` | -| Plugin timeout | `{ isError: true, content: [{ type: 'text', text: '{"error": "State query timed out after 5 seconds."}' }] }` | -| Instance not found | `{ isError: true, content: [{ type: 'text', text: '{"error": "No instance found at path: game.Workspace.NonExistent"}' }] }` | -| Screenshot failed | `{ isError: true, content: [{ type: 'text', text: '{"error": "Screenshot capture failed: viewport is not available."}' }] }` | -| Script execution error | Normal result (not `isError`): `{ content: [{ type: 'text', text: '{"success": false, "error": "...", "logs": [...]}' }] }` | - -Note that script execution errors (syntax errors, runtime errors) are NOT mapped to `isError: true`. They are returned as successful tool results with `success: false` in the data. This allows the agent to see the error message and partial output. `isError: true` is reserved for infrastructure failures (no session, timeout, connection error). - -### 7.2 Protocol-level errors (JSON-RPC error codes) - -These are errors in the MCP protocol itself, not in tool execution: - -| Condition | JSON-RPC error code | Message | -|-----------|-------------------|---------| -| Unknown tool name | `-32602` (InvalidParams) | "Unknown tool: {name}" | -| Invalid input schema | `-32602` (InvalidParams) | "Invalid input: {validation error}" | -| Bridge connection failed | `-32603` (InternalError) | "Cannot connect to studio-bridge. Is the bridge host running?" | -| Unexpected server error | `-32603` (InternalError) | "Internal error: {message}" | - -## 8. Configuration - -### 8.1 Claude Code MCP configuration - -To register studio-bridge as an MCP tool provider in Claude Code, add the following to your MCP configuration (e.g., `~/.claude/claude_desktop_config.json` or `.mcp.json` in the project root): - -```json -{ - "mcpServers": { - "studio-bridge": { - "command": "studio-bridge", - "args": ["mcp"] - } - } -} -``` - -If studio-bridge is installed locally (not globally), use the full path or `npx`: - -```json -{ - "mcpServers": { - "studio-bridge": { - "command": "npx", - "args": ["studio-bridge", "mcp"] - } - } -} -``` - -### 8.2 Split-server mode (devcontainer) - -When using studio-bridge in a devcontainer with the bridge host running on the host OS, the MCP server should connect to the remote bridge host: - -```json -{ - "mcpServers": { - "studio-bridge": { - "command": "studio-bridge", - "args": ["mcp", "--remote", "localhost:38741"] - } - } -} -``` - -In most devcontainer setups, port 38741 is automatically forwarded, so the default configuration (without `--remote`) works. - -### 8.3 MCP command flags - -The `studio-bridge mcp` command accepts these flags: - -| Flag | Type | Default | Description | -|------|------|---------|-------------| -| `--remote` | string | (auto) | Connect to a remote bridge host instead of local | -| `--port` | number | 38741 | Bridge host port | -| `--log-level` | string | `error` | Log level for MCP server diagnostics (written to stderr) | - -Diagnostic logs are written to stderr (not stdout) to avoid interfering with the MCP stdio transport on stdout. - -## 9. File Layout - -The MCP server adds a minimal set of files: - -``` -src/ - mcp/ - mcp-server.ts MCP server lifecycle (startMcpServerAsync), tool registration - adapters/ - mcp-adapter.ts createMcpTool() -- generic adapter: CommandDefinition -> MCP tool - index.ts Public exports - commands/ - mcp.ts 'studio-bridge mcp' command handler (mcpEnabled: false) - cli/ - (no changes) cli.ts already loops over allCommands -``` - -There are no per-command MCP tool files. `studio-state-tool.ts`, `studio-exec-tool.ts`, etc. do NOT exist. Each tool is generated from the corresponding `CommandDefinition` by `createMcpTool`. See `02-command-system.md` section 3.4 for why. - -### 9.1 What does NOT exist - -To be explicit: - -- `src/mcp/tools/studio-state-tool.ts` -- does not exist. No per-tool files. -- `src/mcp/tools/studio-exec-tool.ts` -- does not exist. -- `src/mcp/tools/studio-screenshot-tool.ts` -- does not exist. -- `src/mcp/tools/index.ts` -- does not exist. Tools are registered in the loop in `mcp-server.ts`. -- `src/mcp/session-resolver.ts` -- does not exist. Uses `resolveSessionAsync` from `src/commands/session-resolver.ts`. - -## 10. Dependencies - -### 10.1 MCP SDK - -The MCP server uses the `@modelcontextprotocol/sdk` package for protocol handling and transport: - -```json -{ - "dependencies": { - "@modelcontextprotocol/sdk": "^1.0.0" - } -} -``` - -The SDK provides: -- `Server` class for handling MCP protocol requests -- `StdioServerTransport` for stdio communication -- Type definitions for MCP messages - -### 10.2 Internal dependencies - -The MCP server depends on: -- `src/commands/index.ts` -- the `allCommands` registry -- `src/commands/types.ts` -- `CommandDefinition`, `CommandContext`, `CommandResult` -- `src/commands/session-resolver.ts` -- `resolveSessionAsync` -- `src/bridge/index.ts` -- `BridgeConnection`, `BridgeSession` - -It does NOT depend on: -- `src/cli/` -- no CLI-specific code -- `src/bridge/internal/` -- no internal networking code -- `src/server/` -- no direct server interaction (goes through `BridgeSession`) - -## 11. Testing Strategy - -### 11.1 Unit tests - -- `mcp-adapter.test.ts`: Verify `createMcpTool` generates correct tool name, description, input schema from a `CommandDefinition`. Verify handler calls `resolveSessionAsync` and the command handler, and returns structured JSON. Verify screenshot returns image content block. - -### 11.2 Integration tests - -- Start MCP server in a subprocess, send `tools/list` request via stdio, verify all expected tools are listed with correct schemas. -- Send `tools/call` for `studio_sessions` with a mock bridge connection, verify structured JSON response. -- Send `tools/call` for `studio_state` with a mock session, verify state data is returned. -- Send `tools/call` for an unknown tool name, verify error response with correct JSON-RPC error code. -- Send `tools/call` for `studio_exec` with a script that errors, verify `success: false` is in the result (not `isError: true`). - -### 11.3 Manual validation - -- Register in Claude Code MCP configuration, verify tools appear in the tool list. -- Call `studio_sessions` from Claude Code, verify session list is returned. -- Call `studio_exec` from Claude Code with `print("hello")`, verify output appears. -- Call `studio_screenshot` from Claude Code, verify image is displayed inline. diff --git a/studio-bridge/plans/tech-specs/07-bridge-network.md b/studio-bridge/plans/tech-specs/07-bridge-network.md deleted file mode 100644 index 058ee1ca4f..0000000000 --- a/studio-bridge/plans/tech-specs/07-bridge-network.md +++ /dev/null @@ -1,1436 +0,0 @@ -# Bridge Network: Technical Specification - -The Bridge Network is the networking substrate that all of studio-bridge runs on. It is the most critical subsystem in the project: every command, every MCP tool, every terminal dot-command, every library call flows through it. This document is the authoritative reference for its design, public API, internal architecture, protocols, lifecycle, and testing strategy. - -This spec consolidates and deepens the networking sections from `00-overview.md` (sections 1, 4, 7), `01-protocol.md` (transport concerns), and `05-split-server.md` (explicit host). Those documents retain their summaries; this document is the single source of truth for implementation. - -## 1. Purpose and Design Philosophy - -### 1.1 What this layer is - -The Bridge Network is the abstraction boundary between "what studio-bridge does" (execute scripts, query state, capture screenshots) and "how messages reach Studio" (WebSockets, ports, host/client roles, session tracking, hand-off). Every consumer of studio-bridge -- CLI commands, terminal dot-commands, MCP tools, the `StudioBridge` library class -- interacts with Studio through exactly two public types: `BridgeConnection` and `BridgeSession`. Everything else is hidden. - -### 1.2 Design goals - -**Isolated.** No business logic leaks into the networking layer. The Bridge Network knows how to route messages to sessions and deliver responses. It does not know what "execute a script" means, what a screenshot is, or how to format output for a terminal. Conversely, no consumer code knows about WebSocket frames, port binding, or host election. - -**Testable.** The entire networking stack can be exercised without Roblox Studio. A mock plugin (a test helper that opens a WebSocket to the bridge host, sends `register`, and responds to actions) is sufficient to validate every code path. Unit tests cover each internal module in isolation; integration tests cover the full stack with mock plugins. - -**Abstract.** The consumer invariant: code using `BridgeConnection` and `BridgeSession` cannot tell: -- How many Studios are connected (one or ten) -- Whether this process is the bridge host or a bridge client -- Whether the connection is local or port-forwarded from a devcontainer -- How messages are transported (WebSocket, forwarded through a host, etc.) -- Whether the host was started implicitly or via `studio-bridge serve` - -This is not a convenience -- it is a hard architectural constraint. Any change that would require consumer code to be aware of the networking topology is a design violation. - -### 1.3 Relationship to other specs - -| Spec | Relationship | -|------|-------------| -| `00-overview.md` | Contains summary-level descriptions of bridge host, bridge client, transport, hand-off, and API boundary. This spec is the authoritative deep reference. | -| `01-protocol.md` | Defines the message types and wire format between server and plugin. The Bridge Network transports these messages but does not define them. | -| `02-command-system.md` | Defines `CommandDefinition` handlers that call `BridgeSession` action methods. Consumers of this networking layer. | -| `03-persistent-plugin.md` | Defines the Luau plugin that connects to the bridge host. A peer, not part of this layer. | -| `05-split-server.md` | Describes the `studio-bridge serve` command, which is a thin wrapper around the bridge host. An operational concern built on top of this layer. | -| `06-mcp-server.md` | The MCP server uses `BridgeConnection` to access sessions. A consumer of this layer. | - -## 2. Public API Surface - -The Bridge Network exports exactly these types from `src/bridge/index.ts`. Nothing else is public. Nothing from `src/bridge/internal/` is re-exported. - -### 2.1 BridgeConnection - -The single entry point for connecting to the studio-bridge network. Handles host/client role detection transparently. Consumers never create a `BridgeHost`, `BridgeClient`, `TransportServer`, or any other internal type. - -```typescript -/** - * The constructor is private. Use the static factory `connectAsync()` to - * create instances. This prevents double-connect and ensures the connection - * is fully established before the caller receives the object. - */ -interface BridgeConnection { - // ── Lifecycle ── - - /** - * Static factory: connect to the studio-bridge network and return a - * ready-to-use BridgeConnection. - * - * - If no host is running: binds port 38741, becomes the host. - * - If a host is running: connects as a client. - * - If remoteHost is specified: connects directly as a client to that host. - * - If running inside a devcontainer: auto-detects and connects to host. - * - * The caller cannot tell which path was taken. The returned - * BridgeConnection behaves identically in all cases. - */ - static connectAsync(options?: BridgeConnectionOptions): Promise; - - /** - * Disconnect from the bridge network. - * - If host: triggers the hand-off protocol (transfer to a connected - * client, or shut down cleanly). - * - If client: closes the client connection. Does NOT stop the host - * or kill Studio. - */ - disconnectAsync(): Promise; - - // ── Session access ── - - /** List all currently connected Studio sessions (across all instances and contexts). */ - listSessions(): SessionInfo[]; - - /** - * List unique Studio instances. Each instance groups 1-3 context sessions - * (edit, client, server) that share the same instanceId. - */ - listInstances(): InstanceInfo[]; - - /** Get a session handle by ID. Returns undefined if not connected. */ - getSession(sessionId: string): BridgeSession | undefined; - - /** - * Wait for at least one session to connect. - * Resolves with the first session that connects (or the only session - * if one is already connected). Rejects after timeout. - */ - waitForSession(timeout?: number): Promise; - - /** - * Resolve a session for command execution. Instance-aware: groups sessions - * by instanceId and auto-selects context within an instance. - * - * Algorithm: - * 1. If sessionId is provided → return that specific session. - * 2. If instanceId is provided → select that instance, then apply context - * selection (step 4a-4c below). - * 3. Collect unique instances (by instanceId). - * 4. If 0 instances → wait up to timeout for one. - * 5. If 1 instance: - * a. If context is provided → return that context's session - * (throws ContextNotFoundError if not connected). - * b. If only 1 context (Edit mode) → return it. - * c. If multiple contexts (Play mode) → return Edit context (default). - * 6. If N instances → throw SessionNotFoundError with instance list - * (caller must use --session or --instance to disambiguate). - */ - resolveSession(sessionId?: string, context?: SessionContext, instanceId?: string): Promise; - - // ── Events ── - - on(event: 'session-connected', listener: (session: BridgeSession) => void): this; - on(event: 'session-disconnected', listener: (sessionId: string) => void): this; - on(event: 'instance-connected', listener: (instance: InstanceInfo) => void): this; - on(event: 'instance-disconnected', listener: (instanceId: string) => void): this; - on(event: 'error', listener: (error: Error) => void): this; - - // ── Diagnostics ── - - /** Whether this process ended up as host or client. */ - readonly role: 'host' | 'client'; - - /** Whether the connection is currently active. */ - readonly isConnected: boolean; -} -``` - -### 2.2 BridgeConnectionOptions - -```typescript -interface BridgeConnectionOptions { - /** Port for the bridge host. Default: 38741. */ - port?: number; - - /** Max time to wait for initial connection setup. Default: 30_000ms. */ - timeoutMs?: number; - - /** - * Keep the host alive even when idle (no clients, no pending commands). - * Default: false. Used by `studio-bridge serve` and MCP server. - */ - keepAlive?: boolean; - - /** - * Skip local port-bind attempt and connect directly as a client - * to this host address. Used for split-server / devcontainer mode. - * Example: 'localhost:38741' - */ - remoteHost?: string; -} -``` - -### 2.3 BridgeSession - -A handle to a single connected Studio instance. Provides all action methods. Works identically whether this process is the bridge host or a client -- the networking layer routes commands transparently. - -Consumers get `BridgeSession` instances from `BridgeConnection`. They never construct them directly. - -```typescript -interface BridgeSession { - /** Read-only metadata about this session. */ - readonly info: SessionInfo; - - /** Which Studio VM this session represents (edit, client, or server). */ - readonly context: SessionContext; - - /** Whether the session's plugin is still connected. */ - readonly isConnected: boolean; - - // ── Actions ── - // Each method sends a protocol message to the plugin and waits for - // the correlated response. Timeouts are per-action-type defaults - // from 01-protocol.md section 7.4. - - /** - * Execute a Luau script in this Studio instance. - * - * The public API uses `code`; the wire protocol uses `script` (Roblox - * terminology). The adapter layer translates between the two when - * constructing the `execute` message. - */ - execAsync(code: string, timeout?: number): Promise; - - /** Query Studio's current run mode and place info. */ - queryStateAsync(): Promise; - - /** Capture a viewport screenshot. */ - captureScreenshotAsync(): Promise; - - /** Retrieve buffered log history. */ - queryLogsAsync(options?: LogOptions): Promise; - - /** Query the DataModel instance tree. */ - queryDataModelAsync(options: QueryDataModelOptions): Promise; - - /** Subscribe to push events. */ - subscribeAsync(events: SubscribableEvent[]): Promise; - - /** Unsubscribe from push events. */ - unsubscribeAsync(events: SubscribableEvent[]): Promise; - - /** - * Follow log output as an async iterable. Yields log entries - * as they arrive. Ends when the session disconnects or the - * iterable is broken out of. - */ - followLogs(options?: LogFollowOptions): AsyncIterable; - - // ── Events ── - - on(event: 'state-changed', listener: (state: StudioState) => void): this; - on(event: 'disconnected', listener: () => void): this; - on(event: 'log', listener: (entry: LogEntry) => void): this; -} -``` - -### 2.3.1 SubscribableEvent - -```typescript -type SubscribableEvent = 'stateChange' | 'logPush'; -``` - -- `stateChange` -- Studio run state transitions (Edit <-> Play <-> Pause). Delivered as `stateChange` push messages from the plugin. -- `logPush` -- Continuous log entries from LogService (all sources, all levels). Delivered as individual `logPush` push messages, one per log entry. This is distinct from `output` messages (which are batched and scoped to a single `execute` request). - -### 2.4 SessionInfo - -```typescript -interface SessionInfo { - /** Unique identifier for this session. */ - sessionId: string; - - /** Name of the place open in this Studio instance. */ - placeName: string; - - /** File path to the place file, if available. */ - placeFile?: string; - - /** Current Studio run mode. */ - state: StudioState; - - /** Version of the plugin running in this session. */ - pluginVersion: string; - - /** Protocol capabilities the plugin supports. */ - capabilities: Capability[]; - - /** - * When the plugin connected to the bridge host. - * This is a Date object in the public TypeScript API. The wire protocol - * uses a millisecond timestamp (number); the adapter converts on receipt. - * CLI/JSON output serializes this as an ISO 8601 string. - */ - connectedAt: Date; - - /** How this session was established. */ - origin: SessionOrigin; - - /** Which Studio VM context this session represents. */ - context: SessionContext; - - /** - * Stable identifier for the Studio instance. All context sessions from - * the same Studio share the same instanceId (e.g., Edit, Client, Server - * contexts in Play mode all share one instanceId). - */ - instanceId: string; - - /** The Roblox place ID, or 0 for unsaved places. */ - placeId: number; - - /** The Roblox game (universe) ID, or 0 for unsaved places. */ - gameId: number; -} -``` - -### 2.4.1 SessionContext - -```typescript -/** - * Identifies which Studio VM a session belongs to. - * - * - 'edit' -- The Edit context. Always present. This is the plugin instance - * that runs in the normal Studio editing environment. - * - 'client' -- The Client context. Present only during Play mode (Play, Play - * Here, or Run). Runs in the client-side VM. - * - 'server' -- The Server context. Present only during Play mode. Runs in - * the server-side VM. - */ -type SessionContext = 'edit' | 'client' | 'server'; -``` - -### 2.4.2 InstanceInfo - -```typescript -/** - * Metadata about a Studio instance, grouping all of its context sessions. - * Returned by BridgeConnection.listInstances(). - */ -interface InstanceInfo { - /** Stable identifier for this Studio instance. */ - instanceId: string; - - /** Name of the place open in this Studio instance. */ - placeName: string; - - /** The Roblox place ID, or 0 for unsaved places. */ - placeId: number; - - /** The Roblox game (universe) ID, or 0 for unsaved places. */ - gameId: number; - - /** Which contexts are currently connected (e.g., ['edit'] or ['edit', 'client', 'server']). */ - contexts: SessionContext[]; - - /** How this instance's sessions were established. */ - origin: SessionOrigin; -} -``` - -### 2.5 SessionOrigin - -```typescript -/** - * 'user' -- The developer opened Studio manually and the persistent - * plugin discovered the bridge host on its own. - * 'managed' -- studio-bridge launched Studio and injected/waited for the plugin. - */ -type SessionOrigin = 'user' | 'managed'; -``` - -### 2.6 Result types - -Result types for each action are defined in `src/bridge/types.ts`: - -- `ExecResult` -- wraps `StudioBridgeResult` (success, output, error) -- `StateResult` -- `{ state, placeId, placeName, gameId }` -- `ScreenshotResult` -- `{ data (base64), format, width, height }` -- `LogsResult` -- `{ entries[], total, bufferCapacity }` -- `DataModelResult` -- `{ instance: DataModelInstance }` -- `LogEntry` -- `{ level, body, timestamp }` - -These are re-exports of the protocol payload types from `01-protocol.md`, surfaced through the public API so consumers never import from the protocol module directly. - -### 2.7 Error types - -All errors from the Bridge Network are typed error classes, catchable and inspectable: - -```typescript -class SessionNotFoundError extends Error { - readonly sessionId: string; -} - -class HostUnreachableError extends Error { - readonly host: string; - readonly port: number; -} - -class ActionTimeoutError extends Error { - readonly action: string; - readonly timeoutMs: number; - readonly sessionId: string; -} - -class SessionDisconnectedError extends Error { - readonly sessionId: string; -} - -class CapabilityNotSupportedError extends Error { - readonly capability: string; - readonly sessionId: string; -} - -class ContextNotFoundError extends Error { - /** The context that was requested but not found. */ - readonly context: SessionContext; - /** The instanceId the context was looked up on. */ - readonly instanceId: string; - /** The contexts that ARE available on this instance. */ - readonly availableContexts: SessionContext[]; -} - -class PortInUseError extends Error { - readonly port: number; -} - -class HandOffFailedError extends Error { - readonly reason: string; -} -``` - -No silent failures. Every error path either rejects a promise, throws an exception, or emits an `'error'` event on the connection. - -## 3. Internal Architecture - -### 3.1 Layer diagram - -``` -┌─────────────────────────────────────────────────────┐ -│ PUBLIC API │ -│ │ -│ BridgeConnection BridgeSession SessionInfo │ -│ Result types Error types Events │ -│ │ -│ (src/bridge/bridge-connection.ts) │ -│ (src/bridge/bridge-session.ts) │ -│ (src/bridge/types.ts) │ -├─────────────────────────────────────────────────────┤ -│ ROLE DETECTION │ -│ │ -│ connectAsync() → try bind port │ -│ Success → create BridgeHost (become host) │ -│ EADDRINUSE → create BridgeClient (connect) │ -│ remoteHost set → create BridgeClient directly │ -│ Devcontainer → try remote, fall back to local │ -│ │ -│ (src/bridge/bridge-connection.ts, internal only) │ -├─────────────────────────────────────────────────────┤ -│ BRIDGE HOST │ -│ │ -│ session-tracker.ts host-protocol.ts hand-off.ts│ -│ │ -│ Manages plugin connections, routes client requests, │ -│ tracks sessions, handles host transfer. │ -│ │ -│ (src/bridge/internal/bridge-host.ts) │ -│ (src/bridge/internal/session-tracker.ts) │ -│ (src/bridge/internal/host-protocol.ts) │ -│ (src/bridge/internal/hand-off.ts) │ -├─────────────────────────────────────────────────────┤ -│ BRIDGE CLIENT │ -│ │ -│ Connects to an existing host, forwards action │ -│ requests via host-protocol envelopes, receives │ -│ forwarded responses. │ -│ │ -│ (src/bridge/internal/bridge-client.ts) │ -├─────────────────────────────────────────────────────┤ -│ TRANSPORT │ -│ │ -│ transport-server.ts transport-client.ts │ -│ transport-handle.ts health-endpoint.ts │ -│ │ -│ Low-level WebSocket server/client. HTTP upgrade, │ -│ connection management, reconnection, backoff. │ -│ No business logic. │ -├─────────────────────────────────────────────────────┤ -│ WEBSOCKET (ws library) │ -└─────────────────────────────────────────────────────┘ -``` - -### 3.2 File layout - -``` -src/bridge/ - index.ts PUBLIC: re-exports ONLY BridgeConnection, BridgeSession, types - bridge-connection.ts BridgeConnection class (public API, orchestrates role detection) - bridge-session.ts BridgeSession class (public API, delegates to transport handles) - types.ts SessionInfo, SessionOrigin, result types, option types, error types - - internal/ - bridge-host.ts WebSocket server on port 38741, plugin + client management - bridge-client.ts WebSocket client connecting to existing host - transport-server.ts Low-level WebSocket/HTTP server - transport-client.ts Low-level WebSocket client with reconnection - transport-handle.ts TransportHandle interface (abstraction over a connection to a plugin) - health-endpoint.ts HTTP /health endpoint handler - session-tracker.ts In-memory session map with event emission - host-protocol.ts Client-to-host envelope messages - hand-off.ts Host transfer logic (graceful + crash recovery) - environment-detection.ts isDevcontainer(), getDefaultRemoteHost() -``` - -### 3.3 Import rules - -``` -src/bridge/index.ts Re-exports public API only. No internal/ types leak out. -src/bridge/bridge-connection.ts May import from internal/ (it orchestrates networking). -src/bridge/bridge-session.ts May import from internal/ (it delegates to transport handles). -src/bridge/types.ts No imports from internal/ (pure type definitions). -src/bridge/internal/*.ts May import from each other. NEVER imported outside src/bridge/. -``` - -The key rule: **nothing outside `src/bridge/` may import from `src/bridge/internal/`**. If a consumer needs something from the internal layer, the correct fix is to add it to the public API surface in `src/bridge/index.ts`, not to reach into internals. - -### 3.4 Internal module responsibilities - -#### transport-server.ts - -Low-level WebSocket server implementation. Handles: -- HTTP server creation and port binding -- WebSocket upgrade for `/plugin` and `/client` paths -- HTTP GET handler for `/health` (delegates to health-endpoint.ts) -- Connection lifecycle (open, message, close, error) -- WebSocket configuration: `maxPayload: 16MB`, `perMessageDeflate: true` -- WebSocket-level ping/pong every 30 seconds - -Does NOT handle: message parsing, session tracking, protocol logic, hand-off. It is a dumb pipe that emits connection and message events. - -```typescript -interface TransportServer { - listenAsync(port: number): Promise; - close(): void; - on(event: 'plugin-connection', listener: (ws: WebSocket, req: IncomingMessage) => void): this; - on(event: 'client-connection', listener: (ws: WebSocket, req: IncomingMessage) => void): this; - on(event: 'health-request', listener: (req: IncomingMessage, res: ServerResponse) => void): this; - readonly port: number; -} -``` - -#### transport-client.ts - -Low-level WebSocket client with automatic reconnection. Handles: -- WebSocket connection to a target URL -- Reconnection with exponential backoff (1s, 2s, 4s, 8s, max 30s) -- Connection state tracking (connecting, connected, disconnected, reconnecting) -- Message send/receive - -Does NOT handle: message parsing, protocol logic, host-protocol envelopes. It is a dumb pipe. - -```typescript -interface TransportClient { - connectAsync(url: string): Promise; - disconnect(): void; - send(data: string): void; - on(event: 'message', listener: (data: string) => void): this; - on(event: 'connected', listener: () => void): this; - on(event: 'disconnected', listener: () => void): this; - on(event: 'error', listener: (error: Error) => void): this; - readonly isConnected: boolean; -} -``` - -#### transport-handle.ts - -Abstraction over "I have a connection to a Studio plugin and can send it actions." Both the bridge host (which has a direct WebSocket to the plugin) and the bridge client (which forwards through the host) implement this interface. `BridgeSession` delegates to a `TransportHandle` without knowing which kind it is. - -```typescript -interface TransportHandle { - /** Send a protocol message to the plugin and wait for the response. */ - sendActionAsync(message: ServerMessage, timeoutMs: number): Promise; - - /** Send a one-way message (no response expected). */ - sendMessage(message: ServerMessage): void; - - /** Whether the connection to the plugin is alive. */ - readonly isConnected: boolean; - - on(event: 'message', listener: (msg: PluginMessage) => void): this; - on(event: 'disconnected', listener: () => void): this; -} -``` - -**Host-side TransportHandle** (DirectTransportHandle): wraps a WebSocket connection directly to the plugin. `sendActionAsync` writes to the WebSocket, registers a pending request in `PendingRequestMap`, and waits for the correlated response. - -**Client-side TransportHandle** (RelayedTransportHandle): wraps a connection to the bridge host. `sendActionAsync` wraps the action in a `HostEnvelope`, sends it to the host, and waits for the host to forward the response back. - -#### session-tracker.ts - -In-memory map of session ID to session state, with instance-level grouping by `instanceId`. Used exclusively by `bridge-host.ts`. Emits events when sessions are added, removed, or updated, and when instance groups are created or removed. - -```typescript -interface SessionTracker { - addSession(sessionId: string, info: SessionInfo, handle: TransportHandle): void; - removeSession(sessionId: string): void; - getSession(sessionId: string): TrackedSession | undefined; - listSessions(): SessionInfo[]; - updateSessionState(sessionId: string, state: StudioState): void; - - // ── Instance-level access ── - - /** - * List unique instances. Each instance groups 1-3 context sessions - * that share the same instanceId. - */ - listInstances(): InstanceInfo[]; - - /** - * Get all sessions for a given instanceId. Returns sessions for - * all connected contexts (edit, client, server). - */ - getSessionsByInstance(instanceId: string): TrackedSession[]; - - /** - * Get a specific context session for an instance. - * Returns undefined if the context is not connected. - */ - getSessionByContext(instanceId: string, context: SessionContext): TrackedSession | undefined; - - // ── Events ── - - on(event: 'session-added', listener: (session: TrackedSession) => void): this; - on(event: 'session-removed', listener: (sessionId: string) => void): this; - on(event: 'session-updated', listener: (session: TrackedSession) => void): this; - on(event: 'instance-added', listener: (instance: InstanceInfo) => void): this; - on(event: 'instance-removed', listener: (instanceId: string) => void): this; -} - -interface TrackedSession { - info: SessionInfo; - handle: TransportHandle; - lastHeartbeat: Date; -} -``` - -**Instance grouping logic:** - -When `addSession()` is called, the tracker groups the session by its `info.instanceId`. If this is the first session for that `instanceId`, a new instance group is created and the `instance-added` event fires. If sessions already exist for that `instanceId` (e.g., Play mode adding Client/Server contexts), the instance group's `contexts` array is updated. - -When `removeSession()` is called, the session is removed from the instance group. If this was the last session for that `instanceId` (all contexts disconnected), the instance group is removed and the `instance-removed` event fires. - -Sessions are tracked entirely in-memory. There are no files on disk, no lock files, no PID-based stale session detection. A session exists if and only if its plugin is currently connected to the bridge host. - -#### host-protocol.ts - -The envelope protocol for client-to-host communication. When a bridge client needs to send an action to session X, it wraps the action in a host-protocol envelope and sends it to the host. The host unwraps the envelope, forwards the action to the plugin, collects the response, wraps it in a response envelope, and sends it back to the client. - -```typescript -// Client → Host -interface HostEnvelope { - type: 'host-envelope'; - requestId: string; // client-generated, for correlating the host response - targetSessionId: string; // which plugin session to route to - action: ServerMessage; // the actual protocol message to forward -} - -interface ListSessionsRequest { - type: 'list-sessions'; - requestId: string; -} - -interface HostTransferNotice { - type: 'host-transfer'; - // Sent by the host to all clients when it is shutting down gracefully -} - -interface HostReadyNotice { - type: 'host-ready'; - // Sent by the new host to remaining clients after takeover -} - -// Host → Client -interface HostResponse { - type: 'host-response'; - requestId: string; // echoes the client's requestId - result: PluginMessage; // the plugin's response, unwrapped -} - -interface ListSessionsResponse { - type: 'list-sessions-response'; - requestId: string; - sessions: SessionInfo[]; -} - -interface SessionEvent { - type: 'session-event'; - event: 'connected' | 'disconnected' | 'state-changed'; - session?: SessionInfo; // present for connected/state-changed (includes context, instanceId) - sessionId: string; - context: SessionContext; // which VM context this event relates to - instanceId: string; // which Studio instance this event relates to -} - -type HostProtocolMessage = - | HostEnvelope - | ListSessionsRequest - | HostTransferNotice - | HostReadyNotice - | HostResponse - | ListSessionsResponse - | SessionEvent; -``` - -#### bridge-host.ts - -The bridge host is the "source of truth" for session state. It runs the WebSocket server on port 38741 and manages two classes of connections: - -**Plugin connections** (`/plugin` path): -- Plugin connects, sends `register` (or `hello`) with a plugin-generated UUID as `sessionId`, plus `instanceId` and `context`. Host accepts the proposed session ID (or overrides it on collision), creates a session entry in `SessionTracker` (grouped by `instanceId`), and responds with `welcome` containing the authoritative `sessionId`. -- In Play mode, up to 3 plugins from the same Studio connect with the same `instanceId` but different `context` values (edit, client, server) -- Plugin sends heartbeats, host updates `lastHeartbeat` -- Plugin sends responses to actions, host forwards to the appropriate client (or resolves locally if this process initiated the action) -- Plugin disconnects, host removes session after a brief grace period (2 seconds, to handle transient network blips). Instance group is removed only when ALL contexts disconnect. - -**Client connections** (`/client` path): -- Client connects, host tracks it in a client list -- Client sends `HostEnvelope`, host unwraps, looks up the target session in `SessionTracker`, forwards the action to the plugin via the session's `TransportHandle` -- Plugin responds, host wraps the response in a `HostResponse` and sends it back to the requesting client -- Client sends `ListSessionsRequest`, host responds with current session list -- Host emits `SessionEvent` to all connected clients when sessions connect/disconnect/change state - -Only one bridge host exists per machine (per port). This is enforced by port binding -- two processes cannot bind the same port. - -#### bridge-client.ts - -A bridge client connects to an existing bridge host on port 38741 (or a configured remote host). From the consumer's perspective, it behaves identically to being the host. The difference is entirely internal: actions are forwarded through the host rather than delivered directly to plugins. - -The bridge client: -- Connects to `ws://host:port/client` using `TransportClient` -- Creates `RelayedTransportHandle` instances for each session (which wrap actions in `HostEnvelope` and forward to the host) -- Listens for `SessionEvent` messages from the host to maintain a local mirror of the session list -- On disconnect from the host, enters the hand-off flow (attempt to become the new host) - -#### hand-off.ts - -Host transfer logic for when the bridge host process exits. - -**Graceful exit** (Ctrl+C, normal shutdown, `disconnectAsync()`): -1. Host sends `HostTransferNotice` to all connected clients -2. Clients receive the notice and enter "takeover standby" mode -3. Host closes the WebSocket server, freeing the port -4. First client to successfully bind port 38741 becomes the new host -5. New host sends `HostReadyNotice` to remaining clients -6. Remaining clients reconnect to the new host as clients -7. Plugins detect the WebSocket close, poll `/health`, and reconnect when the new host is ready - -**Crash / kill -9** (no graceful message possible): -1. Clients detect WebSocket disconnect (close or error event) -2. Each client waits a random jitter (0-500ms) to avoid thundering herd -3. First client to successfully bind port 38741 becomes the new host -4. Remaining clients retry connecting to port 38741 -5. Plugins detect the WebSocket close, poll `/health`, and reconnect - -**No clients connected**: -1. Host exits, port is freed -2. Plugins poll `/health`, get connection refused, continue polling with backoff -3. Next CLI process to start binds the port and becomes the new host -4. Plugins discover the new host on the next poll cycle - -#### health-endpoint.ts - -HTTP GET handler for the `/health` path. Returns a JSON health check response. Used by the persistent plugin for discovery and by diagnostic tools. - -```typescript -// GET /health → 200 OK -interface HealthResponse { - status: 'ok'; - port: number; - protocolVersion: number; - serverVersion: string; - sessions: number; - uptime: number; // milliseconds since the host started -} -``` - -#### environment-detection.ts - -Detects whether the process is running inside a devcontainer and provides the default remote host for auto-connection. - -```typescript -function isDevcontainer(): boolean { - return !!( - process.env.REMOTE_CONTAINERS || - process.env.CODESPACES || - process.env.CONTAINER || - existsSync('/.dockerenv') - ); -} - -function getDefaultRemoteHost(): string | null { - if (isDevcontainer()) { - return `localhost:${DEFAULT_BRIDGE_PORT}`; - } - return null; -} -``` - -## 4. Role Detection and Startup - -When `BridgeConnection.connectAsync()` is called, the following decision flow executes. This is entirely internal -- the consumer sees a promise that resolves with a working connection regardless of which path was taken. - -``` -connectAsync(options) called -│ -├── options.remoteHost is set? -│ YES → connect to host at remoteHost as client via bridge-client.ts -│ ├── success → role = 'client', done -│ └── failure → throw HostUnreachableError -│ -├── isDevcontainer() is true? -│ YES → try connecting to localhost:38741 as client -│ ├── success → role = 'client', done -│ └── failure → warn("No bridge host found, falling back to local mode") -│ continue to local bind attempt below -│ -├── Try to bind port (options.port or 38741) -│ ├── success → this process is the HOST -│ │ start bridge-host.ts with TransportServer -│ │ role = 'host', done -│ │ -│ ├── EADDRINUSE → port is taken, try connecting as client -│ │ ├── connect to ws://localhost:{port}/client -│ │ │ ├── success → role = 'client', done -│ │ │ └── failure → port is held by a non-bridge process -│ │ │ OR previous host crashed and OS hasn't released port -│ │ │ wait 1 second, retry bind -│ │ │ (up to 3 retries, then throw HostUnreachableError) -│ │ -│ └── other error → throw with clear message -│ -└── timeout after options.timeoutMs → throw ActionTimeoutError -``` - -### 4.1 Stale port detection - -If port 38741 is bound but the process holding it is not a bridge host (or is a crashed host whose OS hasn't released the socket), the client connection attempt will fail with a connection refused or handshake error. In this case, the connection logic waits briefly (1 second) and retries the bind, up to 3 times. If all retries fail, it throws `HostUnreachableError` with a message explaining that the port is held by another process. - -### 4.2 Multiple processes starting simultaneously - -If two CLI processes start at nearly the same time and both attempt to bind the port, exactly one will succeed (OS guarantees atomic port binding). The other will get `EADDRINUSE` and connect as a client. This is correct behavior with no race condition. - -## 5. Host-Client Protocol - -When a bridge client needs to reach a plugin session, the action is forwarded through the bridge host. This section describes the forwarding protocol in detail. - -### 5.1 Request flow - -``` -Consumer BridgeSession Bridge Client Bridge Host Plugin - │ │ │ │ │ - │ session.execAsync(code) │ │ │ │ - │ ─────────────────────────>│ │ │ │ - │ │ │ │ │ - │ │ sendActionAsync() │ │ │ - │ │ (RelayedTransport │ │ │ - │ │ Handle) │ │ │ - │ │ ──────────────────────>│ │ │ - │ │ │ │ │ - │ │ │ HostEnvelope { │ │ - │ │ │ target: sessionId │ │ - │ │ │ action: execute │ │ - │ │ │ requestId: "r-01" │ │ - │ │ │ } │ │ - │ │ │ ─────────────────────>│ │ - │ │ │ │ │ - │ │ │ │ execute { │ - │ │ │ │ script: code │ - │ │ │ │ requestId: "r-01" │ - │ │ │ │ } │ - │ │ │ │ ────────────────────> │ - │ │ │ │ │ - │ │ │ │ scriptComplete { │ - │ │ │ │ requestId: "r-01" │ - │ │ │ │ success: true │ - │ │ │ │ } │ - │ │ │ │ <──────────────────── │ - │ │ │ │ │ - │ │ │ HostResponse { │ │ - │ │ │ requestId: "r-01" │ │ - │ │ │ result: script- │ │ - │ │ │ Complete │ │ - │ │ │ } │ │ - │ │ │ <─────────────────────│ │ - │ │ │ │ │ - │ │ resolve(result) │ │ │ - │ │ <──────────────────────│ │ │ - │ │ │ │ │ - │ ExecResult │ │ │ │ - │ <─────────────────────────│ │ │ │ -``` - -### 5.2 Direct host flow - -When the consumer's process IS the bridge host, the flow is shorter -- no forwarding envelope is needed: - -``` -Consumer BridgeSession Bridge Host Plugin - │ │ │ │ - │ session.execAsync(code) │ │ │ - │ ─────────────────────────>│ │ │ - │ │ │ │ - │ │ sendActionAsync() │ │ - │ │ (DirectTransport │ │ - │ │ Handle) │ │ - │ │ ──────────────────────>│ │ - │ │ │ │ - │ │ │ execute { │ - │ │ │ requestId: "r-01" │ - │ │ │ } │ - │ │ │ ────────────────────> │ - │ │ │ │ - │ │ │ scriptComplete { │ - │ │ │ requestId: "r-01" │ - │ │ │ } │ - │ │ │ <──────────────────── │ - │ │ │ │ - │ │ resolve(result) │ │ - │ │ <──────────────────────│ │ - │ │ │ │ - │ ExecResult │ │ │ - │ <─────────────────────────│ │ │ -``` - -The key insight: `BridgeSession` delegates to a `TransportHandle`. Whether that handle is a `DirectTransportHandle` (host) or a `RelayedTransportHandle` (client) is invisible to `BridgeSession` and therefore invisible to the consumer. The `TransportHandle` interface is the abstraction boundary. - -### 5.3 Push message forwarding (subscription routing) - -Push messages from the plugin (`stateChange`, `logPush`, `heartbeat`) are forwarded by the bridge host to subscribed clients using **WebSocket push**. This is the transport mechanism for `--watch` (state) and `--follow` (logs) features. The host maintains a per-session subscription map that tracks which clients are subscribed to which event types. - -#### Subscription data flow - -The full subscription lifecycle is a 5-step WebSocket push flow: - -``` -CLI/Client Bridge Host Plugin - │ │ │ - │ 1. subscribe { │ │ - │ events: ['stateChange'] │ │ - │ } │ │ - │ ─────────────────────────────>│ │ - │ │ 2. subscribe { │ - │ │ events: ['stateChange'] │ - │ │ } │ - │ │ ─────────────────────────────>│ - │ │ │ - │ │ 3. subscribeResult { │ - │ │ events: ['stateChange'] │ - │ │ } │ - │ │ <─────────────────────────────│ - │ subscribeResult │ │ - │ <─────────────────────────────│ │ - │ │ │ - │ │ 4. stateChange { │ - │ │ previousState, newState │ - │ │ } │ - │ │ <─────────────────────────────│ - │ │ │ - │ stateChange (forwarded) │ (host checks subscription │ - │ <─────────────────────────────│ map, forwards to all │ - │ │ subscribed clients) │ - │ │ │ - │ 5. unsubscribe { │ │ - │ events: ['stateChange'] │ │ - │ } │ │ - │ ─────────────────────────────>│ │ - │ │ unsubscribe (forwarded) │ - │ │ ─────────────────────────────>│ - │ │ │ - │ unsubscribeResult │ unsubscribeResult │ - │ <─────────────────────────────│ <─────────────────────────────│ -``` - -#### Host subscription map - -The bridge host maintains an in-memory subscription map per session: - -```typescript -// Internal to bridge-host.ts -type SubscriptionMap = Map< - string, // sessionId - Map> // clientId -> subscribed events ->; -``` - -When a client sends a `subscribe` message (wrapped in a `HostEnvelope`): -1. The host records the client's subscription in the map: `subscriptions.get(sessionId).get(clientId).add(event)`. -2. The host forwards the `subscribe` message to the plugin (so the plugin knows to start pushing events). -3. The plugin responds with `subscribeResult`, which the host forwards back to the client. - -When a push message arrives from a plugin (`stateChange` or `logPush`): -1. The host looks up the session's subscription map. -2. For each client that has subscribed to the event type matching the push message, the host forwards the push message to that client (wrapped in a `HostResponse` with a synthetic envelope). -3. Clients that are NOT subscribed to that event type do NOT receive the push message. - -When a client sends an `unsubscribe` message: -1. The host removes the event from the client's subscription set. -2. The host forwards the `unsubscribe` to the plugin. -3. If no clients remain subscribed to a given event type for that session, the host may optionally forward an `unsubscribe` to the plugin to stop the push stream (optimization, not required for correctness). - -When a client disconnects: -1. The host removes all of that client's subscriptions from the map. -2. If no clients remain subscribed to a given event type, the host may send `unsubscribe` to the plugin. - -When a plugin disconnects: -1. The host removes the session's entire subscription map entry. -2. Any clients that were subscribed to that session's events stop receiving pushes (the session no longer exists). - -#### Direct host flow (host process is also the consumer) - -When the consumer's process IS the bridge host (no client forwarding needed), subscriptions are handled locally: -1. `BridgeSession.subscribeAsync()` sends `subscribe` directly to the plugin via the `DirectTransportHandle`. -2. Push messages from the plugin arrive on the `TransportHandle`'s `message` event. -3. `BridgeSession` dispatches them to the appropriate event listeners (`'state-changed'`, `'log'`). -4. No subscription map is needed -- the host process receives all messages from its directly-connected plugins. - -#### Heartbeat messages - -`heartbeat` messages are NOT subscription-gated. The host always receives heartbeats from connected plugins (for session liveness tracking). Heartbeat messages are NOT forwarded to clients -- they are consumed internally by the host's session tracker. - -### 5.4 Session event broadcasting - -When a session connects or disconnects, the host broadcasts a `SessionEvent` to ALL connected clients (not just those subscribed to that session). This is how clients maintain their session list in sync with the host. - -## 6. Session Lifecycle - -### 6.1 Plugin connection (session creation) - -1. Plugin opens a WebSocket to `ws://localhost:38741/plugin` -2. Plugin sends `register` message (v2) or `hello` message (v1) with session metadata (including a plugin-generated UUID as `sessionId`, plus `instanceId`, `context`, `placeId`, `gameId`) -3. Bridge host validates the message, accepts the plugin's proposed session ID (or overrides it if there is a collision with an existing session), creates a `TrackedSession` in `SessionTracker` (grouped by `instanceId`) -4. Bridge host responds with `welcome` containing the authoritative `sessionId` (which confirms or overrides the plugin's proposed ID) and negotiated capabilities (for v2). The plugin must use this `sessionId` for all subsequent messages. -5. Bridge host emits `session-added` event on `SessionTracker`. If this is the first session for the `instanceId`, `instance-added` also fires. -6. All connected clients receive a `SessionEvent { event: 'connected', session: SessionInfo }` (includes `context` and `instanceId`) -7. `BridgeConnection` emits `'session-connected'` to consumer code. If the instance is new, `'instance-connected'` also fires. - -### 6.2 Plugin heartbeat - -- Plugin sends `heartbeat` every 15 seconds with current state and uptime -- Bridge host updates `lastHeartbeat` timestamp on the `TrackedSession` -- If no heartbeat is received for 45 seconds (3 missed heartbeats), the host marks the session as stale -- If no heartbeat is received for 60 seconds (4 missed heartbeats), the host removes the session and emits `session-disconnected` - -### 6.3 Plugin disconnection (session removal) - -1. Plugin's WebSocket closes (Studio closed, crash, network drop, or explicit `shutdown`) -2. Bridge host starts a 2-second grace period (to handle transient network blips) -3. If the plugin reconnects within the grace period (same `instanceId` AND same `context`), the session is updated, not duplicated -4. If the grace period expires without reconnection, the session is removed from `SessionTracker` -5. Bridge host emits `session-removed` event. If this was the last session for the `instanceId`, `instance-removed` also fires. -6. All connected clients receive a `SessionEvent { event: 'disconnected', sessionId }` (includes `context` and `instanceId`) -7. `BridgeConnection` emits `'session-disconnected'` to consumer code. If the instance group was removed, `'instance-disconnected'` also fires. - -### 6.4 Plugin reconnection (same instance + context) - -When a persistent plugin reconnects after a temporary disconnect (e.g., the bridge host restarted): -1. Plugin sends `register` with the same `instanceId` AND `context` it used before -2. Bridge host checks `SessionTracker` for an existing session with that `instanceId` AND `context` pair (not just `instanceId` alone -- this is important in Play mode where 3 contexts share one `instanceId`) -3. If found (within the grace period): update the session's WebSocket handle, reset heartbeat timer. No `session-connected` event (the session never truly disconnected from the consumer's perspective) -4. If not found (grace period expired): create a new session as in 6.1 - -### Session Reconnection Lifecycle - -When a plugin disconnects and reconnects with the same `(instanceId, context)`: - -1. Plugin WebSocket closes -> bridge host removes the session from its tracker -2. `BridgeConnection` emits `'session-disconnected'` with the old `sessionId` -3. Old `BridgeSession` handle becomes stale -- `isConnected` returns `false`, action methods reject with `SessionDisconnectedError` -4. Plugin reconnects -> sends `register` -> bridge host creates a **new** session with a new `sessionId` -5. `BridgeConnection` emits `'session-connected'` with a new `BridgeSession` -6. Consumers must re-resolve to get the new handle: `session = await conn.resolveSession()` - -**Key invariant**: `BridgeSession` objects are NOT reused across reconnections. Each connection produces a new handle. Consumers should listen for `'session-disconnected'` and re-resolve. - -### 6.5 Play mode transitions (multi-context lifecycle) - -When Studio enters Play mode, 2 new plugin instances (server and client) connect, joining the already-connected edit instance. The bridge host handles this as follows: - -**Entering Play mode:** -1. Studio starts Play mode. The Edit context's plugin remains connected (its session already exists). -2. The Server VM loads a new plugin instance. It connects to the bridge host and sends `register` with the same `instanceId` as the Edit session but `context: 'server'`. -3. The Client VM loads a new plugin instance. It connects and sends `register` with the same `instanceId` and `context: 'client'`. -4. The bridge host now has 3 sessions grouped under one `instanceId`. The `InstanceInfo.contexts` array is `['edit', 'server', 'client']`. -5. Consumers calling `resolveSession()` continue to get the Edit context by default (no disruption to in-flight work). - -**Leaving Play mode (Stop button):** -1. Studio stops the Play session. The Client and Server VMs are destroyed. -2. The Client and Server plugins' WebSocket connections close. -3. The bridge host removes those two sessions from the `SessionTracker`. The instance group remains (Edit context is still connected). -4. The `InstanceInfo.contexts` array returns to `['edit']`. -5. `session-disconnected` events fire for the Client and Server sessions, but `instance-disconnected` does NOT fire (the Edit context keeps the instance alive). - -**Plugin reconnection during Play mode:** -When matching a reconnecting plugin to an existing session, the bridge host uses the `(instanceId, context)` pair as the key -- not `instanceId` alone. This prevents a reconnecting Server context from accidentally matching an Edit context session (or vice versa). - -### 6.6 Idle shutdown - -When the bridge host has no active CLI commands and no connected clients: -- If `keepAlive: true` (set by `studio-bridge serve` or MCP server): host stays alive indefinitely -- If `keepAlive: false` (default, set by `exec`/`run` commands): host enters idle mode - - If any `user`-origin sessions are connected: host stays alive (it would be wrong to kill a manually-opened Studio's connection) - - If only `managed`-origin sessions or no sessions: host exits after a 5-second grace period - - The grace period allows rapid re-invocation (e.g., running `studio-bridge exec` twice in a row) without losing the session - -### 6.7 resolveSession() algorithm (instance-aware resolution) - -The `resolveSession(sessionId?, context?, instanceId?)` method is the primary way consumers target a session. It is instance-aware: it groups sessions by `instanceId` and selects a context within the matched instance. This algorithm is shared between the CLI (via `--session`, `--instance`, and `--context` flags), the MCP server (via `sessionId`, `instanceId`, and `context` tool parameters), and the terminal (via `.connect` dot-command). - -``` -resolveSession(sessionId?, context?, instanceId?) { - 1. If sessionId is provided: - → Look up the session by ID. - → If found, return it. - → If not found, throw SessionNotFoundError. - - 2. If instanceId is provided: - → Look up the instance by instanceId. - → If not found, throw SessionNotFoundError. - → If found, apply context selection (step 5a-5c below) within that instance. - - 3. Collect unique instances from SessionTracker.listInstances(). - - 4. If 0 instances: - → Wait up to timeoutMs for an instance to connect. - → If timeout expires, throw ActionTimeoutError. - → When an instance connects, continue to step 5. - - 5. If 1 instance: - a. If context is provided: - → Look up that context's session within the instance. - → If found, return it. - → If not found (e.g., --context server but Studio is in Edit mode): - throw ContextNotFoundError { - context, - instanceId, - availableContexts: instance.contexts - } - b. If instance has only 1 context (Edit mode): - → Return the Edit session. - c. If instance has multiple contexts (Play mode): - → Return the Edit context session (default). - - 6. If N instances (N > 1): - → Throw SessionNotFoundError with the instance list, e.g.: - "Multiple Studio instances connected. Use --session or --instance to select one." - List each instance with its instanceId, placeName, and connected contexts. -} -``` - -**Why Edit is the default in Play mode:** Most CLI operations (exec, query, run) target the Edit context because it represents the authoritative editing environment. Server and Client contexts are transient (destroyed when Play stops) and primarily useful for inspecting runtime state. Consumers who want to target the Server or Client context must explicitly pass `context: 'server'` or `context: 'client'`. - -## 7. Connection Types on Port 38741 - -The WebSocket server on port 38741 distinguishes connections by HTTP upgrade path: - -| Path | Source | Purpose | -|------|--------|---------| -| `/plugin` | Studio plugin (Luau) | Plugin upstream connection. Plugin sends `register`/`hello`, receives actions, sends responses and push messages. | -| `/client` | CLI process / MCP server | CLI downstream connection. Client sends host-protocol envelopes (`HostEnvelope`, `ListSessionsRequest`), receives `HostResponse` and `SessionEvent`. | -| `/health` | HTTP GET (any) | Health check endpoint. Returns JSON with host status, session count, uptime. Used by plugins for discovery. | - -All other HTTP paths return 404. The WebSocket upgrade is rejected for paths other than `/plugin` and `/client`. - -## 8. Testing Strategy - -The networking layer MUST be testable without Roblox Studio. This section describes the testing approach at each level. - -### 8.1 Mock plugin helper - -The foundation of all Bridge Network tests is a mock plugin: a test utility that simulates a Studio plugin connecting to the bridge host and responding to actions. - -```typescript -/** - * Test helper: simulates a Studio plugin connecting to the bridge host. - * - * Usage in tests: - * const host = await createBridgeHost({ port: 0 }); // ephemeral port - * const plugin = await createMockPlugin({ port: host.port }); - * await plugin.waitForWelcome(); - * // Now the host has one session - * - * // Configure responses - * plugin.onAction('queryState', () => ({ - * type: 'stateResult', - * payload: { state: 'Edit', placeId: 0, placeName: 'Test', gameId: 0 } - * })); - * - * // Test consumer code - * const connection = await BridgeConnection.connectAsync({ port: host.port }); - * const session = await connection.waitForSession(); - * const state = await session.queryStateAsync(); - * expect(state.state).toBe('Edit'); - */ -interface MockPlugin { - /** Connect to the bridge host on the specified port. */ - connectAsync(port: number): Promise; - - /** Wait for the welcome message from the host. */ - waitForWelcome(): Promise; - - /** Register a response handler for a specific action type. */ - onAction(type: string, handler: (payload: unknown) => PluginMessage): void; - - /** Send a push message (heartbeat, stateChange, logPush, output). */ - sendPush(message: PluginMessage): void; - - /** Disconnect from the host. */ - disconnect(): void; - - /** The authoritative session ID (from the host's welcome response -- confirms or overrides the plugin-proposed ID). */ - readonly sessionId: string; -} - -function createMockPlugin(options?: MockPluginOptions): MockPlugin; - -interface MockPluginOptions { - port?: number; - instanceId?: string; - context?: SessionContext; // default: 'edit' - placeName?: string; - placeId?: number; - gameId?: number; - capabilities?: Capability[]; - protocolVersion?: number; -} -``` - -This mock plugin is essential for testing everything above the transport layer. It lives in `src/test/helpers/mock-plugin-client.ts` and is used by both unit and integration tests. - -### 8.2 Unit tests - -Each internal module is tested in isolation with mocked dependencies. - -| Module | Test strategy | -|--------|--------------| -| `transport-server.ts` | Create server on ephemeral port, connect raw WebSocket, verify upgrade paths, verify health endpoint | -| `transport-client.ts` | Create a local WebSocket server, connect client, verify reconnection with backoff after disconnect | -| `transport-handle.ts` | Mock WebSocket, verify `sendActionAsync` registers pending request and resolves on response | -| `session-tracker.ts` | Pure in-memory. Add/remove/update sessions, verify events, verify stale detection | -| `host-protocol.ts` | Verify envelope wrapping/unwrapping, request ID correlation | -| `hand-off.ts` | Simulate host shutdown, verify client takeover sequence. Test crash recovery with jitter | -| `health-endpoint.ts` | Verify JSON response format | -| `environment-detection.ts` | Mock `process.env` and `existsSync`, verify detection logic | - -### 8.3 Integration tests - -Full-stack tests using the mock plugin, exercising the complete request/response path. - -**Single session scenarios:** -- Connect mock plugin, create `BridgeConnection`, resolve session, execute action, verify response -- Plugin disconnects, verify `session-disconnected` event fires -- Plugin reconnects (same instanceId), verify session is restored without duplication - -**Multiple session scenarios:** -- Connect two mock plugins with different `instanceId`s, verify both appear in `listSessions()` and `listInstances()` -- Target a specific session by ID, verify action reaches the correct plugin -- `resolveSession()` with no ID throws when multiple instances exist -- Connect three mock plugins with the same `instanceId` but different contexts (edit, client, server), verify they group into one instance -- `resolveSession()` with one instance in Play mode returns Edit context by default -- `resolveSession(undefined, 'server')` returns the Server context session -- `resolveSession(undefined, 'server')` throws `ContextNotFoundError` when only Edit context exists -- Disconnect Client and Server contexts, verify instance remains with only Edit context -- Disconnect all contexts for an instance, verify `instance-disconnected` event fires - -**Host/client scenarios:** -- Start two `BridgeConnection` instances on the same port, verify first is host, second is client -- Client sends action, verify it is forwarded through host to plugin -- Client receives session events when plugins connect/disconnect - -**Host crash + client takeover:** -- Start host, connect client and mock plugin -- Kill the host (close its transport server) -- Verify client detects disconnect and attempts to become new host -- Verify mock plugin reconnects to the new host -- Verify actions continue to work through the new host - -**Reconnection:** -- Start host, connect mock plugin -- Temporarily disconnect the plugin's WebSocket -- Verify the plugin's session survives the grace period if it reconnects in time -- Verify the session is removed if the grace period expires - -**Timeout:** -- Send action to a mock plugin that does not respond -- Verify the action rejects with `ActionTimeoutError` after the configured timeout - -**Concurrent actions:** -- Send multiple actions to the same session simultaneously -- Verify all resolve with correct responses (request ID correlation) - -### 8.4 Test infrastructure - -- All tests use ephemeral ports (`port: 0`) to avoid conflicts with other tests or running instances -- Tests clean up all connections and servers in `afterEach` to prevent resource leaks -- The mock plugin helper supports configurable delays, errors, and partial responses for edge case testing -- Integration tests use a `TestHarness` that manages bridge host, clients, and mock plugins with a single `teardown()` call - -## 9. Configuration - -### 9.1 Port - -| Setting | Value | -|---------|-------| -| Default port | 38741 | -| Override via CLI | `--port ` | -| Override via env | `STUDIO_BRIDGE_PORT` | -| Override via options | `BridgeConnectionOptions.port` | - -### 9.2 Health endpoint - -``` -GET /health HTTP/1.1 -Host: localhost:38741 - -HTTP/1.1 200 OK -Content-Type: application/json - -{ - "status": "ok", - "port": 38741, - "protocolVersion": 2, - "serverVersion": "0.5.0", - "sessions": 2, - "uptime": 45230 -} -``` - -### 9.3 Timing constants - -| Constant | Value | Description | -|----------|-------|-------------| -| Heartbeat interval | 15 seconds | Plugin sends heartbeat to host | -| Heartbeat stale | 45 seconds | 3 missed heartbeats = mark session stale | -| Heartbeat disconnect | 60 seconds | 4 missed heartbeats = remove session | -| Session grace period | 2 seconds | Time before a disconnected session is removed | -| Idle shutdown delay | 5 seconds | Host waits before exiting when idle | -| Reconnection backoff | 1s, 2s, 4s, 8s, max 30s | Client and plugin reconnection | -| Stale port retry | 1 second, 3 retries | When port is bound but not a bridge host | -| Hand-off jitter | 0-500ms random | Prevents thundering herd on crash | -| WebSocket ping | 30 seconds | Low-level keep-alive (ws library) | - -### 9.4 WebSocket configuration - -| Setting | Value | -|---------|-------| -| Max frame size | 16 MB (`maxPayload: 16 * 1024 * 1024`) | -| Compression | Enabled (`perMessageDeflate: true`) | -| Ping interval | 30 seconds | - -### 9.5 Resource limits - -The bridge host enforces hard limits to prevent runaway resource usage. These are not configurable -- they are safe defaults that no legitimate workload should hit. - -| Resource | Limit | Behavior on exceed | -|----------|-------|--------------------| -| Max concurrent sessions | 20 | Reject new `register` with `SERVER_FULL` error | -| Max connected CLI clients | 50 | Reject new WebSocket upgrade with HTTP 503 | -| Max pending requests per session | 10 | Reject new `performActionAsync` with `TOO_MANY_REQUESTS` error | -| WebSocket max payload | 16 MB (`maxPayload: 16 * 1024 * 1024`) | Connection closed by ws library | -| Health endpoint response timeout | 500 ms | Returns 503 if internal state collection takes too long | - -These limits exist primarily to catch bugs (e.g., a leaked request loop) and to keep the host responsive under unexpected load. In normal usage, a single developer has 1-3 sessions (one Studio instance in Play mode) and 1-2 CLI clients. - -## 10. Error Handling - -### 10.1 Error surfacing principle - -Every error path in the Bridge Network either: -1. Rejects a promise with a typed error (e.g., `ActionTimeoutError`) -2. Emits an `'error'` event on `BridgeConnection` -3. Throws synchronously (for programming errors like calling methods after disconnect) - -There are no silent failures. No swallowed exceptions. No errors that disappear into a log without also being reported to the consumer. - -### 10.2 Error scenarios and their handling - -| Scenario | Error type | Where surfaced | -|----------|-----------|---------------| -| Port 38741 held by non-bridge process | `HostUnreachableError` | `connectAsync()` rejects | -| No plugin connects within timeout | `ActionTimeoutError` | `waitForSession()` rejects | -| Session ID not found | `SessionNotFoundError` | `getSession()` returns undefined; `resolveSession()` rejects | -| Requested context not connected | `ContextNotFoundError` | `resolveSession(undefined, 'server')` rejects when Studio is in Edit mode (Server context not available) | -| Multiple instances, no disambiguation | `SessionNotFoundError` | `resolveSession()` rejects with instance list when N > 1 instances and no `sessionId` provided | -| Plugin does not respond to action | `ActionTimeoutError` | `session.execAsync()` (or other action) rejects | -| Plugin responds with error | Protocol-specific error mapped to typed error | Action method rejects | -| Plugin disconnects mid-action | `SessionDisconnectedError` | In-flight action rejects | -| Plugin lacks required capability | `CapabilityNotSupportedError` | Action method rejects | -| Host crashes while client has in-flight action | `SessionDisconnectedError` | In-flight action rejects; then client attempts takeover | -| `serve` command and port already in use | `PortInUseError` | `connectAsync()` rejects (serve does not fall back to client) | -| Hand-off fails (no client can bind) | `HandOffFailedError` | Emitted on `'error'` event | - -### 10.3 Error recovery - -The Bridge Network attempts automatic recovery where possible: -- **Plugin disconnect**: grace period allows reconnection without consumer impact -- **Host crash**: clients automatically attempt takeover; plugins automatically reconnect -- **Transient network errors**: transport client reconnects with exponential backoff - -When automatic recovery fails, errors are surfaced to the consumer so they can decide how to respond (retry, abort, prompt the user, etc.). - -## 11. Security Model - -### 11.1 All connections are localhost - -The bridge host binds to `localhost` only (not `0.0.0.0`). No external network access is introduced. In split-server mode, the connection between container and host goes through secure port forwarding (SSH tunnel, VS Code forwarding), which is also effectively localhost on both ends. - -### 11.2 Plugin authentication - -Plugin connections are validated by the `register`/`hello` handshake. The bridge host verifies the message format before accepting the connection. Session IDs are UUIDv4 (128 bits of entropy), making them unguessable by other processes. - -### 11.3 Client authentication - -In the initial implementation, bridge client connections on `/client` are unauthenticated. This is acceptable because: -- All connections are localhost (or port-forwarded localhost through a secure tunnel) -- The threat model is preventing accidental cross-user access, not sandboxing within a single user session -- Any process running as the same user could already inspect the port and connect - -If a future requirement demands stricter isolation, a bearer token mechanism can be added to the bridge host's client connection handler without changing the public API. `BridgeConnectionOptions` would gain an `authToken?: string` field; the token would be passed in the WebSocket upgrade headers. - -## 12. Topology Summary - -``` -Studio A (Edit) ────────────┐ - │ /plugin WebSocket -Studio B (Edit) ────────────┤ -Studio B (Server) ──────────┼──────────> Bridge Host (:38741) -Studio B (Client) ──────────┤ │ - │ │ instanceId groups: -Studio C (Edit) ────────────┘ │ A: [edit] - │ B: [edit, server, client] - │ C: [edit] - │ - │ /client WebSocket - ┌───────────────┤ - │ │ - CLI (client) MCP server (client) - (exec, run, (studio_exec, - terminal) studio_state, ...) - │ │ - v v - BridgeConnection BridgeConnection - BridgeSession BridgeSession - │ │ - └───────┬───────┘ - │ - Consumer code - (identical in all cases) -``` - -Studio B is in Play mode: it has 3 plugin connections (Edit, Server, Client) all sharing one `instanceId`. The bridge host groups them into a single `InstanceInfo`. Studios A and C are in Edit mode with one connection each. - -The bridge host may be: -- **Implicit**: the first CLI process that happened to bind the port (most common for local development) -- **Explicit**: a dedicated `studio-bridge serve` process (for devcontainer/remote workflows) -- **Terminal**: a `studio-bridge terminal --keep-alive` process (explicit host with a REPL attached) - -In all cases, the consumer API is identical. `BridgeConnection.connectAsync()` resolves to a working connection regardless of the host's origin. diff --git a/studio-bridge/plans/tech-specs/08-host-failover.md b/studio-bridge/plans/tech-specs/08-host-failover.md deleted file mode 100644 index 66495e3f59..0000000000 --- a/studio-bridge/plans/tech-specs/08-host-failover.md +++ /dev/null @@ -1,1083 +0,0 @@ -# Bridge Host Failover: Technical Specification - -The bridge host is a single point of failure. Every plugin connection, every client connection, and every in-memory session lives inside one process on port 38741. When that process dies -- gracefully, violently, or anywhere in between -- every participant in the system is affected simultaneously. This document specifies exactly what happens in each failure mode, what each participant must do to recover, and what guarantees the system provides about recovery time. - -This spec builds on the hand-off protocol described in `07-bridge-network.md` section 6 (hand-off.ts) and the plugin reconnection logic in `03-persistent-plugin.md` section 6. Those documents describe the mechanisms; this document describes the failure taxonomy, the end-to-end recovery sequences, the edge cases that arise when multiple mechanisms interact, and the testing strategy for validating all of it. - -## 1. Failure Taxonomy - -The bridge host can fail in five distinct ways. Each produces different observable behavior for plugins and clients, and each constrains what recovery steps are possible. - -### 1.1 Graceful shutdown (SIGTERM, Ctrl+C) - -**What happens to the host:** The process receives SIGTERM or SIGINT. The Node.js process runs its shutdown handler, which has time to notify all connected participants before closing. - -**What the host can do:** -1. Send `host.shutting_down` (a `HostTransferNotice` message) to all connected clients -2. Send WebSocket close frames (code 1001, "Going Away") to all connected plugins -3. Close the HTTP server, releasing the port -4. Exit cleanly - -**What clients observe:** -- Receive `HostTransferNotice` message over their `/client` WebSocket -- Then receive a clean WebSocket close (code 1001) -- The close is expected -- the client knows the host is shutting down intentionally - -**What plugins observe:** -- Receive a WebSocket close frame (code 1001) -- The plugin transitions from `connected` to `searching` (not `reconnecting`, because the close was clean -- see `03-persistent-plugin.md` section 6.1) -- No backoff delay on clean disconnect; the plugin immediately begins polling `/health` - -**Recovery timeline:** Port is freed immediately after the server socket closes. A client can bind the port within milliseconds. Plugin discovers the new host on its next 2-second poll cycle. Total recovery: under 2 seconds. - -### 1.2 Hard kill (SIGKILL, kill -9) - -**What happens to the host:** The OS terminates the process immediately. No signal handlers run. No cleanup code executes. The TCP connections are torn down by the kernel. - -**What the host can do:** Nothing. The process is gone. - -**What clients observe:** -- WebSocket `close` or `error` event fires. The exact event depends on timing -- if the kernel sends RST packets, clients see an error; if FIN packets, clients see a close. -- No `HostTransferNotice` was received -- the client knows this was an unexpected death. - -**What plugins observe:** -- WebSocket `Closed` or `Error` event fires (Roblox WebSocket API) -- The plugin cannot distinguish between a hard kill and a network failure -- The plugin transitions from `connected` to `reconnecting` (because no `shutdown` message preceded the close) -- Backoff starts at 1 second (see `03-persistent-plugin.md` section 6.2) - -**Recovery timeline:** The kernel releases the port after TCP teardown, typically within 100-500ms. However, if the socket was in an active data transfer, the port may enter TIME_WAIT (see section 1.5). Without TIME_WAIT: a client can bind within 1 second. Plugin reconnects within 1-5 seconds depending on backoff position. Total recovery: under 5 seconds. - -### 1.3 Crash (unhandled exception, out-of-memory) - -**What happens to the host:** The Node.js process terminates due to an uncaught exception, unhandled promise rejection, or an OS-level OOM kill. The process exits with a non-zero code. Like SIGKILL, there is no opportunity for cleanup. - -**What the host can do:** If the crash is from an uncaught exception, the `uncaughtException` handler could attempt a brief notification. However, this is unreliable -- the process may be in a corrupted state. The spec treats crash recovery identically to hard kill: assume no notifications were sent. - -**What clients observe:** Same as hard kill. WebSocket disconnect with no prior `HostTransferNotice`. - -**What plugins observe:** Same as hard kill. WebSocket close/error with no prior `shutdown` message. - -**Recovery timeline:** Same as hard kill. Port may or may not enter TIME_WAIT depending on the state of active connections at crash time. Total recovery: under 5 seconds without TIME_WAIT. - -**Additional concern:** OOM kills may indicate a systemic resource problem. If the new host also encounters OOM, the system enters a crash loop. This is outside the scope of automatic recovery -- the user must investigate resource usage. The observability section (section 6) covers how to diagnose this. - -### 1.4 Port conflict (another process binds 38741) - -**What happens:** The bridge host is not running, and another process (not studio-bridge) has bound port 38741. Alternatively, the host was running and died, and a non-bridge process grabbed the port before a client could. - -**What clients observe when trying to take over:** -- `bind()` call succeeds (they have the port) OR -- `bind()` fails with EADDRINUSE, and the subsequent client connection attempt to the port fails because the process holding the port is not a bridge host (no WebSocket upgrade, no valid health endpoint) -- After 3 retries at 1-second intervals, the client throws `HostUnreachableError` - -**What plugins observe:** -- HTTP health check to `localhost:38741/health` returns either a connection refused, a non-200 status, or invalid JSON (because the non-bridge process does not serve the health endpoint) -- The plugin stays in `searching` state, polling every 2 seconds -- When the port conflict resolves (the other process exits, or the user changes the bridge port), recovery proceeds normally - -**Recovery timeline:** Depends entirely on when the port conflict resolves. The system cannot recover automatically while the port is held by a foreign process. If the user specifies `--port `, recovery is immediate. - -### 1.5 Network stack issues (TIME_WAIT) - -**What happens:** After a hard kill or crash, the OS places the server's TCP connections in TIME_WAIT state. This is a standard TCP behavior designed to prevent delayed packets from a previous connection being misinterpreted as belonging to a new connection. On Linux, TIME_WAIT typically lasts 60 seconds (the `net.ipv4.tcp_fin_timeout` value). On macOS, it is 15-30 seconds. - -**What clients observe when trying to bind:** -- `bind()` fails with EADDRINUSE even though no process holds the port -- With `SO_REUSEADDR` set on the server socket (which the bridge host MUST set), this is typically a non-issue -- `SO_REUSEADDR` allows binding to a port in TIME_WAIT -- Without `SO_REUSEADDR`, clients must wait for TIME_WAIT to expire - -**What plugins observe:** Same as any host-down scenario. Health checks fail, plugin polls with backoff. - -**Mitigation:** The transport server (`transport-server.ts`) MUST set `SO_REUSEADDR` on the server socket before binding. In Node.js with the `http` module, this is the default behavior -- `server.listen()` sets `SO_REUSEADDR` automatically. However, the spec explicitly requires this to prevent future refactors from accidentally removing it. - -**Recovery timeline:** With `SO_REUSEADDR` (default): typically under 1 second. Without `SO_REUSEADDR`: up to 60 seconds on Linux, up to 30 seconds on macOS. The system MUST use `SO_REUSEADDR`. - -## 2. Recovery Protocol - -This section describes the step-by-step recovery sequence from each participant's perspective after the host dies. The sequence differs based on whether the shutdown was graceful (section 2.1) or unexpected (section 2.2). - -### 2.1 Graceful shutdown recovery - -This is the orderly case. The host knows it is shutting down and can coordinate the transition. - -#### Host (the process shutting down) - -1. Signal handler (SIGTERM/SIGINT) fires, or `disconnectAsync()` is called -2. Host sends `HostTransferNotice` to all connected clients over their `/client` WebSockets -3. Host sends WebSocket close frame (code 1001, "Going Away") to all connected plugins -4. Host sends WebSocket close frame (code 1001) to all connected clients -5. Host closes the HTTP server, freeing port 38741 -6. Host process exits - -Steps 2-5 execute within a 2-second timeout. If any step takes longer (e.g., slow WebSocket close due to backpressure), the host force-closes remaining connections and exits anyway. The host MUST NOT hang indefinitely on shutdown. - -```typescript -// Pseudocode for graceful shutdown in bridge-host.ts -async shutdownAsync(): Promise { - // Notify clients that host is going away - for (const client of this._clients) { - client.send(JSON.stringify({ type: 'host-transfer' })); - } - - // Close all plugin WebSockets - for (const session of this._sessionTracker.listAll()) { - session.handle.close(1001, 'Host shutting down'); - } - - // Close all client WebSockets - for (const client of this._clients) { - client.close(1001, 'Host shutting down'); - } - - // Close the server (frees the port) - await this._transportServer.closeAsync({ timeout: 2000 }); -} -``` - -#### Client (receiving graceful shutdown notice) - -1. Client receives `HostTransferNotice` message from the host -2. Client enters "takeover standby" -- it stops sending new requests and prepares to transition roles -3. Client receives WebSocket close frame from the host -4. Client attempts to bind port 38741 (no jitter needed -- the `HostTransferNotice` already primed it) -5. **If bind succeeds:** Client promotes to host role (see section 2.3) -6. **If bind fails (another client won the race):** Client waits 500ms, then connects as a client to the new host at `ws://localhost:38741/client` - -```typescript -// Pseudocode for client-side graceful takeover in bridge-client.ts -private onHostTransferNotice(): void { - this._takeoverPending = true; - // Stop sending new requests; existing in-flight will timeout -} - -private async onHostDisconnected(): Promise { - if (this._takeoverPending) { - // Graceful: try immediately, no jitter - await this.attemptTakeover(); - } else { - // Crash: use jitter (section 2.2) - await this.attemptTakeoverWithJitter(); - } -} -``` - -#### Plugin (receiving graceful close) - -1. Plugin detects WebSocket close (code 1001) -2. If the last message received before close was `shutdown`, plugin transitions to `searching` (no backoff). NOTE: In the graceful path, the host sends a WebSocket close frame, not a `shutdown` protocol message. The plugin treats a clean close (code 1001) the same as receiving `shutdown` -- it transitions to `searching` without backoff. -3. Plugin begins polling `localhost:38741/health` every 2 seconds -4. When health returns 200, plugin opens a new WebSocket to `/plugin` -5. Plugin sends `session.register` with its persisted `instanceId`, its `context` (`edit`, `client`, or `server`), `placeId`, and `gameId` -6. New host responds with `welcome`, plugin enters `connected` state - -The plugin does NOT know whether the new host is the same process or a different one. It does not need to know. The registration handshake is the same regardless. - -### 2.2 Unexpected death recovery (hard kill, crash) - -This is the disorderly case. No notifications were sent. Every participant discovers the failure independently through connection errors. - -#### Client (detecting unexpected host death) - -1. Client detects WebSocket `close` or `error` event on its `/client` connection -2. No `HostTransferNotice` was received -- client knows this was unexpected -3. Client waits a random jitter uniformly distributed in [0, 500ms] (to prevent thundering herd when multiple clients try to bind simultaneously) -4. Client attempts to bind port 38741 -5. **If bind succeeds:** Client promotes to host role (see section 2.3) -6. **If bind fails with EADDRINUSE:** - a. Another client may have won the race -- try connecting as a client to `ws://localhost:38741/client` - b. If client connection succeeds -- done, operating as client to the new host - c. If client connection fails -- the port may be in TIME_WAIT or held by a foreign process. Wait 1 second and retry from step 4. Retry up to 10 times (covering up to ~10 seconds of TIME_WAIT). - d. After 10 retries, throw `HostUnreachableError` - -```typescript -// Pseudocode for crash recovery in bridge-client.ts -private async attemptTakeoverWithJitter(): Promise { - // Random jitter to prevent thundering herd - const jitterMs = Math.random() * 500; - await delay(jitterMs); - - for (let attempt = 0; attempt < 10; attempt++) { - try { - await this.tryBindPort(this._port); - // Success: promote to host - await this.promoteToHost(); - return; - } catch (err) { - if (err.code === 'EADDRINUSE') { - // Try connecting as client (maybe another client took over) - try { - await this.connectAsClient(this._port); - return; // Connected to new host - } catch { - // Port held but not by a bridge host. Wait and retry. - await delay(1000); - } - } else { - throw err; - } - } - } - - throw new HostUnreachableError('localhost', this._port); -} -``` - -#### Plugin (detecting unexpected disconnect) - -1. Plugin detects WebSocket `Closed` or `Error` event -2. No `shutdown` message preceded the close -- plugin transitions from `connected` to `reconnecting` -3. Plugin waits the current backoff duration (starts at 1 second) -4. Plugin transitions to `searching` and begins polling `localhost:38741/health` -5. If health returns 200, plugin connects and registers (same as section 2.1 step 4-6) -6. If health fails, plugin waits 2 seconds (poll interval) and retries -7. Backoff doubles on each failed reconnection cycle: 1s, 2s, 4s, 8s, 16s, 30s (capped) -8. Backoff resets to 0 on successful connection - -### 2.3 Host takeover protocol - -When a client successfully binds port 38741, it becomes the new bridge host. The takeover sequence is: - -1. Client creates a new `TransportServer` and binds it to port 38741 -2. Client starts the HTTP server (serves `/health` endpoint immediately) -3. Client initializes a new `SessionTracker` (empty -- no sessions yet) -4. Client sends `HostReadyNotice` to any remaining clients that were connected to the old host and are now connecting to this one -5. Client starts accepting plugin connections on `/plugin` and client connections on `/client` -6. Plugins discover the new host via health polling and send `register` messages -7. Each plugin registration creates a new `TrackedSession` in the `SessionTracker` -8. The new host emits `SessionEvent { event: 'connected' }` to all connected clients for each plugin that registers - -**Critical detail:** The new host starts with an empty session map. It has no knowledge of which sessions existed on the old host. Session state is rebuilt entirely from plugin re-registrations. This means there is a window (typically 1-5 seconds) where `listSessions()` returns fewer sessions than actually exist -- some plugins have not yet reconnected. - -The new host does NOT attempt to "import" or "restore" sessions from the old host. There is no state transfer between hosts. The session map is always derived from live WebSocket connections. - -```typescript -// Pseudocode for host promotion in bridge-client.ts -private async promoteToHost(): Promise { - // Create and start the transport server - this._host = new BridgeHost({ port: this._port }); - await this._host.startAsync(); - - // Notify any clients that reconnect - this._host.on('client-connection', (client) => { - client.send(JSON.stringify({ type: 'host-ready' })); - }); - - // Update our own role - this._role = 'host'; - - debug('studio-bridge:failover')('Promoted to host on port %d', this._port); -} -``` - -### 2.4 No clients connected - -When the host dies and there are no CLI clients connected: - -1. Host exits, port is freed -2. Plugins detect the WebSocket close and enter `reconnecting` or `searching` -3. Plugins poll `localhost:38741/health`, get connection refused, continue polling with backoff -4. No automatic recovery is possible -- there is no client to take over the host role -5. The next CLI process to start (`studio-bridge exec`, `studio-bridge terminal`, etc.) calls `BridgeConnection.connectAsync()`, which binds port 38741 and becomes the new host -6. Plugins discover the new host on their next poll cycle and reconnect - -This is the most common recovery scenario in practice: a developer runs a command, it finishes, the host exits (idle shutdown after 5 seconds), and the next command starts a fresh host. The plugins bridge the gap by polling. - -## 3. State Recovery - -### 3.1 What is lost - -When the bridge host dies, the following state is irrecoverably lost: - -| State | Location | Impact | -|-------|----------|--------| -| In-memory session map | `SessionTracker` in bridge-host.ts | New host starts with zero sessions until plugins re-register | -| Pending action requests | `PendingRequestMap` in bridge-host.ts | In-flight RPCs will never receive responses; clients must timeout | -| Client subscription map | Bridge host internal state | Clients must re-subscribe to session events after reconnecting | -| Log forwarding state | Bridge host push routing | Log streams (`followLogs()`) are interrupted; consumers must restart iteration | -| Host uptime counter | `HealthResponse.uptime` | Resets to 0 on the new host | - -### 3.2 What survives - -| State | Location | Why it survives | -|-------|----------|-----------------| -| Plugin `instanceId` | `plugin:SetSetting("StudioBridge_InstanceId")` | Persisted in Studio's plugin settings, survives everything except plugin uninstall | -| Plugin `context` | Determined at runtime from the DataModel environment (`edit`, `client`, or `server`) | Intrinsic to the plugin instance -- each context runs as a separate plugin instance | -| Plugin known ports | `plugin:SetSetting("StudioBridge_KnownPorts")` | Persisted in Studio's plugin settings | -| Session origin metadata | Plugin knows if it was `IS_EPHEMERAL` or persistent | Compiled into the plugin at build time | -| Studio's actual state | Roblox Studio process (unaffected by host death) | Studio is a separate process; host death does not crash Studio | -| Plugin log buffer | `LogBuffer` in plugin Luau code | The ring buffer continues accumulating entries during disconnection | -| Plugin state monitor | `StateMonitor` in plugin Luau code | Tracks Studio state changes while disconnected; can push delta on reconnect | - -### 3.3 What is recovered - -| State | How recovered | Timeline | -|-------|---------------|----------| -| Sessions | Plugins re-register with the new host, sending `instanceId`, `context`, `placeId`, `gameId`, place name, capabilities, and current state. A Studio instance in Play mode re-registers 3 sessions (edit, client, server contexts). | 1-5 seconds after new host is available | -| Session IDs | Each plugin generates a fresh UUID as its proposed session ID when re-registering; the new host accepts or overrides it. `(instanceId, context)` provides continuity for correlation. | Immediate on registration | -| Instance grouping | Sessions sharing the same `instanceId` are re-grouped automatically as each context re-registers. During recovery, the group may be partially populated (e.g., 1 of 3 contexts reconnected). | Progressive, complete within 5 seconds | -| Log history | Queried from the plugin's `LogBuffer` on demand (buffered entries survive the gap) | Available immediately after session re-registration | -| Studio state | Included in the plugin's `register` message | Available immediately after session re-registration | -| Client session list | Rebuilt from `SessionEvent` messages as plugins reconnect | Progressive, complete within 5 seconds | - -### 3.4 Instance ID and context continuity - -The `(instanceId, context)` pair is the unique key for correlating sessions across host failures. A single `instanceId` can have up to 3 sessions when Studio is in Play mode (one each for `edit`, `client`, and `server` contexts). When a plugin reconnects to a new host: - -- The plugin generates a fresh UUID as its proposed `sessionId` (via `HttpService:GenerateGUID()`), which the new host accepts or overrides in the `welcome` response. The new host has no memory of the old host's session IDs. -- The plugin sends the same `instanceId` and `context` it has always used, along with `placeId` and `gameId` -- Observability tools and logs can match pre-failure and post-failure sessions by `(instanceId, context)` -- A Studio instance in Play mode produces 3 re-registrations during failover -- one per context. These arrive independently (possibly seconds apart) and are grouped by `instanceId` -- Consumer code that cached a `sessionId` will find it invalid after failover; it must re-resolve sessions via `BridgeConnection.listSessions()` or `waitForSession()` - -This design means that session IDs are ephemeral (scoped to a single host lifetime) while instance IDs are durable (scoped to a plugin installation). The `context` field is determined by which DataModel environment the plugin instance is running in. Consumer code should NOT persist session IDs across process restarts. - -**Recovery example -- Studio in Play mode**: Before failover, one Studio instance had 3 sessions (edit/client/server) all sharing `instanceId: "abc-123"`. After the host dies and a new host starts: - -| Re-registration order | instanceId | context | New sessionId | Group complete? | -|----------------------|------------|---------|---------------|-----------------| -| 1st (arrives at T+2s) | abc-123 | edit | new-001 | 1 of 3 | -| 2nd (arrives at T+2.5s) | abc-123 | server | new-002 | 2 of 3 | -| 3rd (arrives at T+3s) | abc-123 | client | new-003 | 3 of 3 | - -During the recovery window, `listSessions()` may return a partially-populated instance group. Consumers that need all 3 contexts should wait until the group is complete or use a short grace period after the first session in a group appears. - -## 4. Graceful Shutdown Protocol - -This section provides the detailed timeline for a graceful shutdown, which is the best-case scenario for host transitions. - -### 4.1 Signal handling - -The bridge host registers handlers for SIGTERM and SIGINT: - -```typescript -// In bridge-host.ts startup -process.on('SIGTERM', () => this.shutdownAsync()); -process.on('SIGINT', () => this.shutdownAsync()); -``` - -The shutdown handler is idempotent -- calling it multiple times (e.g., user presses Ctrl+C twice) does not cause errors. The second call is a no-op if shutdown is already in progress. - -### 4.2 Shutdown sequence timeline - -``` -T+0ms Signal received. Host begins shutdown. -T+0ms Host sends HostTransferNotice to all clients. -T+10ms Host sends WebSocket close (1001) to all plugins. -T+20ms Host sends WebSocket close (1001) to all clients. -T+30ms Host calls server.close(), beginning port release. -T+50ms Port is freed. Host process exits. - -T+50ms First client detects close, attempts to bind port. -T+100ms Client successfully binds port, starts new host. -T+100ms New host serves /health endpoint. - -T+2000ms Plugin polls /health, gets 200. -T+2100ms Plugin opens WebSocket, sends register. -T+2200ms New host creates session, sends welcome. -T+2200ms Recovery complete for this plugin. -``` - -Total time from signal to full recovery: approximately 2 seconds (dominated by the plugin's 2-second poll interval). - -### 4.3 Shutdown timeout - -If any step in the shutdown sequence blocks for more than 2 seconds (e.g., a WebSocket close handshake hangs because the remote end is unresponsive), the host force-terminates all connections: - -```typescript -private async shutdownAsync(): Promise { - if (this._shuttingDown) return; - this._shuttingDown = true; - - const shutdownTimer = setTimeout(() => { - debug('studio-bridge:host')('Shutdown timeout, force-closing'); - this._transportServer.forceClose(); - process.exit(0); - }, 2000); - - try { - await this.gracefulShutdown(); - } finally { - clearTimeout(shutdownTimer); - } - - process.exit(0); -} -``` - -### 4.4 Drain behavior - -When a client receives `HostTransferNotice`, it enters drain mode: - -1. **Stop sending new requests:** Any calls to `session.execAsync()` or other action methods while in drain mode queue internally rather than sending to the dying host. -2. **Wait for in-flight responses:** Existing pending requests have two possible outcomes: - a. The host responds before closing -- the response is delivered normally. - b. The host closes before responding -- the pending request rejects with `SessionDisconnectedError`. -3. **Transition:** Once the host's WebSocket close frame arrives, the client proceeds to takeover (section 2.3). - -The drain window is brief (typically under 50ms between `HostTransferNotice` and WebSocket close). In-flight requests during this window almost always fail. Consumer code should be prepared to retry. - -## 5. Edge Cases and Race Conditions - -### 5.1 Two clients try to become host simultaneously - -**Scenario:** The host dies with two clients connected. Both detect the disconnect and attempt to bind port 38741. - -**Resolution:** The OS guarantees that `bind()` is atomic. Exactly one client will succeed; the other gets EADDRINUSE. The losing client then connects as a client to the winning one. - -**Jitter mitigation:** Each client waits a random 0-500ms delay before attempting to bind (in the crash case only; graceful shutdown does not use jitter). This reduces contention and makes the race less likely, but does not eliminate it -- and does not need to. The bind-or-connect fallback is correct regardless of timing. - -**Sequence diagram:** -``` -Host dies (crash) - | - +-- Client A: waits 150ms jitter, tries bind → SUCCESS → becomes host - | - +-- Client B: waits 300ms jitter, tries bind → EADDRINUSE - tries connect to :38741/client → SUCCESS → becomes client -``` - -### 5.2 Plugin reconnects before any client becomes host - -**Scenario:** The host dies. A plugin enters `reconnecting`, waits 1 second (initial backoff), transitions to `searching`, and polls `/health`. No client has taken over the port yet. - -**What happens:** The health check gets connection refused. The plugin stays in `searching`, polls again in 2 seconds. This repeats until a client binds the port or a new CLI process starts. - -**No harm done:** The plugin is designed to poll indefinitely. Each failed health check is a lightweight HTTP GET that returns immediately with connection refused. There is no timeout or retry limit on discovery. - -### 5.3 TIME_WAIT prevents port rebind - -**Scenario:** The host crashes while actively sending data. The OS places the socket in TIME_WAIT. A client tries to bind the port. - -**With SO_REUSEADDR (required by spec):** The bind succeeds despite TIME_WAIT. This is the expected path. - -**Without SO_REUSEADDR (should never happen):** The bind fails with EADDRINUSE. The client's retry loop (section 2.2, step 6) retries every 1 second for up to 10 attempts. TIME_WAIT typically resolves within this window on macOS (15-30 seconds) but may exceed it on Linux (60 seconds). - -**Verification:** The transport server MUST log a warning at startup if `SO_REUSEADDR` is not set. This is a defense-in-depth check; Node.js `http.Server` sets it by default. - -### 5.4 Host dies mid-action - -**Scenario:** A client has sent a `HostEnvelope` with an action (e.g., `execute`) to the host. The host forwarded it to the plugin. The host crashes before the plugin's response can be relayed back. - -**What the client observes:** -1. The WebSocket to the host closes unexpectedly -2. The pending request in the client's `PendingRequestMap` has no response -3. The client enters the takeover flow (section 2.2) -4. Meanwhile, the pending request's timeout timer continues ticking - -**Resolution:** The pending request eventually times out (default 30 seconds, configurable per action type). The consumer receives `ActionTimeoutError`. The consumer must decide whether to retry. - -**What happened on the plugin side:** The plugin may have already executed the script. The response was sent to the (now-dead) host. When the plugin reconnects to the new host, the old response is not resent -- it was a response to a request on the old host's connection, and the new host has no knowledge of it. This means the action may have had side effects (e.g., the script modified Studio state) without the consumer knowing it succeeded. - -**Mitigation for consumers:** Actions that have side effects should be idempotent where possible. The `execute` action cannot be made automatically idempotent (arbitrary Luau code), so consumers of `execAsync()` must handle `ActionTimeoutError` as "unknown outcome" and decide whether to retry. - -### 5.5 Rapid kill+restart cycle - -**Scenario:** User presses Ctrl+C on the host process and immediately runs `studio-bridge exec 'print("hello")'`. The new CLI process starts within milliseconds of the old one dying. - -**What happens:** -1. Old host begins graceful shutdown (section 4) -2. New CLI process starts, calls `BridgeConnection.connectAsync()` -3. `connectAsync()` tries to bind port 38741 -4. If the old host has not yet released the port: EADDRINUSE. The new process tries to connect as a client. -5. The client connection attempt may succeed briefly (the old host is still alive) or fail (the old host has closed its server socket) -6. If the client connection fails, the new process retries the bind (up to 3 retries at 1-second intervals per `07-bridge-network.md` section 4.1) -7. By the time retries start, the old host has finished shutting down and freed the port -8. The new process binds the port and becomes the host - -**Timeline:** The new process becomes the host within 1-2 seconds of starting. This covers the overlap window where the old host is still shutting down. - -### 5.6 All clients die, only plugins remain - -**Scenario:** The bridge host was an implicit host (a CLI process). It exits. There are no other CLI clients. Multiple Studio instances with persistent plugins are still running. - -**What happens:** -1. Plugins detect WebSocket close, enter `reconnecting` or `searching` -2. Plugins poll `/health` with backoff -3. No process binds port 38741 -4. Plugins poll indefinitely (no timeout, no retry limit) -5. Eventually, a user runs a CLI command. The new process binds port 38741, becomes the host. -6. Plugins discover the new host on their next poll cycle, connect, and register. - -**Design note:** The plugins are designed to be patient. They will poll for hours, days, or weeks without ill effect. The polling interval is 2 seconds during active searching, which is lightweight (a single HTTP GET that returns connection refused). There is no exponential backoff on the discovery poll itself -- only on reconnection after a connection that was previously established drops. - -### 5.7 Managed session with dead host - -**Scenario:** The bridge host launched Studio with `origin: 'managed'`. The host dies. Should Studio be killed? - -**Answer: No.** The host that dies cannot kill Studio (it is dead). The new host, when it sees the plugin reconnect, observes that the session's `origin` is reported by the plugin. Managed vs. user origin is a property of how the session was originally established. The new host does not kill managed sessions just because it was not the host that launched them. - -However, managed session cleanup semantics still apply: when the new host shuts down gracefully, it may choose to close managed sessions (this depends on the `keepAlive` option and idle shutdown logic, per `07-bridge-network.md` section 6.5). The reconnected session inherits its origin classification. - -### 5.8 Client has stale session references after failover - -**Scenario:** A consumer holds a `BridgeSession` reference from before the failover. After failover, the consumer tries to use it. - -**What happens:** The old `BridgeSession` holds a `TransportHandle` that is disconnected. Any action method called on it rejects with `SessionDisconnectedError`. - -**Recovery:** The consumer must re-resolve sessions from `BridgeConnection`: -```typescript -// Before failover -const session = await bridge.waitForSession(); -await session.execAsync('print("hello")'); // works - -// Host dies, client takes over, plugin reconnects - -await session.execAsync('print("hello")'); // throws SessionDisconnectedError - -// Recovery: get the new session -const newSession = await bridge.waitForSession(); -await newSession.execAsync('print("hello")'); // works -``` - -`BridgeConnection` emits `'session-disconnected'` and then `'session-connected'` events during failover. Consumer code that listens to these events can update its session references automatically. - -### 5.9 Multiple host deaths in rapid succession - -**Scenario:** Host A dies. Client B takes over as host. Client B immediately dies (e.g., the user is rapidly Ctrl+C-ing all terminals). - -**What happens:** Client C (if it exists) detects Client B's death and attempts takeover. The recovery protocol is the same regardless of how many times it has been invoked. Each client independently follows the same logic: detect disconnect, jitter, try bind, fallback to client. - -If all clients die, only plugins remain (section 5.6). The system degrades gracefully to "plugins polling, waiting for any host." - -### 5.10 Failover during `studio-bridge serve` - -**Scenario:** A dedicated host started via `studio-bridge serve` crashes. There are CLI clients connected. - -**What happens:** Same as any other host crash (section 2.2). A connected client takes over. The difference is that `serve` was running with `keepAlive: true`, meaning the host was intended to be long-lived. The client that takes over may or may not have `keepAlive: true`. - -**Recommendation:** If the user is running `serve` for a reason (e.g., devcontainer support), they should restart `serve` after the crash. The client that temporarily took over will detect the new `serve` instance and relinquish the host role (by disconnecting and reconnecting as a client when it detects the dedicated host). - -Actually, there is no "relinquish" mechanism in the current design. Once a client becomes a host, it stays a host until it exits. The user must manually stop the temporary host and restart `serve`. This is an acceptable limitation for an edge case (dedicated host crashing), and adding a relinquish protocol would add significant complexity for minimal benefit. - -## 6. Observability - -### 6.1 Plugin output messages - -The plugin logs all connection state transitions to Studio's Output window with a `[StudioBridge]` prefix. These messages are the primary debugging tool for plugin-side issues. - -| State transition | Output message | -|-----------------|----------------| -| Plugin starts, enters discovery | `[StudioBridge] Persistent mode, searching for server...` | -| Health check succeeds | `[StudioBridge] searching -> connecting` | -| WebSocket opened, handshake complete | `[StudioBridge] connecting -> connected` or `[StudioBridge] Connected (v2)` | -| WebSocket closed unexpectedly | `[StudioBridge] connected -> reconnecting` | -| Clean shutdown received | `[StudioBridge] connected -> searching` | -| Backoff timer expires | `[StudioBridge] reconnecting -> searching` | -| Reconnection to new host succeeds | `[StudioBridge] Reconnected (new host)` | - -The "Reconnected (new host)" message is emitted when the plugin connects and the health response shows a different `uptime` value (near zero, indicating a fresh host) compared to the previous connection. This helps distinguish "reconnected to the same host after a blip" from "connected to a new host after failover." - -### 6.2 CLI output messages - -CLI commands that encounter host failure show clear, actionable messages: - -| Scenario | CLI output | -|----------|-----------| -| Host unreachable during `connectAsync()` | `Bridge host unreachable on port 38741. Attempting to become host...` | -| Client successfully takes over | `Promoted to bridge host on port 38741.` | -| Client connects to new host after takeover | `Connected to bridge host on port 38741 (new host).` | -| Action timeout after host death | `Error: Action timed out after 30000ms. The bridge host may have crashed during execution.` | -| All retries exhausted | `Error: Unable to connect to bridge host on port 38741 after 10 attempts. Is another process using this port?` | -| Recovery in progress | `Waiting for bridge host... (attempt 3/10)` | - -### 6.3 `studio-bridge sessions` during recovery - -The `sessions` command reflects the live state of the session tracker, which means it shows the recovery in progress: - -``` -$ studio-bridge sessions -No active sessions. (Host started 2s ago, waiting for plugins to reconnect.) -``` - -If the host has been up for less than 10 seconds and has zero sessions, the output includes the "(waiting for plugins to reconnect)" hint. After 10 seconds with no sessions, the hint changes to standard "no sessions" output. - -When sessions are reconnecting progressively: -``` -$ studio-bridge sessions -SESSION ID PLACE NAME CONTEXT STATE CONNECTED -abc-123 MyGame edit Edit 2s ago -abc-124 MyGame server Play 2s ago - -(2 sessions connected across 1 instance. More plugins may still be reconnecting.) -``` - -A Studio instance in Play mode may show partial recovery -- for example, the `edit` and `server` contexts may reconnect before the `client` context. - -The "more plugins may still be reconnecting" hint appears when the host has been up for less than 10 seconds. - -### 6.4 Health endpoint during failover - -| Host state | Health endpoint behavior | -|-----------|-------------------------| -| Host alive and healthy | `200 OK` with JSON body | -| Host shutting down (graceful) | Connection may succeed or fail depending on timing | -| Host dead | Connection refused (ECONNREFUSED) | -| New host starting | `200 OK` with `uptime: 0` (or very low), `sessions: 0` | -| New host with reconnected plugins | `200 OK` with accurate session count | - -### 6.5 Debug logging - -When `DEBUG=studio-bridge:*` is set (or the equivalent verbose flag), the bridge logs every state transition in the failover process: - -``` -studio-bridge:host Shutdown signal received (SIGTERM) -studio-bridge:host Sending HostTransferNotice to 2 clients -studio-bridge:host Closing 3 plugin connections -studio-bridge:host Closing 2 client connections -studio-bridge:host Server closed, port 38741 released -studio-bridge:client Host disconnected (HostTransferNotice received) -studio-bridge:client Attempting takeover of port 38741 -studio-bridge:client Bind succeeded, promoting to host -studio-bridge:failover Promoted to host on port 38741 -studio-bridge:host Plugin connected: instanceId=abc-123, context=edit -studio-bridge:host Session registered: sessionId=new-456, instanceId=abc-123, context=edit, placeName=MyGame -studio-bridge:host Plugin connected: instanceId=abc-123, context=server -studio-bridge:host Session registered: sessionId=new-457, instanceId=abc-123, context=server, placeName=MyGame -studio-bridge:host Plugin connected: instanceId=abc-123, context=client -studio-bridge:host Session registered: sessionId=new-458, instanceId=abc-123, context=client, placeName=MyGame -studio-bridge:host Plugin connected: instanceId=def-789, context=edit -studio-bridge:host Session registered: sessionId=new-012, instanceId=def-789, context=edit, placeName=TestPlace -``` - -The `studio-bridge:failover` debug namespace is specifically for failover-related events, making it easy to filter for failover diagnostics: - -``` -DEBUG=studio-bridge:failover studio-bridge exec 'print("hello")' -``` - -### 6.6 Error types for failover scenarios - -All failover-related errors use the typed error classes from `07-bridge-network.md` section 2.7: - -| Error class | When thrown during failover | -|-------------|---------------------------| -| `HostUnreachableError` | All takeover retries exhausted; port held by foreign process | -| `ActionTimeoutError` | In-flight action lost due to host death; timeout expired | -| `SessionDisconnectedError` | Consumer tries to use a session from the old host | -| `HandOffFailedError` | Graceful hand-off initiated but no client could take over | - -## 7. Testing Strategy - -### 7.1 Unit tests - -Unit tests validate individual components of the failover system in isolation. - -#### State machine transitions (bridge-client.ts) - -Test that the client correctly transitions through failover states: - -```typescript -describe('bridge-client failover', () => { - it('enters takeover mode on HostTransferNotice', () => { - const client = createBridgeClient({ port: TEST_PORT }); - simulateMessage(client, { type: 'host-transfer' }); - expect(client.state).toBe('takeover-standby'); - }); - - it('enters takeover mode on unexpected disconnect', () => { - const client = createBridgeClient({ port: TEST_PORT }); - await client.connectAsync(); - simulateDisconnect(client); - expect(client.state).toBe('takeover-attempt'); - }); - - it('rejects pending requests on host death', async () => { - const client = createBridgeClient({ port: TEST_PORT }); - const pending = client.sendActionAsync({ type: 'execute', ... }, 5000); - simulateDisconnect(client); - await expect(pending).rejects.toThrow(SessionDisconnectedError); - }); -}); -``` - -#### Jitter distribution (hand-off.ts) - -Test that the jitter delay is within the expected range and uniformly distributed: - -```typescript -describe('takeover jitter', () => { - it('produces delays between 0 and 500ms', () => { - const delays = Array.from({ length: 1000 }, () => computeTakeoverJitter()); - expect(Math.min(...delays)).toBeGreaterThanOrEqual(0); - expect(Math.max(...delays)).toBeLessThanOrEqual(500); - }); - - it('skips jitter for graceful shutdown', () => { - const delay = computeTakeoverJitter({ graceful: true }); - expect(delay).toBe(0); - }); -}); -``` - -#### Session tracker reset (session-tracker.ts) - -Test that a new session tracker starts empty and rebuilds from registrations: - -```typescript -describe('session tracker after failover', () => { - it('starts with zero sessions', () => { - const tracker = new SessionTracker(); - expect(tracker.listSessions()).toEqual([]); - }); - - it('adds sessions from register messages', () => { - const tracker = new SessionTracker(); - tracker.addSession('s1', mockSessionInfo({ instanceId: 'inst-1', context: 'edit' }), mockHandle()); - expect(tracker.listSessions()).toHaveLength(1); - expect(tracker.listSessions()[0].sessionId).toBe('s1'); - }); - - it('groups sessions by instanceId across contexts', () => { - const tracker = new SessionTracker(); - tracker.addSession('s1', mockSessionInfo({ instanceId: 'inst-1', context: 'edit' }), mockHandle()); - tracker.addSession('s2', mockSessionInfo({ instanceId: 'inst-1', context: 'server' }), mockHandle()); - tracker.addSession('s3', mockSessionInfo({ instanceId: 'inst-1', context: 'client' }), mockHandle()); - expect(tracker.listSessions()).toHaveLength(3); - // All three sessions share the same instanceId but have different contexts - const contexts = tracker.listSessions().map(s => s.context).sort(); - expect(contexts).toEqual(['client', 'edit', 'server']); - }); -}); -``` - -### 7.2 Integration tests - -Integration tests exercise the full failover path with mock plugins and real WebSocket connections. - -#### Graceful shutdown + client takeover - -```typescript -it('client takes over after graceful host shutdown', async () => { - // Start host on ephemeral port - const host = await createTestHost({ port: 0 }); - const port = host.port; - - // Connect a mock plugin (edit context) - const plugin = createMockPlugin({ port, instanceId: 'inst-1', context: 'edit' }); - await plugin.connectAsync(); - await plugin.waitForWelcome(); - - // Connect a client - const client = await BridgeConnection.connectAsync({ port }); - expect(client.role).toBe('client'); - - // Verify session exists - const sessions = client.listSessions(); - expect(sessions).toHaveLength(1); - - // Shut down the host gracefully - await host.shutdownAsync(); - - // Wait for client to take over - await waitForCondition(() => client.role === 'host', 5000); - expect(client.role).toBe('host'); - - // Wait for plugin to reconnect - await plugin.waitForReconnection(5000); - - // Verify session is restored - const newSessions = client.listSessions(); - expect(newSessions).toHaveLength(1); - // Session ID may differ, but (instanceId, context) is the same -}); -``` - -#### Hard kill + client takeover - -```typescript -it('client takes over after host crash', async () => { - const host = await createTestHost({ port: 0 }); - const port = host.port; - - const plugin = createMockPlugin({ port, instanceId: 'inst-1', context: 'edit' }); - await plugin.connectAsync(); - await plugin.waitForWelcome(); - - const client = await BridgeConnection.connectAsync({ port }); - expect(client.role).toBe('client'); - - // Kill the host without graceful shutdown - host.forceClose(); // closes server socket immediately, no notifications - - // Wait for client to take over - await waitForCondition(() => client.role === 'host', 5000); - - // Wait for plugin to reconnect - await plugin.waitForReconnection(10000); - - // Verify actions work through the new host - plugin.onAction('queryState', () => ({ - type: 'stateResult', - payload: { state: 'Edit', placeId: 0, placeName: 'Test', gameId: 0 } - })); - - const session = await client.waitForSession(5000); - const state = await session.queryStateAsync(); - expect(state.state).toBe('Edit'); -}); -``` - -#### No clients -- plugin waits for new host - -```typescript -it('plugin reconnects when new host appears after gap', async () => { - const host = await createTestHost({ port: 0 }); - const port = host.port; - - const plugin = createMockPlugin({ port, instanceId: 'inst-1', context: 'edit' }); - await plugin.connectAsync(); - await plugin.waitForWelcome(); - - // Kill host with no clients - host.forceClose(); - - // Plugin enters reconnecting/searching - await waitForCondition(() => plugin.state === 'searching', 5000); - - // Start a new host on the same port - const newHost = await createTestHost({ port }); - - // Wait for plugin to discover and reconnect - await plugin.waitForReconnection(10000); - - // Verify the new host has the session - expect(newHost.listSessions()).toHaveLength(1); -}); -``` - -#### Two clients race for host - -```typescript -it('exactly one client becomes host when two race', async () => { - const host = await createTestHost({ port: 0 }); - const port = host.port; - - const clientA = await BridgeConnection.connectAsync({ port }); - const clientB = await BridgeConnection.connectAsync({ port }); - - // Kill the host - host.forceClose(); - - // Wait for both clients to settle - await waitForCondition( - () => clientA.isConnected && clientB.isConnected, - 10000 - ); - - // Exactly one should be host, the other should be client - const roles = [clientA.role, clientB.role].sort(); - expect(roles).toEqual(['client', 'host']); -}); -``` - -### 7.3 Mock plugin reconnection support - -The `createMockPlugin()` helper from `07-bridge-network.md` section 8.1 is extended with reconnection behavior for failover testing: - -```typescript -interface MockPlugin { - // ... existing methods from 07-bridge-network.md ... - - /** Current connection state. */ - readonly state: 'disconnected' | 'connecting' | 'connected' | 'searching'; - - /** - * Wait for the plugin to reconnect after a disconnection. - * Simulates the persistent plugin's reconnection behavior: - * detects disconnect, polls health, reconnects, re-registers. - */ - waitForReconnection(timeoutMs: number): Promise; - - /** - * Enable auto-reconnection behavior. - * When enabled, the mock plugin polls the health endpoint - * and reconnects automatically, just like the real plugin. - */ - enableAutoReconnect(options?: { - pollIntervalMs?: number; // default: 500 (faster than real plugin for tests) - backoffMs?: number; // default: 100 (faster for tests) - }): void; -} - -function createMockPlugin(options?: MockPluginOptions): MockPlugin; - -interface MockPluginOptions { - port?: number; - instanceId?: string; - context?: SessionContext; // 'edit' | 'client' | 'server', default: 'edit' - placeName?: string; - placeId?: number; - gameId?: number; - capabilities?: Capability[]; - protocolVersion?: number; - autoReconnect?: boolean; // default: true for failover tests -} -``` - -The mock plugin's reconnection uses shorter intervals than the real plugin (500ms poll, 100ms backoff) to keep tests fast. The real plugin uses 2-second polls and 1-30 second backoff. - -### 7.4 Chaos testing guidance - -These scenarios cannot be fully automated in unit/integration tests and should be tested manually or in a staging environment: - -**Rapid kill cycle:** -1. Start `studio-bridge serve` -2. Open 3 Studio instances, verify all sessions appear in `studio-bridge sessions` -3. Put one Studio instance into Play mode (this creates 3 sessions: edit, client, server) -4. Kill the serve process (kill -9) -5. Immediately run `studio-bridge sessions` -6. Verify the new process becomes host and all sessions reconnect within 5 seconds (including all 3 contexts for the Play-mode instance) - -**Multi-client takeover race:** -1. Start `studio-bridge serve` (the host) -2. Open a terminal and run `studio-bridge terminal` (client A) -3. Open another terminal and run `studio-bridge terminal` (client B) -4. Kill the serve process (kill -9) -5. Verify that exactly one terminal becomes host, the other remains a client -6. Verify both terminals can still execute commands - -**TIME_WAIT recovery:** -1. Start a host process -2. Connect a plugin and send a large action (e.g., execute a script that generates megabytes of output) -3. Kill the host mid-transfer (kill -9) -4. Immediately start a new host on the same port -5. Verify the new host can bind (SO_REUSEADDR handles TIME_WAIT) - -**Sustained disconnection:** -1. Start a host process -2. Connect a Studio instance with the persistent plugin -3. Kill the host process -4. Wait 5 minutes (no host running) -5. Start a new host -6. Verify the plugin reconnects (it should still be polling) - -**OOM simulation:** -1. Start a host process with a low memory limit (`NODE_OPTIONS="--max-old-space-size=64"`) -2. Send actions that allocate memory (large script outputs, screenshots) -3. Observe the OOM crash and verify client takeover works - -## 8. Timeline Guarantees - -These are the expected recovery times for each failure scenario. They assume standard conditions: localhost networking, modern hardware, no unusual OS load, `SO_REUSEADDR` enabled. - -| Scenario | Expected Recovery Time | Limiting Factor | -|----------|----------------------|-----------------| -| Graceful shutdown + client takeover | < 2 seconds | Plugin poll interval (2s) | -| Graceful shutdown + no clients + new CLI command | < 3 seconds | Plugin poll interval + CLI startup | -| Hard kill + client takeover | < 5 seconds | Jitter (0-500ms) + plugin backoff (1s) + poll interval (2s) | -| Hard kill + no clients + new CLI command | < 3 seconds | CLI startup time + plugin poll interval | -| TIME_WAIT port recovery (with SO_REUSEADDR) | < 1 second | Kernel socket teardown | -| TIME_WAIT port recovery (without SO_REUSEADDR) | < 60 seconds | TCP TIME_WAIT timer (Linux) | -| Plugin reconnection after new host available | < 5 seconds | Backoff position + poll interval | -| Port conflict resolution (foreign process) | Indefinite | Depends on external process | -| Consumer session re-resolution after failover | < 1 second | `waitForSession()` resolves immediately if a session is already connected | - -### 8.1 What these guarantees do NOT cover - -- **Studio startup time:** If the host dies and Studio is not running, starting Studio takes 10-30 seconds. This is outside the scope of failover recovery (the failover is about reconnecting existing Studio instances, not launching new ones). -- **Plugin installation:** If the persistent plugin is not installed, the failover recovery path is not available. Ephemeral plugins do not reconnect. -- **Network issues beyond localhost:** In split-server mode, network failures between the devcontainer and the host OS are not covered by this spec. The devcontainer sees a disconnection and follows the same client recovery path, but the recovery time depends on the port-forwarding infrastructure. -- **OS-level failures:** Kernel panics, disk full, or system-wide resource exhaustion are outside the scope of application-level recovery. - -## 9. Implementation Notes - -### 9.1 SO_REUSEADDR requirement - -The transport server MUST create its HTTP server with `SO_REUSEADDR`. In Node.js: - -```typescript -const server = http.createServer(); -// Node.js sets SO_REUSEADDR by default on server.listen(). -// This comment exists to prevent future refactors from using -// a custom socket creation path that might omit it. -server.listen(port, 'localhost'); -``` - -If the implementation ever moves to a raw `net.Server` or a third-party HTTP library, `SO_REUSEADDR` must be explicitly set: - -```typescript -const server = net.createServer(); -server.on('listening', () => { - // Verify SO_REUSEADDR is set (defense in depth) - // Node.js does this automatically, but log a warning if not -}); -``` - -### 9.2 Shutdown handler registration - -The bridge host MUST register shutdown handlers early in its lifecycle, before any async work: - -```typescript -class BridgeHost { - async startAsync(): Promise { - // Register signal handlers FIRST, before binding port - this.registerShutdownHandlers(); - - // Now do the potentially-slow work - await this._transportServer.listenAsync(this._port); - } - - private registerShutdownHandlers(): void { - const shutdown = () => this.shutdownAsync(); - process.on('SIGTERM', shutdown); - process.on('SIGINT', shutdown); - - // Also handle uncaught exceptions for best-effort notification - process.on('uncaughtException', (err) => { - debug('studio-bridge:host')('Uncaught exception: %O', err); - // Best-effort: try to notify clients, but don't block on it - this.shutdownAsync().catch(() => {}).finally(() => process.exit(1)); - }); - } -} -``` - -### 9.3 Idempotent shutdown - -The shutdown handler MUST be idempotent. Users may press Ctrl+C multiple times, or SIGTERM may arrive while a previous shutdown is in progress: - -```typescript -private _shuttingDown = false; - -async shutdownAsync(): Promise { - if (this._shuttingDown) { - debug('studio-bridge:host')('Shutdown already in progress, ignoring'); - return; - } - this._shuttingDown = true; - // ... shutdown logic ... -} -``` - -### 9.4 Pending request cleanup on failover - -When a client transitions from client role to host role, it must reject all pending requests from the old connection: - -```typescript -private async promoteToHost(): Promise { - // Reject all pending requests from the client connection - this._pendingRequests.rejectAll( - new SessionDisconnectedError('Host died during request') - ); - - // Clear the pending request map - this._pendingRequests.clear(); - - // Now set up the host - // ... -} -``` - -### 9.5 File layout for failover code - -The failover logic lives in existing files from the `07-bridge-network.md` file layout. No new files are needed: - -| File | Failover responsibility | -|------|------------------------| -| `src/bridge/internal/hand-off.ts` | Takeover logic (jitter, bind, promote), graceful shutdown coordination | -| `src/bridge/internal/bridge-host.ts` | Shutdown handler, `HostTransferNotice` sending, connection close sequencing | -| `src/bridge/internal/bridge-client.ts` | Disconnect detection, takeover decision (graceful vs. crash), role transition | -| `src/bridge/internal/transport-server.ts` | `SO_REUSEADDR` configuration, `forceClose()` method | -| `src/bridge/internal/transport-client.ts` | Reconnection backoff, disconnect event propagation | -| `src/bridge/internal/session-tracker.ts` | Reset/rebuild on new host, `(instanceId, context)`-based session correlation, instance grouping | diff --git a/tools/nevermore-cli-helpers/package.json b/tools/nevermore-cli-helpers/package.json index 6aead76821..bd99a17795 100644 --- a/tools/nevermore-cli-helpers/package.json +++ b/tools/nevermore-cli-helpers/package.json @@ -23,6 +23,7 @@ ], "dependencies": { "@quenty/cli-output-helpers": "workspace:*", + "inquirer": "^13.2.0", "latest-version": "^9.0.0", "semver": "^7.6.0" }, @@ -31,12 +32,15 @@ "@types/semver": "^7.5.0", "prettier": "2.7.1", "typescript": "^5.9.3", - "typescript-memoize": "^1.1.1" + "typescript-memoize": "^1.1.1", + "vitest": "^3.0.0" }, "scripts": { "build": "tsc --build", "build:watch": "tsc --build --watch", "build:clean": "tsc --build --clean", + "test": "vitest run", + "test:watch": "vitest", "preinstall": "npx only-allow pnpm" }, "publishConfig": { diff --git a/tools/nevermore-cli-helpers/src/auth/cookie/cookie-parser.test.ts b/tools/nevermore-cli-helpers/src/auth/cookie/cookie-parser.test.ts new file mode 100644 index 0000000000..4445ee2e97 --- /dev/null +++ b/tools/nevermore-cli-helpers/src/auth/cookie/cookie-parser.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from 'vitest'; +import { COOKIE_NAME, parseStudioCookieValue } from './cookie-parser.js'; + +describe('COOKIE_NAME', () => { + it('equals .ROBLOSECURITY', () => { + expect(COOKIE_NAME).toBe('.ROBLOSECURITY'); + }); +}); + +describe('parseStudioCookieValue', () => { + it('parses COOK:: format with angle brackets', () => { + const result = parseStudioCookieValue('COOK::'); + expect(result).toBe('abc123'); + }); + + it('parses value from comma-separated list', () => { + const result = parseStudioCookieValue('OTHER::stuff,COOK::'); + expect(result).toBe('secret'); + }); + + it('returns undefined for plain text', () => { + expect(parseStudioCookieValue('just a string')).toBeUndefined(); + }); + + it('returns undefined for COOK:: without angle brackets', () => { + expect(parseStudioCookieValue('COOK::noBrackets')).toBeUndefined(); + }); + + it('returns undefined for empty string', () => { + expect(parseStudioCookieValue('')).toBeUndefined(); + }); + + it('handles a realistic cookie value', () => { + const cookie = '_|WARNING:-DO-NOT-SHARE|_abc123def456'; + const result = parseStudioCookieValue(`COOK::<${cookie}>`); + expect(result).toBe(cookie); + }); +}); diff --git a/tools/nevermore-cli/src/utils/auth/roblox-auth/cookie-parser.ts b/tools/nevermore-cli-helpers/src/auth/cookie/cookie-parser.ts similarity index 100% rename from tools/nevermore-cli/src/utils/auth/roblox-auth/cookie-parser.ts rename to tools/nevermore-cli-helpers/src/auth/cookie/cookie-parser.ts diff --git a/tools/nevermore-cli/src/utils/auth/roblox-auth/index.ts b/tools/nevermore-cli-helpers/src/auth/cookie/index.ts similarity index 60% rename from tools/nevermore-cli/src/utils/auth/roblox-auth/index.ts rename to tools/nevermore-cli-helpers/src/auth/cookie/index.ts index 0c636319ef..dd1cedc90c 100644 --- a/tools/nevermore-cli/src/utils/auth/roblox-auth/index.ts +++ b/tools/nevermore-cli-helpers/src/auth/cookie/index.ts @@ -8,13 +8,14 @@ import { OutputHelper } from '@quenty/cli-output-helpers'; import { COOKIE_NAME } from './cookie-parser.js'; import { readCookie as readWindowsCookie } from './windows.js'; import { readCookie as readMacOSCookie } from './macos.js'; +import { readCookie as readLinuxCookie } from './linux.js'; /** * Resolve the .ROBLOSECURITY cookie for legacy Roblox API calls. * * Resolution order (matching Mantle's rbx_cookie crate): * 1. ROBLOSECURITY environment variable - * 2. Platform credential store (Windows Credential Manager / macOS HTTPStorages) + * 2. Platform credential store (Windows Credential Manager / macOS HTTPStorages / Wine Credential Manager) * 3. Platform legacy store (Windows Registry / macOS plist) * 4. Interactive prompt */ @@ -55,19 +56,42 @@ function readPlatformCookie(): string | undefined { return readWindowsCookie(); case 'darwin': return readMacOSCookie(); + case 'linux': + return readLinuxCookie(); default: return undefined; } } +interface CsrfFetchResult { + response: Response; + rotatedCookie?: string; +} + +/** + * Extract a rotated .ROBLOSECURITY cookie from a response's set-cookie header. + */ +function extractRotatedCookie(response: Response): string | undefined { + const setCookie = response.headers.get('set-cookie'); + if (!setCookie) { + return undefined; + } + + // set-cookie may contain multiple cookies separated by commas (or multiple headers). + // Look for .ROBLOSECURITY=. + const match = setCookie.match(/\.ROBLOSECURITY=([^;,\s]+)/); + return match?.[1]; +} + /** - * Make a cookie-authenticated request to Roblox, handling CSRF token exchange. + * Make a cookie-authenticated request to Roblox, handling CSRF token exchange, + * cookie rotation capture, and 429 rate-limit retries. */ async function fetchWithCsrfAsync( url: string, cookie: string, options: RequestInit = {} -): Promise { +): Promise { const headers: Record = { Cookie: `${COOKIE_NAME}=${cookie}`, 'User-Agent': 'Roblox/WinInet', @@ -90,7 +114,21 @@ async function fetchWithCsrfAsync( } } - return response; + // Retry once on rate limit + if (response.status === 429) { + const retryAfter = response.headers.get('retry-after'); + const delaySec = retryAfter ? Math.min(parseInt(retryAfter, 10) || 2, 30) : 2; + OutputHelper.verbose(`Rate limited (429). Retrying after ${delaySec}s...`); + await new Promise(resolve => setTimeout(resolve, delaySec * 1000)); + response = await fetch(url, { ...options, headers }); + } + + const rotatedCookie = extractRotatedCookie(response); + if (rotatedCookie) { + OutputHelper.verbose('Captured rotated .ROBLOSECURITY cookie from response.'); + } + + return { response, rotatedCookie }; } /** @@ -106,7 +144,7 @@ export async function createPlaceInUniverseAsync( `Creating place "${placeName}" in universe ${universeId}...` ); - const createResponse = await fetchWithCsrfAsync( + const createResult = await fetchWithCsrfAsync( `https://apis.roblox.com/universes/v1/user/universes/${universeId}/places`, cookie, { @@ -118,19 +156,20 @@ export async function createPlaceInUniverseAsync( } ); - if (!createResponse.ok) { - const text = await createResponse.text(); + if (!createResult.response.ok) { + const text = await createResult.response.text(); throw new Error( - `Failed to create place: ${createResponse.status} ${createResponse.statusText}: ${text}` + `Failed to create place: ${createResult.response.status} ${createResult.response.statusText}: ${text}` ); } - const createData = (await createResponse.json()) as { placeId: number }; + const createData = (await createResult.response.json()) as { placeId: number }; const placeId = createData.placeId; - const renameResponse = await fetchWithCsrfAsync( + // Use rotated cookie if the create request triggered rotation + const { response: renameResponse } = await fetchWithCsrfAsync( `https://develop.roblox.com/v2/places/${placeId}`, - cookie, + createResult.rotatedCookie ?? cookie, { method: 'PATCH', headers: { @@ -150,6 +189,36 @@ export async function createPlaceInUniverseAsync( return placeId; } +export interface CookieValidationResult { + valid: boolean; + reason?: 'invalid' | 'network_error'; + status?: number; +} + +/** + * Validates the ROBLOSECURITY cookie against the Roblox API. + * Returns a result indicating whether the cookie is valid. + * Network errors are treated as "unknown" (not invalid) so callers + * can decide whether to continue in offline scenarios. + */ +export async function validateCookieAsync(cookie: string): Promise { + try { + const response = await fetch('https://users.roblox.com/v1/users/authenticated', { + headers: { + Cookie: `.ROBLOSECURITY=${cookie}`, + }, + }); + + if (response.status !== 200) { + return { valid: false, reason: 'invalid', status: response.status }; + } + + return { valid: true }; + } catch { + return { valid: false, reason: 'network_error' }; + } +} + export interface RenamePlaceResult { success: boolean; reason?: 'no_cookie' | 'api_error'; @@ -170,7 +239,7 @@ export async function tryRenamePlaceAsync( return { success: false, reason: 'no_cookie' }; } - const response = await fetchWithCsrfAsync( + const { response } = await fetchWithCsrfAsync( `https://develop.roblox.com/v2/places/${placeId}`, cookie, { diff --git a/tools/nevermore-cli-helpers/src/auth/cookie/linux.ts b/tools/nevermore-cli-helpers/src/auth/cookie/linux.ts new file mode 100644 index 0000000000..b3f8261d3e --- /dev/null +++ b/tools/nevermore-cli-helpers/src/auth/cookie/linux.ts @@ -0,0 +1,175 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { OutputHelper } from '@quenty/cli-output-helpers'; +import { COOKIE_NAME } from './cookie-parser.js'; + +/** + * Read .ROBLOSECURITY from Wine's Credential Manager stored in the Wine + * registry file ($WINEPREFIX/user.reg). + * + * Wine stores Windows Credential Manager entries as registry keys under + * [Software\\Wine\\Credential Manager]. Each credential target becomes a + * subkey with hex-encoded blob values. + * + * Resolution order (mirrors windows.ts): + * 1. Modern: user-specific credential (RobloxStudioAuth.ROBLOSECURITY{userId}) + * 2. Legacy: RobloxStudioAuth.ROBLOSECURITY (no user suffix) + */ +export function readCookie(): string | undefined { + const userReg = getWineUserRegPath(); + if (!userReg || !fs.existsSync(userReg)) { + return undefined; + } + + let regContent: string; + try { + regContent = fs.readFileSync(userReg, 'utf-8'); + } catch { + return undefined; + } + + const credentials = parseWineCredentials(regContent); + + // Modern: user-specific credential + const userId = credentials.get( + 'https://www.roblox.com:RobloxStudioAuthuserid' + ); + if (userId) { + const cookie = credentials.get( + `https://www.roblox.com:RobloxStudioAuth${COOKIE_NAME}${userId}` + ); + if (cookie) { + OutputHelper.verbose( + `Loaded cookie from Wine Credential Manager (user ${userId}).` + ); + return cookie; + } + } + + // Legacy: no user suffix + const legacyCookie = credentials.get( + `https://www.roblox.com:RobloxStudioAuth${COOKIE_NAME}` + ); + if (legacyCookie) { + OutputHelper.verbose( + 'Loaded cookie from Wine Credential Manager (legacy).' + ); + return legacyCookie; + } + + return undefined; +} + +function getWineUserRegPath(): string | undefined { + const wineprefix = process.env.WINEPREFIX || path.join(os.homedir(), '.wine'); + return path.join(wineprefix, 'user.reg'); +} + +/** + * Parse Wine's user.reg file for Credential Manager entries. + * + * Wine stores credentials under registry keys like: + * [Software\\Wine\\Credential Manager] + * + * Each credential is a named value where the name is the target and the + * value is a hex-encoded binary blob. The credential blob (the actual + * secret) is stored as UTF-8 bytes within the binary structure. + * + * Returns a Map of target name -> credential value (decoded string). + */ +function parseWineCredentials(regContent: string): Map { + const credentials = new Map(); + + // Wine Credential Manager stores creds as individual hex blobs under + // [Software\\Wine\\Credential Manager]. The key format is: + // "Target Name"=hex:xx,xx,xx,... + // The hex blob is a serialized CREDENTIAL struct. + const credSectionMatch = regContent.match( + /\[Software\\\\Wine\\\\Credential Manager\]([\s\S]*?)(?=\n\[|$)/i + ); + if (!credSectionMatch) { + return credentials; + } + + const section = credSectionMatch[1]; + + // Match each credential entry: "TargetName"=hex:bytes + const entryRegex = /^"(.+?)"=hex:(.+)$/gm; + let match; + while ((match = entryRegex.exec(section)) !== null) { + const targetName = unescapeRegString(match[1]); + const hexStr = match[2].replace(/\\\n\s*/g, '').replace(/,/g, ''); + + try { + const blob = Buffer.from(hexStr, 'hex'); + const value = extractCredentialBlob(blob); + if (value) { + credentials.set(targetName, value); + } + } catch { + // Malformed hex data + } + } + + return credentials; +} + +/** + * Extract the credential value from a Wine serialized CREDENTIAL blob. + * + * The blob layout follows the Windows CREDENTIAL struct. The credential + * value (CredentialBlob) is stored as UTF-8 bytes. We look for the + * actual cookie/value content by searching for known patterns. + */ +function extractCredentialBlob(blob: Buffer): string | undefined { + // Wine's serialized credential format stores the blob data inline. + // The simplest approach: the credential value for Roblox entries is + // plain UTF-8 text. Try to find it by looking for cookie-like content + // or numeric user IDs. + + // Try interpreting the entire blob as UTF-8 and looking for the value + const text = blob.toString('utf-8'); + + // For simple values (user IDs, cookie names), the blob may just be + // the raw UTF-8 string + if (text && isPrintableAscii(text)) { + return text; + } + + // For structured blobs, search for the credential data section. + // Wine writes a serialized struct — scan for the longest printable + // ASCII substring that looks like a credential value. + let best = ''; + let current = ''; + for (let i = 0; i < blob.length; i++) { + const byte = blob[i]; + if (byte >= 0x20 && byte < 0x7f) { + current += String.fromCharCode(byte); + } else { + if (current.length > best.length) { + best = current; + } + current = ''; + } + } + if (current.length > best.length) { + best = current; + } + + return best.length > 0 ? best : undefined; +} + +function isPrintableAscii(str: string): boolean { + for (let i = 0; i < str.length; i++) { + const code = str.charCodeAt(i); + if (code < 0x20 || code > 0x7e) { + return false; + } + } + return str.length > 0; +} + +function unescapeRegString(str: string): string { + return str.replace(/\\\\/g, '\\'); +} diff --git a/tools/nevermore-cli/src/utils/auth/roblox-auth/macos.ts b/tools/nevermore-cli-helpers/src/auth/cookie/macos.ts similarity index 100% rename from tools/nevermore-cli/src/utils/auth/roblox-auth/macos.ts rename to tools/nevermore-cli-helpers/src/auth/cookie/macos.ts diff --git a/tools/nevermore-cli-helpers/src/auth/cookie/validate-cookie.test.ts b/tools/nevermore-cli-helpers/src/auth/cookie/validate-cookie.test.ts new file mode 100644 index 0000000000..40cecc3f1e --- /dev/null +++ b/tools/nevermore-cli-helpers/src/auth/cookie/validate-cookie.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +import { validateCookieAsync } from './index.js'; + +describe('validateCookieAsync', () => { + const originalFetch = globalThis.fetch; + + beforeEach(() => { + vi.restoreAllMocks(); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it('returns valid when cookie is accepted (HTTP 200)', async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ status: 200 }); + + const result = await validateCookieAsync('valid-cookie'); + + expect(result).toEqual({ valid: true }); + }); + + it('returns invalid with status when cookie is rejected (HTTP 401)', async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ status: 401 }); + + const result = await validateCookieAsync('expired-cookie'); + + expect(result).toEqual({ valid: false, reason: 'invalid', status: 401 }); + }); + + it('returns network_error when fetch throws', async () => { + globalThis.fetch = vi.fn().mockRejectedValue(new Error('network error')); + + const result = await validateCookieAsync('some-cookie'); + + expect(result).toEqual({ valid: false, reason: 'network_error' }); + }); +}); diff --git a/tools/nevermore-cli/src/utils/auth/roblox-auth/windows.ts b/tools/nevermore-cli-helpers/src/auth/cookie/windows.ts similarity index 100% rename from tools/nevermore-cli/src/utils/auth/roblox-auth/windows.ts rename to tools/nevermore-cli-helpers/src/auth/cookie/windows.ts diff --git a/tools/nevermore-cli/src/utils/auth/credential-store.ts b/tools/nevermore-cli-helpers/src/auth/open-cloud/credential-store.ts similarity index 100% rename from tools/nevermore-cli/src/utils/auth/credential-store.ts rename to tools/nevermore-cli-helpers/src/auth/open-cloud/credential-store.ts diff --git a/tools/nevermore-cli-helpers/src/utils.ts b/tools/nevermore-cli-helpers/src/utils.ts index b063af1af7..392174d4f3 100644 --- a/tools/nevermore-cli-helpers/src/utils.ts +++ b/tools/nevermore-cli-helpers/src/utils.ts @@ -1 +1,20 @@ export { VersionChecker } from './version-checker.js'; + +export { + getRobloxCookieAsync, + createPlaceInUniverseAsync, + tryRenamePlaceAsync, + validateCookieAsync, +} from './auth/cookie/index.js'; +export type { RenamePlaceResult, CookieValidationResult } from './auth/cookie/index.js'; +export { COOKIE_NAME, parseStudioCookieValue } from './auth/cookie/cookie-parser.js'; + +export { + getApiKeyAsync, + loadStoredApiKeyAsync, + saveApiKeyAsync, + clearApiKeyAsync, + validateApiKeyAsync, + printApiKeySetupHelp, +} from './auth/open-cloud/credential-store.js'; +export type { CredentialArgs } from './auth/open-cloud/credential-store.js'; diff --git a/tools/nevermore-cli/src/commands/batch-command/batch-deploy-command.ts b/tools/nevermore-cli/src/commands/batch-command/batch-deploy-command.ts index e7cfd5b13f..72b559bf33 100644 --- a/tools/nevermore-cli/src/commands/batch-command/batch-deploy-command.ts +++ b/tools/nevermore-cli/src/commands/batch-command/batch-deploy-command.ts @@ -12,7 +12,7 @@ import { } from '@quenty/cli-output-helpers/reporting'; import { resolvePackagePath } from '@quenty/nevermore-template-helpers'; import { NevermoreGlobalArgs } from '../../args/global-args.js'; -import { getApiKeyAsync } from '../../utils/auth/credential-store.js'; +import { getApiKeyAsync } from '@quenty/nevermore-cli-helpers'; import { runBatchAsync } from '../../utils/batch/batch-runner.js'; import { uploadPlaceAsync } from '../../utils/build/upload.js'; import { type BatchDeployResult } from '../../utils/deploy/deploy-github-columns.js'; diff --git a/tools/nevermore-cli/src/commands/batch-command/batch-test-command.ts b/tools/nevermore-cli/src/commands/batch-command/batch-test-command.ts index 2932564bf2..2d3bde59bb 100644 --- a/tools/nevermore-cli/src/commands/batch-command/batch-test-command.ts +++ b/tools/nevermore-cli/src/commands/batch-command/batch-test-command.ts @@ -6,7 +6,7 @@ import { type ProgressSummary, } from '@quenty/cli-output-helpers/reporting'; import { NevermoreGlobalArgs } from '../../args/global-args.js'; -import { getApiKeyAsync } from '../../utils/auth/credential-store.js'; +import { getApiKeyAsync } from '@quenty/nevermore-cli-helpers'; import { runBatchAsync } from '../../utils/batch/batch-runner.js'; import { type JobContext, diff --git a/tools/nevermore-cli/src/commands/deploy-command/deploy-init-prompts.ts b/tools/nevermore-cli/src/commands/deploy-command/deploy-init-prompts.ts index 03c7989efc..04a1de8c41 100644 --- a/tools/nevermore-cli/src/commands/deploy-command/deploy-init-prompts.ts +++ b/tools/nevermore-cli/src/commands/deploy-command/deploy-init-prompts.ts @@ -1,6 +1,6 @@ import inquirer from 'inquirer'; import { OutputHelper } from '@quenty/cli-output-helpers'; -import { getRobloxCookieAsync, createPlaceInUniverseAsync } from '../../utils/auth/roblox-auth/index.js'; +import { getRobloxCookieAsync, createPlaceInUniverseAsync } from '@quenty/nevermore-cli-helpers'; interface RobloxPlace { id: number; diff --git a/tools/nevermore-cli/src/commands/deploy-command/deploy-init.ts b/tools/nevermore-cli/src/commands/deploy-command/deploy-init.ts index 346d323ec8..87105e734f 100644 --- a/tools/nevermore-cli/src/commands/deploy-command/deploy-init.ts +++ b/tools/nevermore-cli/src/commands/deploy-command/deploy-init.ts @@ -3,7 +3,7 @@ import * as fs from 'fs/promises'; import * as path from 'path'; import { OutputHelper } from '@quenty/cli-output-helpers'; import { DeployConfig, discoverUniverseIdAsync } from '../../utils/build/deploy-config.js'; -import { getRobloxCookieAsync, createPlaceInUniverseAsync } from '../../utils/auth/roblox-auth/index.js'; +import { getRobloxCookieAsync, createPlaceInUniverseAsync } from '@quenty/nevermore-cli-helpers'; import { fileExistsAsync, buildPlaceNameAsync } from '../../utils/nevermore-cli-utils.js'; import { DeployArgs } from './index.js'; import { promptPlaceIdAsync } from './deploy-init-prompts.js'; diff --git a/tools/nevermore-cli/src/commands/deploy-command/index.ts b/tools/nevermore-cli/src/commands/deploy-command/index.ts index 11e6437116..f8c60541e5 100644 --- a/tools/nevermore-cli/src/commands/deploy-command/index.ts +++ b/tools/nevermore-cli/src/commands/deploy-command/index.ts @@ -9,7 +9,7 @@ import { SimpleReporter, } from '@quenty/cli-output-helpers/reporting'; import { NevermoreGlobalArgs } from '../../args/global-args.js'; -import { getApiKeyAsync } from '../../utils/auth/credential-store.js'; +import { getApiKeyAsync } from '@quenty/nevermore-cli-helpers'; import { uploadPlaceAsync } from '../../utils/build/upload.js'; import { OpenCloudClient } from '../../utils/open-cloud/open-cloud-client.js'; import { RateLimiter } from '../../utils/open-cloud/rate-limiter.js'; diff --git a/tools/nevermore-cli/src/commands/login-command.ts b/tools/nevermore-cli/src/commands/login-command.ts index 54e5b78ea7..860f57f734 100644 --- a/tools/nevermore-cli/src/commands/login-command.ts +++ b/tools/nevermore-cli/src/commands/login-command.ts @@ -8,7 +8,7 @@ import { clearApiKeyAsync, validateApiKeyAsync, printApiKeySetupHelp, -} from '../utils/auth/credential-store.js'; +} from '@quenty/nevermore-cli-helpers'; export interface LoginArgs extends NevermoreGlobalArgs { apiKey?: string; diff --git a/tools/nevermore-cli/src/commands/test-command/test-command.ts b/tools/nevermore-cli/src/commands/test-command/test-command.ts index 60ac144746..3ace549bd9 100644 --- a/tools/nevermore-cli/src/commands/test-command/test-command.ts +++ b/tools/nevermore-cli/src/commands/test-command/test-command.ts @@ -2,7 +2,7 @@ import * as path from 'path'; import { Argv, CommandModule } from 'yargs'; import { OutputHelper } from '@quenty/cli-output-helpers'; import { NevermoreGlobalArgs } from '../../args/global-args.js'; -import { getApiKeyAsync } from '../../utils/auth/credential-store.js'; +import { getApiKeyAsync } from '@quenty/nevermore-cli-helpers'; import { OpenCloudClient } from '../../utils/open-cloud/open-cloud-client.js'; import { RateLimiter } from '../../utils/open-cloud/rate-limiter.js'; import { readPackageNameAsync } from '../../utils/nevermore-cli-utils.js'; diff --git a/tools/nevermore-cli/src/utils/build/upload.ts b/tools/nevermore-cli/src/utils/build/upload.ts index aea01176c0..d00c06d813 100644 --- a/tools/nevermore-cli/src/utils/build/upload.ts +++ b/tools/nevermore-cli/src/utils/build/upload.ts @@ -1,4 +1,4 @@ -import { getApiKeyAsync, CredentialArgs } from '../auth/credential-store.js'; +import { getApiKeyAsync, type CredentialArgs } from '@quenty/nevermore-cli-helpers'; import { type DeployTarget } from './deploy-config.js'; import { OpenCloudClient } from '../open-cloud/open-cloud-client.js'; import { type BuiltPlace } from './build.js'; diff --git a/tools/nevermore-cli/src/utils/job-context/cloud-job-context.ts b/tools/nevermore-cli/src/utils/job-context/cloud-job-context.ts index f7de7e0745..164c389adc 100644 --- a/tools/nevermore-cli/src/utils/job-context/cloud-job-context.ts +++ b/tools/nevermore-cli/src/utils/job-context/cloud-job-context.ts @@ -3,7 +3,7 @@ import { type LuauTask, type OpenCloudClient, } from '../open-cloud/open-cloud-client.js'; -import { tryRenamePlaceAsync } from '../auth/roblox-auth/index.js'; +import { tryRenamePlaceAsync } from '@quenty/nevermore-cli-helpers'; import { buildPlaceNameAsync, timeoutAsync } from '../nevermore-cli-utils.js'; import { type Deployment, diff --git a/tools/nevermore-template-helpers/src/build/build-context.ts b/tools/nevermore-template-helpers/src/build/build-context.ts index 47caf4159e..3f5209328e 100644 --- a/tools/nevermore-template-helpers/src/build/build-context.ts +++ b/tools/nevermore-template-helpers/src/build/build-context.ts @@ -1,8 +1,8 @@ -import { OutputHelper } from '@quenty/cli-output-helpers'; import { execa } from 'execa'; import * as fs from 'fs/promises'; import * as os from 'os'; import * as path from 'path'; +import { OutputHelper } from '@quenty/cli-output-helpers'; export interface RojoBuildOptions { /** Absolute path to the rojo project JSON file */ @@ -73,8 +73,16 @@ export class BuildContext { } const args = ['build', projectPath]; + + // On Linux, rojo's --plugin flag is not supported. Build to a temp + // file with -o and copy to the plugins folder ourselves. + const usePluginFallback = plugin && process.platform === 'linux'; + if (output) { args.push('-o', output); + } else if (usePluginFallback) { + const tempOutput = path.join(this._targetdir, plugin); + args.push('-o', tempOutput); } else if (plugin) { args.push('--plugin', plugin); } @@ -83,6 +91,13 @@ export class BuildContext { if (plugin && pluginsFolder) { const pluginPath = path.join(pluginsFolder, plugin); + + if (usePluginFallback) { + const tempOutput = path.join(this._targetdir, plugin); + await fs.mkdir(pluginsFolder, { recursive: true }); + await fs.copyFile(tempOutput, pluginPath); + } + this._trackedFiles.push(pluginPath); return pluginPath; } @@ -123,9 +138,8 @@ export class BuildContext { } } - OutputHelper.verbose(`Cleaning up build directory: ${this._targetdir}`); - try { + OutputHelper.verbose(`[Build] Cleaning up build directory: ${this._targetdir}`); await fs.rm(this._targetdir, { recursive: true, force: true }); } catch { // best effort diff --git a/tools/nevermore-template-helpers/src/scaffolding/template-helpers.ts b/tools/nevermore-template-helpers/src/scaffolding/template-helpers.ts index f1fe427a35..79271ec606 100644 --- a/tools/nevermore-template-helpers/src/scaffolding/template-helpers.ts +++ b/tools/nevermore-template-helpers/src/scaffolding/template-helpers.ts @@ -82,7 +82,7 @@ export class TemplateHelper { } else { if (!(await existsAsync(newFilePath))) { await fs.promises.writeFile(newFilePath, result, 'utf8'); - OutputHelper.verbose(`Created '${newFilePath}'`); + OutputHelper.verbose(`[Template] Created '${newFilePath}'`); } else { OutputHelper.error( `File already exists ${newFilePath} will not overwrite` diff --git a/tools/studio-bridge/docker/.dockerignore b/tools/studio-bridge/docker/.dockerignore new file mode 100644 index 0000000000..ed5e294718 --- /dev/null +++ b/tools/studio-bridge/docker/.dockerignore @@ -0,0 +1,2 @@ +# Primary build context is this directory — only entrypoint.sh is needed. +# The workspace named build context handles repo files. diff --git a/tools/studio-bridge/docker/Dockerfile b/tools/studio-bridge/docker/Dockerfile new file mode 100644 index 0000000000..a72e5b3ff8 --- /dev/null +++ b/tools/studio-bridge/docker/Dockerfile @@ -0,0 +1,104 @@ +# syntax=docker/dockerfile:1 +FROM ubuntu:24.04 +ARG STUDIO_VERSION +ARG DEBIAN_FRONTEND=noninteractive + +# --- System deps (cached layer, rarely changes) --- +# Mirrors linux-prerequisites.ts:installDependenciesAsync() +RUN dpkg --add-architecture i386 \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates curl gnupg software-properties-common \ + xvfb openbox mesa-utils \ + gcc-mingw-w64-x86-64 unzip procps \ + # WineHQ repo for Wine 11+ (same logic as linux-prerequisites.ts:91-128) + && mkdir -pm755 /etc/apt/keyrings \ + && curl -sL https://dl.winehq.org/wine-builds/winehq.key \ + -o /etc/apt/keyrings/winehq-archive.key \ + && curl -sfL https://dl.winehq.org/wine-builds/ubuntu/dists/noble/winehq-noble.sources \ + -o /etc/apt/sources.list.d/winehq-noble.sources \ + && apt-get update \ + && apt-get install -y --no-install-recommends winehq-stable \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# --- Node.js 22 LTS + GitHub CLI (needed by setup-aftman action) --- +RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ + && curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ + -o /usr/share/keyrings/githubcli-archive-keyring.gpg \ + && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ + > /etc/apt/sources.list.d/github-cli.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends nodejs gh \ + && corepack enable pnpm \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# --- Aftman binary --- +RUN curl -fsSL https://github.com/LPGhatguy/aftman/releases/download/v0.3.0/aftman-0.3.0-linux-x86_64.zip \ + -o /tmp/aftman.zip \ + && unzip -o /tmp/aftman.zip -d /tmp/aftman \ + && install -m 755 /tmp/aftman/aftman /usr/local/bin/aftman \ + && rm -rf /tmp/aftman.zip /tmp/aftman + +# --- Non-root user --- +RUN useradd -m -s /bin/bash studio +USER studio +WORKDIR /home/studio + +# --- Install Aftman tools (rojo, lune, etc.) --- +# aftman.toml lives in $HOME so shims can find it from any CWD +COPY --from=workspace --chown=studio:studio aftman.toml /home/studio/aftman.toml +RUN mkdir -p /home/studio/.aftman/bin \ + && aftman install --no-trust-check + +# --- Build studio-bridge from source (via named build context "workspace") --- +COPY --from=workspace --chown=studio:studio package.json pnpm-workspace.yaml pnpm-lock.yaml tsconfig.json /home/studio/build/ +COPY --from=workspace --chown=studio:studio tools/ /home/studio/build/tools/ +WORKDIR /home/studio/build +RUN pnpm install --frozen-lockfile --filter "@quenty/studio-bridge..." \ + && pnpm -r --filter "@quenty/studio-bridge..." run build + +# --- Run studio-bridge to set up Studio (single source of truth!) --- +# Invoke cli.js directly — workspace deps are resolved by pnpm in node_modules. +RUN node tools/studio-bridge/dist/src/cli/cli.js linux setup \ + ${STUDIO_VERSION:+--studio-version "$STUDIO_VERSION"} + +# --- Pre-initialize Wine prefix and compile write-cred.exe --- +# Doing this at build time saves ~40s per auth invocation at runtime. +RUN Xvfb :99 -screen 0 1024x768x24 & \ + sleep 1 \ + && DISPLAY=:99 WINEPREFIX=/home/studio/.wine WINEARCH=win64 \ + WINEDEBUG=-all WINEDLLOVERRIDES="mscoree=d;mshtml=d" \ + wineboot -i \ + && x86_64-w64-mingw32-gcc \ + -o /home/studio/roblox-studio/write-cred.exe \ + tools/studio-bridge/src/linux/write-cred.c \ + -lcredui -ladvapi32 \ + && kill %1 || true \ + && rm -f /tmp/.X99-lock + +# --- Install studio-bridge globally for runtime, then clean up --- +# Use pnpm deploy to create a self-contained copy with resolved workspace deps, +# then link the binary. This avoids npm registry lookups for workspace packages. +RUN pnpm --filter "@quenty/studio-bridge" deploy --legacy --prod /home/studio/.studio-bridge \ + && mkdir -p /home/studio/.npm-global/bin \ + && ln -s /home/studio/.studio-bridge/dist/src/cli/cli.js /home/studio/.npm-global/bin/studio-bridge \ + && chmod +x /home/studio/.studio-bridge/dist/src/cli/cli.js \ + && rm -rf /home/studio/build + +# --- Environment (matches linux-wine-env.ts:buildWineEnv) --- +ENV STUDIO_DIR=/home/studio/roblox-studio \ + WINEPREFIX=/home/studio/.wine \ + DISPLAY=:99 \ + WINEDEBUG=-all \ + WINEARCH=win64 \ + WINEDLLOVERRIDES="mscoree=d;mshtml=d" \ + MESA_GL_VERSION_OVERRIDE=4.5 \ + MESA_GLSL_VERSION_OVERRIDE=450 \ + NPM_CONFIG_PREFIX=/home/studio/.npm-global \ + PATH=/home/studio/.aftman/bin:/home/studio/.npm-global/bin:$PATH + +COPY --chown=studio:studio entrypoint.sh /home/studio/entrypoint.sh +RUN chmod +x /home/studio/entrypoint.sh +WORKDIR /home/studio +ENTRYPOINT ["/home/studio/entrypoint.sh"] +CMD ["bash"] diff --git a/tools/studio-bridge/docker/docker-compose.yml b/tools/studio-bridge/docker/docker-compose.yml new file mode 100644 index 0000000000..1aed85c4ce --- /dev/null +++ b/tools/studio-bridge/docker/docker-compose.yml @@ -0,0 +1,20 @@ +services: + studio: + build: + context: . + additional_contexts: + workspace: ../../.. + args: + STUDIO_VERSION: ${STUDIO_VERSION:-} + image: ghcr.io/quenty/nevermore-studio-linux:${STUDIO_VERSION:-latest} + environment: + - ROBLOSECURITY=${ROBLOSECURITY:-} + volumes: + - ../../../:/workspace + - wine-prefix:/home/studio/.wine + working_dir: /workspace + stdin_open: true + tty: true + +volumes: + wine-prefix: diff --git a/tools/studio-bridge/docker/entrypoint.sh b/tools/studio-bridge/docker/entrypoint.sh new file mode 100644 index 0000000000..0b534be41c --- /dev/null +++ b/tools/studio-bridge/docker/entrypoint.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Start Xvfb + openbox (mirrors linux-display-manager.ts), then exec user command. +set -euo pipefail + +if ! pgrep -x Xvfb > /dev/null 2>&1; then + Xvfb "${DISPLAY:-:99}" -screen 0 1024x768x24 & + sleep 0.5 +fi + +if ! pgrep -x openbox > /dev/null 2>&1; then + DISPLAY="${DISPLAY:-:99}" openbox & + sleep 0.5 +fi + +# Re-detect network interfaces so Wine sees the runtime network, not the +# stale build-time config baked in by wineboot -i during docker build. +wineboot -u > /dev/null 2>&1 || true + +exec "$@" diff --git a/tools/studio-bridge/src/bridge/bridge-session.test.ts b/tools/studio-bridge/src/bridge/bridge-session.test.ts index 36c262e3d6..8d932bb5d2 100644 --- a/tools/studio-bridge/src/bridge/bridge-session.test.ts +++ b/tools/studio-bridge/src/bridge/bridge-session.test.ts @@ -123,12 +123,6 @@ describe('BridgeSession', () => { await expect( session.queryDataModelAsync({ path: 'game' }), ).rejects.toThrow(SessionDisconnectedError); - await expect( - session.subscribeAsync(['stateChange']), - ).rejects.toThrow(SessionDisconnectedError); - await expect( - session.unsubscribeAsync(['stateChange']), - ).rejects.toThrow(SessionDisconnectedError); }); }); @@ -326,47 +320,6 @@ describe('BridgeSession', () => { }); }); - // ----------------------------------------------------------------------- - // Action: subscribeAsync / unsubscribeAsync - // ----------------------------------------------------------------------- - - describe('subscribeAsync', () => { - it('sends subscribe message', async () => { - const handle = new MockTransportHandle(); - handle.sendActionAsync.mockResolvedValueOnce({ - type: 'subscribeResult', - sessionId: 'session-1', - requestId: 'r-1', - payload: { events: ['stateChange'] }, - }); - - const session = new BridgeSession(createSessionInfo(), handle); - await session.subscribeAsync(['stateChange']); - - const [msg] = handle.sendActionAsync.mock.calls[0]; - expect((msg as ServerMessage).type).toBe('subscribe'); - expect((msg as any).payload.events).toEqual(['stateChange']); - }); - }); - - describe('unsubscribeAsync', () => { - it('sends unsubscribe message', async () => { - const handle = new MockTransportHandle(); - handle.sendActionAsync.mockResolvedValueOnce({ - type: 'unsubscribeResult', - sessionId: 'session-1', - requestId: 'r-1', - payload: { events: ['stateChange'] }, - }); - - const session = new BridgeSession(createSessionInfo(), handle); - await session.unsubscribeAsync(['stateChange']); - - const [msg] = handle.sendActionAsync.mock.calls[0]; - expect((msg as ServerMessage).type).toBe('unsubscribe'); - }); - }); - // ----------------------------------------------------------------------- // State change events // ----------------------------------------------------------------------- diff --git a/tools/studio-bridge/src/bridge/bridge-session.ts b/tools/studio-bridge/src/bridge/bridge-session.ts index d6ca9a4213..170a07fba1 100644 --- a/tools/studio-bridge/src/bridge/bridge-session.ts +++ b/tools/studio-bridge/src/bridge/bridge-session.ts @@ -23,7 +23,7 @@ import type { QueryDataModelOptions, } from './types.js'; import { SessionDisconnectedError } from './types.js'; -import type { SubscribableEvent, PluginMessage, OutputLevel } from '../server/web-socket-protocol.js'; +import type { PluginMessage, OutputLevel } from '../server/web-socket-protocol.js'; import { loadActionSourcesAsync, type ActionSource } from '../commands/framework/action-loader.js'; import { OutputHelper } from '@quenty/cli-output-helpers'; @@ -37,8 +37,6 @@ const DEFAULT_TIMEOUTS: Record = { captureScreenshot: 30_000, queryDataModel: 10_000, queryLogs: 10_000, - subscribe: 5_000, - unsubscribe: 5_000, }; // --------------------------------------------------------------------------- @@ -276,44 +274,6 @@ export class BridgeSession extends EventEmitter { throw pluginError('dataModelResult', result); } - /** - * Subscribe to push events from the plugin. - */ - async subscribeAsync(events: SubscribableEvent[]): Promise { - this._assertConnected(); - await this._ensureActionsAsync(); - - const timeoutMs = DEFAULT_TIMEOUTS.subscribe; - await this._handle.sendActionAsync( - { - type: 'subscribe', - sessionId: this._info.sessionId, - requestId: randomUUID(), - payload: { events }, - }, - timeoutMs, - ); - } - - /** - * Unsubscribe from push events. - */ - async unsubscribeAsync(events: SubscribableEvent[]): Promise { - this._assertConnected(); - await this._ensureActionsAsync(); - - const timeoutMs = DEFAULT_TIMEOUTS.unsubscribe; - await this._handle.sendActionAsync( - { - type: 'unsubscribe', - sessionId: this._info.sessionId, - requestId: randomUUID(), - payload: { events }, - }, - timeoutMs, - ); - } - // ----------------------------------------------------------------------- // Private // ----------------------------------------------------------------------- diff --git a/tools/studio-bridge/src/bridge/internal/__tests__/failover-inflight.test.ts b/tools/studio-bridge/src/bridge/internal/__tests__/failover-inflight.test.ts index 81ae4478ef..9583bb4f8e 100644 --- a/tools/studio-bridge/src/bridge/internal/__tests__/failover-inflight.test.ts +++ b/tools/studio-bridge/src/bridge/internal/__tests__/failover-inflight.test.ts @@ -241,12 +241,6 @@ describe('Inflight request handling during failover', () => { await expect( session!.queryDataModelAsync({ path: 'game' }), ).rejects.toThrow(SessionDisconnectedError); - await expect( - session!.subscribeAsync(['stateChange']), - ).rejects.toThrow(SessionDisconnectedError); - await expect( - session!.unsubscribeAsync(['stateChange']), - ).rejects.toThrow(SessionDisconnectedError); }); it('client emits disconnected event when host dies', async () => { diff --git a/tools/studio-bridge/src/bridge/internal/bridge-client.ts b/tools/studio-bridge/src/bridge/internal/bridge-client.ts index 71c76b3ba4..073d343888 100644 --- a/tools/studio-bridge/src/bridge/internal/bridge-client.ts +++ b/tools/studio-bridge/src/bridge/internal/bridge-client.ts @@ -314,11 +314,10 @@ export class BridgeClient extends EventEmitter { } // Resolve with a synthetic message (the caller only cares about the list) pending.resolve({ - type: 'subscribeResult', + type: 'heartbeat', sessionId: '', - requestId: msg.requestId, - payload: { events: [] }, - } as PluginMessage); + payload: { uptimeMs: 0, state: 'Edit', pendingRequests: 0 }, + }); } } diff --git a/tools/studio-bridge/src/bridge/types.ts b/tools/studio-bridge/src/bridge/types.ts index ae9c865750..4921597c74 100644 --- a/tools/studio-bridge/src/bridge/types.ts +++ b/tools/studio-bridge/src/bridge/types.ts @@ -9,13 +9,12 @@ import type { StudioState, Capability, - SubscribableEvent, DataModelInstance, OutputLevel, } from '../server/web-socket-protocol.js'; // Re-export protocol types used in the public API -export type { StudioState, Capability, SubscribableEvent, DataModelInstance, OutputLevel }; +export type { StudioState, Capability, DataModelInstance, OutputLevel }; // --------------------------------------------------------------------------- // Session and instance metadata diff --git a/tools/studio-bridge/src/cli/adapters/group-builder.ts b/tools/studio-bridge/src/cli/adapters/group-builder.ts index 31be4fa7e2..260f77adf9 100644 --- a/tools/studio-bridge/src/cli/adapters/group-builder.ts +++ b/tools/studio-bridge/src/cli/adapters/group-builder.ts @@ -28,6 +28,7 @@ const GROUP_DESCRIPTIONS: Record = { process: 'Manage Studio processes', plugin: 'Manage the bridge plugin', action: 'Invoke a Studio action', + linux: 'Linux/Wine environment management', }; // --------------------------------------------------------------------------- diff --git a/tools/studio-bridge/src/cli/cli.ts b/tools/studio-bridge/src/cli/cli.ts index c5c83f9da1..691c627844 100644 --- a/tools/studio-bridge/src/cli/cli.ts +++ b/tools/studio-bridge/src/cli/cli.ts @@ -39,7 +39,9 @@ import { uninstallCommand } from '../commands/plugin/uninstall/uninstall.js'; import { serveCommand } from '../commands/serve/serve.js'; import { mcpCommand } from '../commands/mcp/mcp.js'; import { terminalCommand } from '../commands/terminal/terminal.js'; -import { actionCommand } from '../commands/action/action.js'; +import { linuxSetupCommand } from '../commands/linux/setup/setup.js'; +import { linuxInjectCredentialsCommand } from '../commands/linux/inject-credentials/inject-credentials.js'; +import { linuxStatusCommand } from '../commands/linux/status/status.js'; // --------------------------------------------------------------------------- // Build registry @@ -53,7 +55,6 @@ registry.register(logsCommand); registry.register(queryCommand); registry.register(screenshotCommand); registry.register(processRunCommand); -registry.register(actionCommand); // Infrastructure commands registry.register(infoCommand); @@ -66,6 +67,11 @@ registry.register(serveCommand); registry.register(mcpCommand); registry.register(terminalCommand); +// Linux commands +registry.register(linuxSetupCommand); +registry.register(linuxInjectCredentialsCommand); +registry.register(linuxStatusCommand); + // --------------------------------------------------------------------------- // Build yargs commands from registry // --------------------------------------------------------------------------- @@ -150,7 +156,7 @@ for (const group of groups) { cli.command(group as any); } -// Register top-level commands from registry (serve, mcp, action) +// Register top-level commands from registry (serve, mcp) // Terminal is handled separately below due to its custom REPL handler for (const cmd of topLevel) { const cmdDef = cmd as { command?: string }; diff --git a/tools/studio-bridge/src/cli/script-executor.ts b/tools/studio-bridge/src/cli/script-executor.ts index 672492ba5a..b66e9eb207 100644 --- a/tools/studio-bridge/src/cli/script-executor.ts +++ b/tools/studio-bridge/src/cli/script-executor.ts @@ -24,6 +24,7 @@ export interface ExecuteScriptOptions { timeoutMs: number; verbose: boolean; showLogs: boolean; + filePath?: string; } /** @@ -52,6 +53,15 @@ export async function resolvePlacePathAsync( export async function executeScriptAsync( options: ExecuteScriptOptions ): Promise { + const { shouldDelegateToDockerAsync, delegateToDockerAsync } = + await import('../docker/docker-delegator.js'); + + if (await shouldDelegateToDockerAsync()) { + OutputHelper.verbose('[StudioBridge] No Wine detected, delegating to Docker'); + await delegateToDockerAsync(options); + return; // unreachable — delegateToDockerAsync calls process.exit + } + const { scriptContent, packageName, placePath, timeoutMs, verbose, showLogs } = options; diff --git a/tools/studio-bridge/src/commands/action/action.ts b/tools/studio-bridge/src/commands/action/action.ts deleted file mode 100644 index d0d56b98e7..0000000000 --- a/tools/studio-bridge/src/commands/action/action.ts +++ /dev/null @@ -1,96 +0,0 @@ -/** - * `action ` -- invoke a named Studio action on a connected session. - * - * The action name is a positional argument. The plugin is expected to - * have the action registered (either statically or pushed dynamically). - * The result is returned as the raw action response payload. - */ - -import { defineCommand } from '../framework/define-command.js'; -import { arg } from '../framework/arg-builder.js'; -import type { BridgeSession } from '../../bridge/index.js'; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -export interface ActionResult { - success: boolean; - response: unknown; - summary: string; -} - -interface ActionArgs { - name: string; - payload?: string; -} - -// --------------------------------------------------------------------------- -// Handler -// --------------------------------------------------------------------------- - -/** - * Stub — action discovery and generic dispatch are not yet implemented. - * Returns a structured error explaining the limitation. - */ -export async function invokeActionHandlerAsync( - _session: BridgeSession, - actionName: string, - _payload?: Record, -): Promise { - return { - success: false, - response: null, - summary: `Action '${actionName}' cannot be invoked: the action command is not yet implemented.`, - }; -} - -// --------------------------------------------------------------------------- -// Command definition -// --------------------------------------------------------------------------- - -export const actionCommand = defineCommand({ - group: null, - name: 'action', - description: 'Invoke a named Studio action on a connected session', - category: 'execution', - safety: 'mutate', - scope: 'session', - args: { - name: arg.positional({ - description: 'Name of the action to invoke', - required: true, - }), - payload: arg.option({ - description: 'JSON payload to send with the action', - alias: 'P', - }), - }, - handler: async (session, args) => { - let payload: Record | undefined; - if (args.payload) { - try { - payload = JSON.parse(args.payload); - } catch { - throw new Error(`Invalid JSON payload: ${args.payload}`); - } - } - return invokeActionHandlerAsync(session, args.name, payload); - }, - mcp: { - toolName: 'studio_action', - mapInput: (input) => ({ - name: input.name as string, - payload: input.payload as string | undefined, - }), - mapResult: (result) => [ - { - type: 'text' as const, - text: JSON.stringify({ - success: result.success, - response: result.response, - }), - }, - ], - }, -}); diff --git a/tools/studio-bridge/src/commands/action/invoke-action.luau b/tools/studio-bridge/src/commands/action/invoke-action.luau deleted file mode 100644 index 358d3a2efb..0000000000 --- a/tools/studio-bridge/src/commands/action/invoke-action.luau +++ /dev/null @@ -1,22 +0,0 @@ ---[[ - Placeholder for a future invokeAction handler. - - The `studio-bridge action ` command is not yet implemented — - action discovery and generic dispatch require further design work. - - Loaded dynamically via registerAction. -]] - -local InvokeAction = {} - -function InvokeAction.register(router: any) - router:setResponseType("invokeAction", "invokeActionResult") - router:register("invokeAction", function(_payload: { [string]: any }, _requestId: string, _sessionId: string) - return { - success = false, - error = "invokeAction is not yet implemented", - } - end) -end - -return InvokeAction diff --git a/tools/studio-bridge/src/commands/framework/subscribe.luau b/tools/studio-bridge/src/commands/framework/subscribe.luau deleted file mode 100644 index 5274678756..0000000000 --- a/tools/studio-bridge/src/commands/framework/subscribe.luau +++ /dev/null @@ -1,30 +0,0 @@ ---[[ - Subscribe/Unsubscribe action handler stubs for the studio-bridge plugin. - - Echoes back the requested events without actually pushing events yet. - This allows the server to complete subscribe/unsubscribe round-trips - while the actual event push mechanism is implemented later. - - Protocol: - Request: { type: "subscribe", payload: { events: ["stateChange", "logPush"] } } - Response: { type: "subscribeResult", payload: { events: ["stateChange", "logPush"] } } - - Request: { type: "unsubscribe", payload: { events: ["stateChange"] } } - Response: { type: "unsubscribeResult", payload: { events: ["stateChange"] } } -]] - -local SubscribeAction = {} - -function SubscribeAction.register(router: any) - router:setResponseType("subscribe", "subscribeResult") - router:setResponseType("unsubscribe", "unsubscribeResult") - router:register("subscribe", function(payload: { [string]: any }, _requestId: string, _sessionId: string) - return { events = payload.events or {} } - end) - - router:register("unsubscribe", function(payload: { [string]: any }, _requestId: string, _sessionId: string) - return { events = payload.events or {} } - end) -end - -return SubscribeAction diff --git a/tools/studio-bridge/src/commands/index.ts b/tools/studio-bridge/src/commands/index.ts index f1b572621d..9692f016ac 100644 --- a/tools/studio-bridge/src/commands/index.ts +++ b/tools/studio-bridge/src/commands/index.ts @@ -21,4 +21,3 @@ export { mcpHandlerAsync, type McpResult } from './mcp/mcp.js'; export { type TerminalOptions, type TerminalResult } from './terminal/terminal.js'; export { processRunHandlerAsync, type ProcessRunOptions, type ProcessRunResult } from './process/run/run.js'; export { processCloseHandlerAsync, type ProcessCloseResult } from './process/close/close.js'; -export { invokeActionHandlerAsync, type ActionResult } from './action/action.js'; diff --git a/tools/studio-bridge/src/commands/linux/inject-credentials/inject-credentials.ts b/tools/studio-bridge/src/commands/linux/inject-credentials/inject-credentials.ts new file mode 100644 index 0000000000..a53f19675f --- /dev/null +++ b/tools/studio-bridge/src/commands/linux/inject-credentials/inject-credentials.ts @@ -0,0 +1,121 @@ +/** + * `linux inject-credentials` — inject .ROBLOSECURITY cookie into Wine's + * Credential Manager so Studio can authenticate. + */ + +import { defineCommand } from '../../framework/define-command.js'; +import { arg } from '../../framework/arg-builder.js'; +import { OutputHelper } from '@quenty/cli-output-helpers'; +import { getRobloxCookieAsync } from '@quenty/nevermore-cli-helpers'; +import { checkLinuxEnvironmentAsync } from '../../../linux/linux-env-guard.js'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface AuthArgs { + cookie?: string; +} + +interface AuthResult { + success: boolean; + summary: string; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function readStdinAsync(): Promise { + return new Promise((resolve, reject) => { + let data = ''; + process.stdin.setEncoding('utf-8'); + process.stdin.on('data', (chunk) => { + data += chunk; + }); + process.stdin.on('end', () => resolve(data)); + process.stdin.on('error', reject); + }); +} + +// --------------------------------------------------------------------------- +// Handler +// --------------------------------------------------------------------------- + +export async function injectCredentialsHandlerAsync(args: AuthArgs): Promise { + try { + const envError = await checkLinuxEnvironmentAsync(); + if (envError) { + OutputHelper.error(envError); + process.exit(1); + } + + const linux = await import('../../../linux/index.js'); + const config = linux.resolveLinuxConfig(); + + // Resolve cookie from explicit arg, stdin, or shared auth + let cookie: string; + if (args.cookie === '-') { + // Read from stdin + cookie = await readStdinAsync(); + if (!cookie.trim()) { + throw new Error('No cookie provided on stdin'); + } + cookie = cookie.trim(); + } else if (args.cookie) { + cookie = args.cookie; + } else { + cookie = await getRobloxCookieAsync(); + } + + // Validate cookie before attempting Wine injection + const { validateCookieAsync } = await import('@quenty/nevermore-cli-helpers'); + const validation = await validateCookieAsync(cookie); + if (!validation.valid) { + if (validation.reason === 'network_error') { + OutputHelper.warn('Could not validate ROBLOSECURITY cookie (network error). Continuing anyway.'); + } else { + throw new Error( + `ROBLOSECURITY cookie is invalid or expired (HTTP ${validation.status}). Update the cookie and try again.`, + ); + } + } + + // Ensure display is running (Wine needs it for credential write) + await linux.ensureDisplayAsync(config); + + // Inject credentials + await linux.injectCredentialsAsync({ cookie, config }); + + OutputHelper.info('Credentials injected.'); + OutputHelper.hint( + 'Next: run "studio-bridge process launch" to start Studio' + ); + + return { success: true, summary: 'Credentials injected into Wine Credential Manager' }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + OutputHelper.error(message); + return { success: false, summary: message }; + } +} + +// --------------------------------------------------------------------------- +// Command definition +// --------------------------------------------------------------------------- + +export const linuxInjectCredentialsCommand = defineCommand({ + group: 'linux', + name: 'inject-credentials', + description: 'Inject .ROBLOSECURITY cookie into Wine Credential Manager (within Docker image or Linux with Wine)', + category: 'infrastructure', + safety: 'none', + scope: 'standalone', + args: { + cookie: arg.option({ + description: + 'Cookie value (or "-" to read from stdin). Falls back to $ROBLOSECURITY env var or interactive prompt.', + }), + }, + handler: async (args) => injectCredentialsHandlerAsync(args), +}); diff --git a/tools/studio-bridge/src/commands/linux/setup/setup.ts b/tools/studio-bridge/src/commands/linux/setup/setup.ts new file mode 100644 index 0000000000..6dabe1a994 --- /dev/null +++ b/tools/studio-bridge/src/commands/linux/setup/setup.ts @@ -0,0 +1,141 @@ +/** + * `linux setup` — install Wine dependencies and Roblox Studio for headless + * Linux operation. + */ + +import { defineCommand } from '../../framework/define-command.js'; +import { arg } from '../../framework/arg-builder.js'; +import { OutputHelper } from '@quenty/cli-output-helpers'; +import { checkLinuxEnvironmentAsync } from '../../../linux/linux-env-guard.js'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface SetupArgs { + 'install-deps': boolean; + 'studio-version'?: string; + 'studio-dir'?: string; + 'skip-shaders': boolean; + force: boolean; +} + +interface SetupResult { + success: boolean; + summary: string; +} + +// --------------------------------------------------------------------------- +// Handler +// --------------------------------------------------------------------------- + +export async function setupHandlerAsync(args: SetupArgs): Promise { + try { + const envError = await checkLinuxEnvironmentAsync(); + if (envError) { + OutputHelper.error(envError); + process.exit(1); + } + + const linux = await import('../../../linux/index.js'); + const config = linux.resolveLinuxConfig(); + + // Override studioDir if provided + if (args['studio-dir']) { + config.studioDir = args['studio-dir']; + } + + // Step 1: Install system deps + if (args['install-deps']) { + OutputHelper.info('Installing system dependencies...'); + await linux.installDependenciesAsync(); + } + + // Step 2: Check prerequisites + OutputHelper.info('Checking prerequisites...'); + const results = linux.checkPrerequisites(); + const missing = results.filter((r) => !r.available); + if (missing.length > 0) { + for (const m of missing) { + OutputHelper.error(`Missing: ${m.name} — ${m.hint}`); + } + if (!args['install-deps']) { + OutputHelper.hint( + 'Run with --install-deps to install missing dependencies' + ); + } + return { + success: false, + summary: `Missing prerequisites: ${missing.map((m) => m.name).join(', ')}`, + }; + } + OutputHelper.info('All prerequisites satisfied.'); + + // Step 3: Resolve version + const version = await linux.resolveStudioVersionAsync(args['studio-version']); + OutputHelper.info(`Studio version: ${version}`); + + // Step 4: Check if already installed + const { readInstalledVersionAsync } = await import( + '../../../linux/linux-version-resolver.js' + ); + const installed = await readInstalledVersionAsync(config.studioDir); + if (installed === version && !args.force) { + OutputHelper.info( + `Studio ${version} already installed. Use --force to reinstall.` + ); + } else { + await linux.installStudioAsync(config, version); + } + + // Step 5: Patch shaders + if (!args['skip-shaders']) { + await linux.patchShadersAsync(config); + } + + // Step 6: Write FFlags + await linux.writeFflagsAsync(config); + + // Step 7: Compile write-cred.exe + const { compileWriteCredAsync } = await import( + '../../../linux/linux-credential-writer.js' + ); + await compileWriteCredAsync(config); + + // Step 8: Start display + await linux.ensureDisplayAsync(config); + await linux.ensureWindowManagerAsync(config); + + OutputHelper.info('Linux setup complete.'); + OutputHelper.hint( + 'Next: run "studio-bridge linux inject-credentials" to inject credentials' + ); + + return { success: true, summary: `Studio ${version} installed and configured` }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + OutputHelper.error(message); + return { success: false, summary: message }; + } +} + +// --------------------------------------------------------------------------- +// Command definition +// --------------------------------------------------------------------------- + +export const linuxSetupCommand = defineCommand({ + group: 'linux', + name: 'setup', + description: 'Install Wine + Roblox Studio for headless Linux operation (within Docker image or Linux with Wine)', + category: 'infrastructure', + safety: 'none', + scope: 'standalone', + args: { + 'install-deps': arg.flag({ description: 'Install system dependencies via apt-get (requires sudo)' }), + 'studio-version': arg.option({ description: 'Studio version hash (default: latest from CDN)' }), + 'studio-dir': arg.option({ description: 'Override Studio installation directory' }), + 'skip-shaders': arg.flag({ description: 'Skip shader patching' }), + force: arg.flag({ description: 'Force reinstall even if already installed' }), + }, + handler: async (args) => setupHandlerAsync(args), +}); diff --git a/tools/studio-bridge/src/commands/linux/status/status.ts b/tools/studio-bridge/src/commands/linux/status/status.ts new file mode 100644 index 0000000000..3298a264cf --- /dev/null +++ b/tools/studio-bridge/src/commands/linux/status/status.ts @@ -0,0 +1,192 @@ +/** + * `linux status` — check the health of the Linux/Wine environment for + * running Studio. + */ + +import * as fs from 'fs/promises'; +import { defineCommand } from '../../framework/define-command.js'; +import { OutputHelper } from '@quenty/cli-output-helpers'; +import { checkLinuxEnvironmentAsync } from '../../../linux/linux-env-guard.js'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +interface StatusArgs {} + +interface PrerequisiteStatus { + name: string; + available: boolean; + version?: string; + hint?: string; +} + +interface StatusResult { + healthy: boolean; + prerequisites: PrerequisiteStatus[]; + display: { xvfb: boolean; openbox: boolean }; + studio: { installed: boolean; version?: string; fflags: boolean; shaders: boolean }; + auth: { writeCredExe: boolean; credentialsInjected: boolean }; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +async function fileExistsAsync(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +// --------------------------------------------------------------------------- +// Handler +// --------------------------------------------------------------------------- + +export async function statusHandlerAsync(_args: StatusArgs): Promise { + try { + const envError = await checkLinuxEnvironmentAsync(); + if (envError) { + OutputHelper.error(envError); + process.exit(1); + } + + const linux = await import('../../../linux/index.js'); + const config = linux.resolveLinuxConfig(); + let allOk = true; + + // 1. Prerequisites + OutputHelper.info('System prerequisites:'); + const prereqs = linux.checkPrerequisites(); + for (const p of prereqs) { + const status = p.available + ? ` OK ${p.name}${p.version ? ` (${p.version})` : ''}` + : ` MISSING ${p.name} — ${p.hint}`; + if (p.available) { + OutputHelper.info(status); + } else { + OutputHelper.error(status); + allOk = false; + } + } + + // 2. Display + const displayNum = config.display.replace(':', ''); + const xvfbOk = linux.isXvfbRunning(displayNum); + const openboxOk = linux.isOpenboxRunning(); + OutputHelper.info(''); + OutputHelper.info('Display:'); + OutputHelper.info( + ` Xvfb (${config.display}): ${xvfbOk ? 'running' : 'not running'}` + ); + OutputHelper.info( + ` openbox: ${openboxOk ? 'running' : 'not running'}` + ); + if (!xvfbOk || !openboxOk) allOk = false; + + // 3. Studio installation + OutputHelper.info(''); + OutputHelper.info('Studio installation:'); + const studioExists = await fileExistsAsync(config.studioExe); + OutputHelper.info( + ` ${config.studioDir}: ${studioExists ? 'installed' : 'not found'}` + ); + if (!studioExists) allOk = false; + + const { readInstalledVersionAsync } = await import( + '../../../linux/linux-version-resolver.js' + ); + const version = await readInstalledVersionAsync(config.studioDir); + if (version) { + OutputHelper.info(` Version: ${version}`); + } + + // 4. FFlags + const fflagsExist = await fileExistsAsync(config.clientSettingsPath); + OutputHelper.info( + ` FFlags: ${fflagsExist ? 'configured' : 'not found'}` + ); + + // 5. Shaders + const shadersExist = await fileExistsAsync( + `${config.shadersDir}/shaders_glsl3.pack` + ); + OutputHelper.info( + ` Shaders: ${shadersExist ? 'present' : 'not found'}` + ); + + // 6. Credentials + OutputHelper.info(''); + OutputHelper.info('Authentication:'); + const writeCredExists = await fileExistsAsync(config.writeCredExe); + OutputHelper.info( + ` write-cred.exe: ${writeCredExists ? 'compiled' : 'not found'}` + ); + + // Check Wine registry for credential entries + let credentialsInjected = false; + const wineRegPath = `${config.winePrefix}/user.reg`; + const wineRegExists = await fileExistsAsync(wineRegPath); + if (wineRegExists) { + const regContent = await fs.readFile(wineRegPath, 'utf-8'); + credentialsInjected = regContent.includes('RobloxStudioAuth'); + OutputHelper.info( + ` Wine credentials: ${credentialsInjected ? 'present' : 'not injected'}` + ); + if (!credentialsInjected) allOk = false; + } else { + OutputHelper.info(' Wine prefix: not initialized'); + allOk = false; + } + + // Summary + OutputHelper.info(''); + if (allOk) { + OutputHelper.info('Environment is ready for Studio.'); + } else { + OutputHelper.warn('Environment has issues. See above for details.'); + } + + return { + healthy: allOk, + prerequisites: prereqs, + display: { xvfb: xvfbOk, openbox: openboxOk }, + studio: { + installed: studioExists, + version: version ?? undefined, + fflags: fflagsExist, + shaders: shadersExist, + }, + auth: { writeCredExe: writeCredExists, credentialsInjected }, + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + OutputHelper.error(message); + return { + healthy: false, + prerequisites: [], + display: { xvfb: false, openbox: false }, + studio: { installed: false, fflags: false, shaders: false }, + auth: { writeCredExe: false, credentialsInjected: false }, + }; + } +} + +// --------------------------------------------------------------------------- +// Command definition +// --------------------------------------------------------------------------- + +export const linuxStatusCommand = defineCommand({ + group: 'linux', + name: 'status', + description: 'Check Linux/Wine environment health for Studio (within Docker image or Linux with Wine)', + category: 'infrastructure', + safety: 'none', + scope: 'standalone', + args: {}, + handler: async (args) => statusHandlerAsync(args), +}); diff --git a/tools/studio-bridge/src/commands/process/run/run.ts b/tools/studio-bridge/src/commands/process/run/run.ts index 908fc45add..38a66d5140 100644 --- a/tools/studio-bridge/src/commands/process/run/run.ts +++ b/tools/studio-bridge/src/commands/process/run/run.ts @@ -21,6 +21,7 @@ export interface ProcessRunOptions { timeoutMs: number; verbose: boolean; showLogs: boolean; + filePath?: string; } export interface ProcessRunResult { @@ -107,6 +108,7 @@ export const processRunCommand = defineCommand timeoutMs: args.timeout ?? 120_000, verbose: false, showLogs: true, + filePath: args.file, }); }, // No MCP config -- process run is CLI-only diff --git a/tools/studio-bridge/src/docker/docker-delegator.test.ts b/tools/studio-bridge/src/docker/docker-delegator.test.ts new file mode 100644 index 0000000000..4fbc051a52 --- /dev/null +++ b/tools/studio-bridge/src/docker/docker-delegator.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Mock execa and fs before importing the module +vi.mock('execa', () => ({ + execa: vi.fn(), +})); + +vi.mock('fs/promises', () => ({ + writeFile: vi.fn().mockResolvedValue(undefined), + unlink: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('@quenty/cli-output-helpers', () => ({ + OutputHelper: { + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + verbose: vi.fn(), + }, +})); + +import { execa } from 'execa'; +import { shouldDelegateToDockerAsync, buildDockerRunArgsAsync } from './docker-delegator.js'; +import type { ExecuteScriptOptions } from '../cli/script-executor.js'; + +const mockedExeca = vi.mocked(execa); + +describe('shouldDelegateToDockerAsync', () => { + const originalPlatform = process.platform; + + afterEach(() => { + vi.restoreAllMocks(); + Object.defineProperty(process, 'platform', { value: originalPlatform }); + }); + + it('returns false on non-Linux platforms', async () => { + Object.defineProperty(process, 'platform', { value: 'darwin' }); + expect(await shouldDelegateToDockerAsync()).toBe(false); + }); + + it('returns false when Wine is available on Linux', async () => { + Object.defineProperty(process, 'platform', { value: 'linux' }); + mockedExeca.mockResolvedValueOnce({ exitCode: 0 } as any); + expect(await shouldDelegateToDockerAsync()).toBe(false); + }); + + it('returns true when Wine is missing but Docker is available', async () => { + Object.defineProperty(process, 'platform', { value: 'linux' }); + mockedExeca.mockRejectedValueOnce(new Error('wine not found')); + mockedExeca.mockResolvedValueOnce({ exitCode: 0 } as any); + expect(await shouldDelegateToDockerAsync()).toBe(true); + }); + + it('returns false when neither Wine nor Docker is available', async () => { + Object.defineProperty(process, 'platform', { value: 'linux' }); + mockedExeca.mockRejectedValueOnce(new Error('wine not found')); + mockedExeca.mockRejectedValueOnce(new Error('docker not found')); + expect(await shouldDelegateToDockerAsync()).toBe(false); + }); +}); + +describe('buildDockerRunArgsAsync', () => { + const cwd = '/workspace/project'; + const cookie = 'test-cookie'; + + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('builds correct args for inline script', async () => { + const options: ExecuteScriptOptions = { + scriptContent: 'print("hello")', + packageName: 'studio-bridge', + timeoutMs: 120_000, + verbose: false, + showLogs: true, + }; + + const args = await buildDockerRunArgsAsync(options, cwd, cookie); + + expect(args).toContain('--rm'); + expect(args).toContain('--init'); + expect(args).toContain(`ROBLOSECURITY=${cookie}`); + expect(args).toContain(`${cwd}:${cwd}`); + expect(args).toContain(cwd); + expect(args).toContain('ghcr.io/quenty/nevermore-studio-linux:latest'); + // Inner command should include auth then run + const bashCmd = args[args.length - 1]; + expect(bashCmd).toContain('studio-bridge linux inject-credentials'); + expect(bashCmd).toContain('studio-bridge process run'); + expect(bashCmd).toContain('--timeout 120000'); + expect(bashCmd).not.toContain('--verbose'); + }); + + it('passes --verbose through to inner command', async () => { + const options: ExecuteScriptOptions = { + scriptContent: 'print("hello")', + packageName: 'studio-bridge', + timeoutMs: 60_000, + verbose: true, + showLogs: true, + }; + + const args = await buildDockerRunArgsAsync(options, cwd, cookie); + const bashCmd = args[args.length - 1]; + expect(bashCmd).toContain('--verbose'); + }); + + it('passes --place through when specified', async () => { + const options: ExecuteScriptOptions = { + scriptContent: 'print("hello")', + packageName: 'studio-bridge', + placePath: '/workspace/project/test.rbxl', + timeoutMs: 120_000, + verbose: false, + showLogs: true, + }; + + const args = await buildDockerRunArgsAsync(options, cwd, cookie); + const bashCmd = args[args.length - 1]; + expect(bashCmd).toContain('--place /workspace/project/test.rbxl'); + }); + + it('uses original file path when filePath is set', async () => { + const options: ExecuteScriptOptions = { + scriptContent: 'print("hello")', + packageName: 'studio-bridge', + timeoutMs: 120_000, + verbose: false, + showLogs: true, + filePath: '/workspace/project/script.lua', + }; + + const args = await buildDockerRunArgsAsync(options, cwd, cookie); + const bashCmd = args[args.length - 1]; + expect(bashCmd).toContain('--file /workspace/project/script.lua'); + }); + + it('calculates docker timeout with 60s buffer', async () => { + const options: ExecuteScriptOptions = { + scriptContent: 'print("hello")', + packageName: 'studio-bridge', + timeoutMs: 120_000, + verbose: false, + showLogs: true, + }; + + const args = await buildDockerRunArgsAsync(options, cwd, cookie); + const stopIdx = args.indexOf('--stop-timeout'); + expect(stopIdx).toBeGreaterThan(-1); + expect(args[stopIdx + 1]).toBe('180'); // (120000 + 60000) / 1000 + }); +}); + diff --git a/tools/studio-bridge/src/docker/docker-delegator.ts b/tools/studio-bridge/src/docker/docker-delegator.ts new file mode 100644 index 0000000000..cdc470fccf --- /dev/null +++ b/tools/studio-bridge/src/docker/docker-delegator.ts @@ -0,0 +1,202 @@ +/** + * Transparent Docker delegation for `process run` on Linux environments + * without Wine. Detects when Docker is available and delegates the entire + * command to the pre-built container image. + */ + +import * as fs from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; +import { execa } from 'execa'; +import { OutputHelper } from '@quenty/cli-output-helpers'; +import { validateCookieAsync } from '@quenty/nevermore-cli-helpers'; +import type { ExecuteScriptOptions } from '../cli/script-executor.js'; + +const DOCKER_IMAGE_BASE = 'ghcr.io/quenty/nevermore-studio-linux'; +const CHECK_TIMEOUT_MS = 5_000; + +/** + * Resolves the Docker image to use. Defaults to :latest, but can be + * overridden with STUDIO_BRIDGE_DOCKER_TAG (e.g. "canary-feat-my-branch"). + */ +function resolveDockerImage(): string { + const tag = process.env.STUDIO_BRIDGE_DOCKER_TAG ?? 'latest'; + return `${DOCKER_IMAGE_BASE}:${tag}`; +} + +/** + * Returns true if the current environment should delegate to Docker + * (Linux without Wine, but with Docker available). + */ +export async function shouldDelegateToDockerAsync(): Promise { + if (os.platform() !== 'linux') { + return false; + } + + // Check if Wine is available locally + try { + await execa('wine', ['--version'], { timeout: CHECK_TIMEOUT_MS }); + return false; // Wine works, use local path + } catch { + // Wine not available, check Docker + } + + try { + await execa('docker', ['info'], { timeout: CHECK_TIMEOUT_MS, stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +/** + * Delegates script execution to the Docker container. Streams stdio + * transparently and propagates the exit code. Does not return — calls + * process.exit(). + */ +export async function delegateToDockerAsync( + options: ExecuteScriptOptions, +): Promise { + const cookie = process.env.ROBLOSECURITY; + if (!cookie) { + OutputHelper.error( + 'ROBLOSECURITY environment variable is required for Docker delegation.', + ); + process.exit(1); + } + + const validation = await validateCookieAsync(cookie); + if (!validation.valid) { + if (validation.reason === 'network_error') { + OutputHelper.warn('Could not validate ROBLOSECURITY cookie (network error). Continuing anyway.'); + } else { + OutputHelper.error( + `ROBLOSECURITY cookie is invalid or expired (HTTP ${validation.status}). Update the cookie and try again.`, + ); + process.exit(1); + } + } + + const image = resolveDockerImage(); + await ensureImageAsync(image); + + const cwd = process.cwd(); + const args = await buildDockerRunArgsAsync(options, cwd, cookie, image); + + // Log args without the cookie value + const safeArgs = args.map(a => + a.startsWith('ROBLOSECURITY=') ? 'ROBLOSECURITY=' : a, + ); + OutputHelper.verbose(`[StudioBridge] docker run args: ${safeArgs.join(' ')}`); + + const result = await execa('docker', args, { + stdio: 'inherit', + reject: false, + }); + + process.exit(result.exitCode ?? 1); +} + +const STALE_DAYS = 7; + +/** + * Ensures the Docker image is available locally, pulling if needed. + * Warns if the local image is older than STALE_DAYS. + */ +async function ensureImageAsync(image: string): Promise { + try { + const { stdout } = await execa('docker', [ + 'image', 'inspect', '--format', '{{.Created}}', image, + ]); + const created = new Date(stdout.trim()); + const ageDays = (Date.now() - created.getTime()) / (1000 * 60 * 60 * 24); + if (ageDays > STALE_DAYS) { + OutputHelper.warn( + `Docker image is ${Math.floor(ageDays)} days old. Run 'docker pull ${image}' to update.`, + ); + } + } catch { + OutputHelper.info(`Pulling ${image}...`); + await execa('docker', ['pull', image], { stdio: 'inherit' }); + } +} + +/** + * Builds the docker run argument array, writing inline script content + * to a temp file if needed. + */ +export async function buildDockerRunArgsAsync( + options: ExecuteScriptOptions, + cwd: string, + cookie: string, + image: string = `${DOCKER_IMAGE_BASE}:latest`, +): Promise { + const { scriptContent, placePath, timeoutMs, verbose } = options; + + // Write script to a temp file in CWD to avoid shell escaping issues + let tmpFile: string | undefined; + let scriptFilePath: string; + + if (options.filePath) { + scriptFilePath = path.resolve(options.filePath); + // Validate file is within CWD + if (!scriptFilePath.startsWith(cwd)) { + OutputHelper.error( + `Cannot delegate: file ${scriptFilePath} is outside working directory ${cwd}`, + ); + process.exit(1); + } + } else { + tmpFile = path.join(cwd, `.studio-bridge-tmp-${process.pid}.lua`); + await fs.writeFile(tmpFile, scriptContent, 'utf-8'); + scriptFilePath = tmpFile; + + // Register cleanup + const cleanup = async () => { + try { + await fs.unlink(tmpFile!); + } catch { + // Ignore + } + }; + process.on('exit', () => { void cleanup(); }); + process.on('SIGINT', () => { void cleanup(); }); + process.on('SIGTERM', () => { void cleanup(); }); + } + + const innerArgs = [ + 'studio-bridge', 'linux', 'inject-credentials', + '&&', + 'studio-bridge', 'process', 'run', + '--file', scriptFilePath, + '--timeout', String(timeoutMs), + ]; + + if (placePath) { + const resolvedPlace = path.resolve(placePath); + if (!resolvedPlace.startsWith(cwd)) { + OutputHelper.error( + `Cannot delegate: place file ${resolvedPlace} is outside working directory ${cwd}`, + ); + process.exit(1); + } + innerArgs.push('--place', resolvedPlace); + } + + if (verbose) { + innerArgs.push('--verbose'); + } + + // Docker-level timeout: script timeout + 60s buffer for auth/setup + const dockerTimeoutSec = Math.ceil((timeoutMs + 60_000) / 1000); + + return [ + 'run', '--rm', '--init', + '--stop-timeout', String(dockerTimeoutSec), + '-e', `ROBLOSECURITY=${cookie}`, + '-v', `${cwd}:${cwd}`, + '-w', cwd, + image, + 'bash', '-c', innerArgs.join(' '), + ]; +} diff --git a/tools/studio-bridge/src/index.ts b/tools/studio-bridge/src/index.ts index 350b213316..c9edc8daf4 100644 --- a/tools/studio-bridge/src/index.ts +++ b/tools/studio-bridge/src/index.ts @@ -52,7 +52,6 @@ export type { export type { Capability, StudioState, - SubscribableEvent, DataModelInstance, ErrorCode, SerializedValue, @@ -89,16 +88,12 @@ export type { LogsResultMessage, StateChangeMessage, HeartbeatMessage, - SubscribeResultMessage, - UnsubscribeResultMessage, PluginErrorMessage, // v2 server -> plugin messages QueryStateMessage, CaptureScreenshotMessage, QueryDataModelMessage, QueryLogsMessage, - SubscribeMessage, - UnsubscribeMessage, ServerErrorMessage, // v2 dynamic action registration RegisterActionMessage, diff --git a/tools/studio-bridge/src/linux/README.md b/tools/studio-bridge/src/linux/README.md new file mode 100644 index 0000000000..1813fe1628 --- /dev/null +++ b/tools/studio-bridge/src/linux/README.md @@ -0,0 +1,130 @@ +# Linux/Wine Support for studio-bridge + +Run Roblox Studio headlessly on Linux via Wine 11, Xvfb, and Mesa llvmpipe. This enables AI agents and CI pipelines to launch Studio in devcontainers and GitHub Actions without a physical display or GPU. + +## Prerequisites + +| Tool | Version | Purpose | +|------|---------|---------| +| Wine | 11.0+ | Windows compatibility layer | +| Xvfb | any | Virtual X11 framebuffer | +| openbox | any | Window manager (required for modal dialogs) | +| x86_64-w64-mingw32-gcc | any | Cross-compiler for write-cred.exe | +| unzip | any | Extract Studio packages | + +All can be installed via `studio-bridge linux setup --install-deps` on Debian/Ubuntu. + +## Quick Start + +```bash +# One-time setup: install deps, download Studio, patch shaders, write FFlags +studio-bridge linux setup --install-deps + +# Inject authentication (reads $ROBLOSECURITY env var) +studio-bridge linux inject-credentials + +# Verify everything is ready +studio-bridge linux status + +# Launch Studio and execute code through the bridge +studio-bridge exec 'print("Hello from Linux!")' +``` + +## Architecture + +``` +linux-config.ts Path resolution + constants (STUDIO_DIR, WINEPREFIX, DISPLAY) +linux-wine-env.ts Wine env vars (DISPLAY, Mesa overrides, WINEDEBUG) +linux-prerequisites.ts Check/install system deps +linux-display-manager.ts Start/stop Xvfb + openbox +linux-version-resolver.ts Fetch Studio version from CDN +linux-studio-installer.ts Download 34 zip packages, extract, write AppSettings.xml +linux-shader-patcher.ts Binary-patch #version 150 → #version 420 +linux-fflags.ts Write ClientAppSettings.json (D3D11 renderer flags) +linux-credential-writer.ts Compile write-cred.c, inject 3 credentials via Wine +write-cred.c Bundled C source for Windows Credential Manager writes +``` + +The process manager (`src/process/studio-process-manager.ts`) uses lazy `import()` for all Linux modules — zero overhead on Windows/macOS. + +## How It Works + +### Rendering: D3D11 via WineD3D + +Studio must use the D3D11 renderer, not OpenGL. Wine's OpenGL/EGL backend has a bug where `wglSwapBuffers` always targets the same EGL surface regardless of HWND, which breaks DockWidget rendering (Explorer, Properties, Toolbox all stay black). + +WineD3D translates D3D11 calls to OpenGL internally but manages per-window swapchains correctly. The FFlags force this path: + +```json +{ + "FFlagDebugGraphicsPreferD3D11": true, + "FFlagDebugGraphicsDisableVulkan": true, + "FFlagDebugGraphicsDisableD3D11FL10": true, + "FFlagDebugGraphicsDisableOpenGL": true +} +``` + +### Shader Patching + +Studio's GLSL shader pack declares `#version 150` but uses `unpackHalf2x16()`, which requires GLSL 4.20+. NVIDIA/AMD drivers are lenient about this; Mesa's llvmpipe is strict and rejects the shaders. + +The fix is a binary patch: replace all `#version 150` with `#version 420` in `shaders/shaders_glsl3.pack`. Both strings are exactly 12 bytes, so the patch is safe and in-place. + +### Authentication + +Studio expects three entries in Windows Credential Manager: + +1. `https://www.roblox.com:RobloxStudioAuthuserid` → numeric user ID +2. `https://www.roblox.com:RobloxStudioAuthCookies` → `.ROBLOSECURITY` (cookie name) +3. `https://www.roblox.com:RobloxStudioAuth.ROBLOSECURITY{userId}` → the cookie value + +The `linux inject-credentials` command: +1. Resolves the cookie via `getRobloxCookieAsync()` (env var → Wine cred store → interactive prompt) +2. Fetches the user ID from `users.roblox.com/v1/users/authenticated` +3. Compiles `write-cred.c` with MinGW (one-time) +4. Runs `wine write-cred.exe` three times to inject all entries + +On first launch, Studio exchanges the cookie for OAuth2 tokens. Subsequent launches use the refresh token, so the original cookie can expire. + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `STUDIO_DIR` | `~/roblox-studio` | Studio installation directory | +| `WINEPREFIX` | `~/.wine` | Wine prefix directory | +| `DISPLAY` | `:99` | X11 display number | +| `ROBLOSECURITY` | — | .ROBLOSECURITY cookie for auth | + +## CLI Commands + +### `studio-bridge linux setup` + +Install Wine dependencies and Roblox Studio. + +| Flag | Description | +|------|-------------| +| `--install-deps` | Install system deps via apt-get (requires sudo) | +| `--version ` | Studio version hash (default: latest from CDN) | +| `--studio-dir ` | Override installation directory | +| `--skip-shaders` | Skip shader patching | +| `--force` | Force reinstall even if same version exists | + +### `studio-bridge linux inject-credentials` + +Inject .ROBLOSECURITY cookie into Wine Credential Manager. + +| Flag | Description | +|------|-------------| +| `--cookie ` | Explicit cookie value | +| `--cookie -` | Read cookie from stdin | +| _(none)_ | Falls back to `$ROBLOSECURITY` or interactive prompt | + +### `studio-bridge linux status` + +Read-only health check. Reports on prerequisites, display, Studio installation, FFlags, shaders, and authentication. Exits with code 1 if any issues found. + +## Resource Requirements + +- **Disk**: ~813MB for Studio installation + ~450MB download cache +- **RAM**: ~450MB–800MB at runtime. 16GB recommended for the host; 8GB may OOM. +- **CPU**: No GPU required — Mesa llvmpipe does software rendering. diff --git a/tools/studio-bridge/src/linux/index.ts b/tools/studio-bridge/src/linux/index.ts new file mode 100644 index 0000000000..398b909508 --- /dev/null +++ b/tools/studio-bridge/src/linux/index.ts @@ -0,0 +1,11 @@ +export { resolveLinuxConfig, ROBLOX_CDN_BASE, ROBLOX_USERS_API, STUDIO_PACKAGES } from './linux-config.js'; +export type { LinuxStudioConfig } from './linux-config.js'; +export { buildWineEnv } from './linux-wine-env.js'; +export { checkPrerequisites, allPrerequisitesMet, installDependenciesAsync } from './linux-prerequisites.js'; +export { ensureDisplayAsync, ensureWindowManagerAsync, isXvfbRunning, isOpenboxRunning } from './linux-display-manager.js'; +export { resolveStudioVersionAsync } from './linux-version-resolver.js'; +export { installStudioAsync } from './linux-studio-installer.js'; +export { patchShadersAsync } from './linux-shader-patcher.js'; +export { writeFflagsAsync } from './linux-fflags.js'; +export { injectCredentialsAsync } from './linux-credential-writer.js'; +export { launchStudioLinuxAsync } from './linux-studio-launcher.js'; diff --git a/tools/studio-bridge/src/linux/linux-config.test.ts b/tools/studio-bridge/src/linux/linux-config.test.ts new file mode 100644 index 0000000000..9679f521c3 --- /dev/null +++ b/tools/studio-bridge/src/linux/linux-config.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { resolveLinuxConfig, STUDIO_PACKAGES } from './linux-config.js'; + +describe('resolveLinuxConfig', () => { + const originalEnv = { ...process.env }; + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it('returns HOME-based defaults when no env vars set', () => { + delete process.env.STUDIO_DIR; + delete process.env.WINEPREFIX; + delete process.env.DISPLAY; + process.env.HOME = '/home/testuser'; + + const config = resolveLinuxConfig(); + expect(config.studioDir).toContain('roblox-studio'); + expect(config.winePrefix).toContain('.wine'); + expect(config.display).toBe(':99'); + expect(config.studioExe).toMatch(/RobloxStudioBeta\.exe$/); + expect(config.clientSettingsPath).toContain('ClientAppSettings.json'); + expect(config.pluginsDir).toMatch(/Plugins$/); + expect(config.writeCredExe).toMatch(/write-cred\.exe$/); + }); + + it('respects STUDIO_DIR env var', () => { + process.env.STUDIO_DIR = '/opt/studio'; + + const config = resolveLinuxConfig(); + expect(config.studioDir).toBe('/opt/studio'); + expect(config.studioExe).toBe('/opt/studio/RobloxStudioBeta.exe'); + expect(config.pluginsDir).toBe('/opt/studio/Plugins'); + }); + + it('respects WINEPREFIX env var', () => { + process.env.WINEPREFIX = '/tmp/test-wine'; + + const config = resolveLinuxConfig(); + expect(config.winePrefix).toBe('/tmp/test-wine'); + }); + + it('respects DISPLAY env var', () => { + process.env.DISPLAY = ':42'; + + const config = resolveLinuxConfig(); + expect(config.display).toBe(':42'); + }); +}); + +describe('STUDIO_PACKAGES', () => { + it('has 34 package entries', () => { + expect(Object.keys(STUDIO_PACKAGES)).toHaveLength(34); + }); + + it('includes critical packages', () => { + expect(STUDIO_PACKAGES).toHaveProperty('RobloxStudio.zip'); + expect(STUDIO_PACKAGES).toHaveProperty('shaders.zip'); + expect(STUDIO_PACKAGES).toHaveProperty('Libraries.zip'); + expect(STUDIO_PACKAGES).toHaveProperty('content-fonts.zip'); + }); + + it('maps shaders.zip to shaders/ directory', () => { + expect(STUDIO_PACKAGES['shaders.zip']).toBe('shaders/'); + }); +}); diff --git a/tools/studio-bridge/src/linux/linux-config.ts b/tools/studio-bridge/src/linux/linux-config.ts new file mode 100644 index 0000000000..e885e1c1a2 --- /dev/null +++ b/tools/studio-bridge/src/linux/linux-config.ts @@ -0,0 +1,105 @@ +/** + * Path resolution and configuration constants for running Roblox Studio + * under Wine on Linux. + */ + +import * as path from 'path'; +import * as os from 'os'; + +export interface LinuxStudioConfig { + /** Root directory of the Studio installation */ + studioDir: string; + /** Wine prefix (usually ~/.wine) */ + winePrefix: string; + /** X11 display number (e.g. ":99") */ + display: string; + /** Path to the RobloxStudioBeta.exe within studioDir */ + studioExe: string; + /** Path to ClientSettings/ClientAppSettings.json */ + clientSettingsPath: string; + /** Path to the shaders directory */ + shadersDir: string; + /** Path to the Plugins folder */ + pluginsDir: string; + /** Path to write-cred.exe (compiled credential writer) */ + writeCredExe: string; +} + +/** + * Resolve all Linux/Wine paths from environment variables and defaults. + */ +export function resolveLinuxConfig(): LinuxStudioConfig { + const studioDir = + process.env.STUDIO_DIR || + path.join(os.homedir(), 'roblox-studio'); + + const winePrefix = + process.env.WINEPREFIX || + path.join(os.homedir(), '.wine'); + + const display = process.env.DISPLAY || ':99'; + + return { + studioDir, + winePrefix, + display, + studioExe: path.join(studioDir, 'RobloxStudioBeta.exe'), + clientSettingsPath: path.join( + studioDir, + 'ClientSettings', + 'ClientAppSettings.json' + ), + shadersDir: path.join(studioDir, 'shaders'), + pluginsDir: path.join(studioDir, 'Plugins'), + writeCredExe: path.join(studioDir, 'write-cred.exe'), + }; +} + +/** CDN base URL for Roblox Studio downloads */ +export const ROBLOX_CDN_BASE = 'https://setup.rbxcdn.com'; + +/** Roblox API base for user info */ +export const ROBLOX_USERS_API = 'https://users.roblox.com'; + +/** + * Package-to-directory mapping extracted from the Studio installer's + * .rdata section. Each key is a zip filename; value is the subdirectory + * within studioDir to extract into. + */ +export const STUDIO_PACKAGES: Record = { + 'ApplicationConfig.zip': 'ApplicationConfig/', + 'redist.zip': '', + 'RobloxStudio.zip': '', + 'Libraries.zip': '', + 'content-avatar.zip': 'content/avatar/', + 'content-configs.zip': 'content/configs/', + 'content-fonts.zip': 'content/fonts/', + 'content-sky.zip': 'content/sky/', + 'content-sounds.zip': 'content/sounds/', + 'content-textures2.zip': 'content/textures/', + 'content-studio_svg_textures.zip': 'content/studio_svg_textures/', + 'content-models.zip': 'content/models/', + 'content-textures3.zip': 'PlatformContent/pc/textures/', + 'content-terrain.zip': 'PlatformContent/pc/terrain/', + 'content-platform-fonts.zip': 'PlatformContent/pc/fonts/', + 'content-platform-dictionaries.zip': + 'PlatformContent/pc/shared_compression_dictionaries/', + 'content-qt_translations.zip': 'content/qt_translations/', + 'content-api-docs.zip': 'content/api_docs/', + 'extracontent-scripts.zip': 'ExtraContent/scripts/', + 'extracontent-luapackages.zip': 'ExtraContent/LuaPackages/', + 'extracontent-translations.zip': 'ExtraContent/translations/', + 'extracontent-models.zip': 'ExtraContent/models/', + 'extracontent-textures.zip': 'ExtraContent/textures/', + 'studiocontent-models.zip': 'StudioContent/models/', + 'studiocontent-textures.zip': 'StudioContent/textures/', + 'shaders.zip': 'shaders/', + 'BuiltInPlugins.zip': 'BuiltInPlugins/', + 'BuiltInStandalonePlugins.zip': 'BuiltInStandalonePlugins/', + 'LibrariesQt5.zip': '', + 'Plugins.zip': 'Plugins/', + 'StudioFonts.zip': 'StudioFonts/', + 'ssl.zip': 'ssl/', + 'WebView2.zip': '', + 'WebView2RuntimeInstaller.zip': 'WebView2RuntimeInstaller/', +}; diff --git a/tools/studio-bridge/src/linux/linux-credential-writer.ts b/tools/studio-bridge/src/linux/linux-credential-writer.ts new file mode 100644 index 0000000000..60090fc0ba --- /dev/null +++ b/tools/studio-bridge/src/linux/linux-credential-writer.ts @@ -0,0 +1,399 @@ +/** + * Inject Roblox authentication credentials into Wine's Credential Manager. + * + * Studio expects three entries in Windows Credential Manager: + * 1. userid: RobloxStudioAuthuserid → numeric user ID + * 2. cookie name: RobloxStudioAuthCookies → ".ROBLOSECURITY" + * 3. cookie value: RobloxStudioAuth.ROBLOSECURITY{userId} → the actual cookie + * + * Additionally, Studio 0.710+ uses OAuth2 for startup authentication. + * Without a valid refresh token, Studio blocks on a WebView2 login dialog + * that doesn't work under Wine. This module obtains a refresh token by + * calling Roblox's first-party OAuth authorization endpoint with the + * .ROBLOSECURITY cookie, then injects it into the Credential Manager. + * + * This module: + * - Compiles write-cred.c with MinGW (if not already compiled) + * - Resolves the user ID from the cookie via Roblox API + * - Obtains an OAuth2 refresh token via Roblox's authorization endpoint + * - Runs `wine write-cred.exe` to inject all credential entries + */ + +import * as crypto from 'crypto'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { execSync } from 'child_process'; +import { execa } from 'execa'; +import { fileURLToPath } from 'url'; +import { OutputHelper } from '@quenty/cli-output-helpers'; +import { ROBLOX_USERS_API, type LinuxStudioConfig } from './linux-config.js'; +import { buildWineEnv } from './linux-wine-env.js'; +import { COOKIE_NAME } from '@quenty/nevermore-cli-helpers'; + +/** Studio's first-party OAuth client ID (extracted from the Studio binary) */ +const STUDIO_OAUTH_CLIENT_ID = '7968549422692352298'; + +/** Roblox OAuth API base */ +const ROBLOX_OAUTH_API = 'https://apis.roblox.com/oauth/v1'; + +/** Roblox Auth API base (for CSRF tokens) */ +const ROBLOX_AUTH_API = 'https://auth.roblox.com'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +/** + * Locate write-cred.c source file. Searches several candidate paths + * relative to __dirname, which at runtime is dist/src/linux/. + */ +async function findWriteCredSourceAsync(): Promise { + const candidates = [ + // Alongside compiled JS (if .c was copied to dist) + path.join(__dirname, 'write-cred.c'), + // Source tree (from dist/src/linux/ → src/linux/) + path.resolve(__dirname, '../../../src/linux/write-cred.c'), + // Sibling to package root + path.resolve(__dirname, '../../linux/write-cred.c'), + ]; + + for (const candidate of candidates) { + try { + await fs.access(candidate); + return candidate; + } catch { + // Try next + } + } + + throw new Error( + 'write-cred.c source not found. Ensure the linux module is properly installed.' + ); +} + +/** + * Compile write-cred.exe from bundled C source using MinGW. + */ +export async function compileWriteCredAsync( + config: LinuxStudioConfig +): Promise { + const { writeCredExe } = config; + const sourcePath = await findWriteCredSourceAsync(); + + OutputHelper.verbose(`Compiling ${sourcePath} → ${writeCredExe}`); + + await fs.mkdir(path.dirname(writeCredExe), { recursive: true }); + + try { + execSync( + `x86_64-w64-mingw32-gcc -o ${JSON.stringify(writeCredExe)} ${JSON.stringify(sourcePath)} -lcredui -ladvapi32`, + { + stdio: ['pipe', 'pipe', 'pipe'], + timeout: 30000, + } + ); + } catch (error) { + throw new Error( + `Failed to compile write-cred.exe: ${error instanceof Error ? error.message : error}` + ); + } + + OutputHelper.verbose('write-cred.exe compiled successfully.'); +} + +interface InjectCredentialsOptions { + cookie: string; + config: LinuxStudioConfig; +} + +/** + * Inject all three required credentials into Wine's Credential Manager. + */ +export async function injectCredentialsAsync( + options: InjectCredentialsOptions +): Promise { + const { cookie, config } = options; + + // Always recompile write-cred.exe from source to ensure it matches the + // current code (e.g. batch-mode support). A stale binary from a Docker + // image may silently ignore extra arguments. + await compileWriteCredAsync(config); + + // Resolve user ID from cookie + const userId = await resolveUserIdAsync(cookie); + OutputHelper.verbose(`Resolved user ID: ${userId}`); + + const env = buildWineEnv(config); + const writeCredExe = config.writeCredExe; + + // Initialize or update the Wine prefix. wineboot must run before any wine + // command to create the prefix on first use, and must also run in containers + // where the prefix was pre-built at image time — the update pass refreshes + // stale registry entries and user profile paths for the current environment. + const prefixExists = await fs.access(path.join(config.winePrefix, 'system.reg')).then(() => true, () => false); + const winebootFlag = prefixExists ? '-u' : '-i'; + OutputHelper.verbose( + prefixExists + ? 'Updating Wine prefix for current environment...' + : 'Initializing Wine prefix...' + ); + const bootResult = await execa('wineboot', [winebootFlag], { + env, + reject: false, + timeout: 120000, + }); + if (bootResult.exitCode !== 0) { + OutputHelper.warn( + `wineboot exited with code ${bootResult.exitCode} (non-fatal)` + ); + } + + // Write all three credential entries in a single Wine invocation + const entries: Array<[string, string]> = [ + ['https://www.roblox.com:RobloxStudioAuthuserid', String(userId)], + ['https://www.roblox.com:RobloxStudioAuthCookies', COOKIE_NAME], + [ + `https://www.roblox.com:RobloxStudioAuth${COOKIE_NAME}${userId}`, + cookie, + ], + ]; + + OutputHelper.verbose(`Writing ${entries.length} credential entries...`); + const credArgs = entries.flatMap(([target, value]) => [target, value]); + const result = await execa('wine', [writeCredExe, ...credArgs], { + env, + reject: false, + timeout: 30000, + }); + + if (result.exitCode !== 0) { + const stderr = result.stderr || result.stdout || 'unknown error'; + throw new Error(`Failed to write credentials: ${stderr}`); + } + + // Also write to the Windows Registry path that Studio checks on startup. + await writeRegistryAuthAsync(cookie, userId, env); + + // Obtain and inject an OAuth2 refresh token. Studio 0.710+ may require this + // for startup authentication — without it, Studio blocks on a WebView2 + // login dialog that doesn't work under Wine. Non-fatal: cookie-based + // credentials above are sufficient for many Studio versions. + try { + await injectOAuth2RefreshTokenAsync(cookie, userId, writeCredExe, env); + } catch (error) { + OutputHelper.warn( + `OAuth2 refresh token injection failed (non-fatal): ${error instanceof Error ? error.message : error}` + ); + } + + OutputHelper.info('Credentials injected into Wine Credential Manager.'); +} + +/** + * Write auth data to Wine registry entries that Studio checks on startup. + * This bypasses Studio's WebView2 login dialog (which doesn't work on Wine). + */ +async function writeRegistryAuthAsync( + cookie: string, + userId: number, + env: Record +): Promise { + const regPath = 'HKCU\\Software\\Roblox\\RobloxStudioBrowser\\roblox.com'; + + const regEntries: Array<[string, string, string]> = [ + [regPath, COOKIE_NAME, cookie], + ]; + + for (const [keyPath, name, value] of regEntries) { + OutputHelper.verbose(`Writing registry: ${keyPath}\\${name}`); + const result = await execa( + 'wine', + ['reg', 'add', keyPath, '/v', name, '/t', 'REG_SZ', '/d', value, '/f'], + { env, reject: false, timeout: 15000 } + ); + if (result.exitCode !== 0) { + OutputHelper.warn( + `Failed to write registry entry ${name} (non-fatal): ${result.stderr || result.stdout}` + ); + } + } + + // Also set the user ID so Studio recognizes a configured user + const userRegPath = 'HKCU\\Software\\Roblox\\RobloxStudioBrowser'; + const userIdResult = await execa( + 'wine', + ['reg', 'add', userRegPath, '/v', 'UserId', '/t', 'REG_SZ', '/d', String(userId), '/f'], + { env, reject: false, timeout: 15000 } + ); + if (userIdResult.exitCode !== 0) { + OutputHelper.warn( + `Failed to write UserId registry entry (non-fatal): ${userIdResult.stderr || userIdResult.stdout}` + ); + } +} + +/** + * Obtain an OAuth2 refresh token from the .ROBLOSECURITY cookie and inject + * it into Wine's Credential Manager. This completes Studio's first-party + * OAuth PKCE flow programmatically, bypassing the WebView2 login dialog. + */ +async function injectOAuth2RefreshTokenAsync( + cookie: string, + userId: number, + writeCredExe: string, + env: Record +): Promise { + OutputHelper.verbose('Obtaining OAuth2 refresh token...'); + + // Step 1: Get CSRF token (Roblox requires this for mutating API calls) + const csrfToken = await getCsrfTokenAsync(cookie); + + // Step 2: Generate PKCE code verifier and challenge + const codeVerifier = crypto + .randomBytes(32) + .toString('base64url') + .slice(0, 43); + const codeChallenge = crypto + .createHash('sha256') + .update(codeVerifier) + .digest('base64url'); + const state = crypto.randomBytes(16).toString('hex'); + + // Step 3: Request authorization code + const authResponse = await fetch(`${ROBLOX_OAUTH_API}/authorizations`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': csrfToken, + Cookie: `${COOKIE_NAME}=${cookie}`, + }, + body: JSON.stringify({ + clientId: STUDIO_OAUTH_CLIENT_ID, + responseTypes: ['Code'], + redirectUri: 'roblox-studio-auth:/', + scopes: [ + { scopeType: 'openid', operations: ['read'] }, + { scopeType: 'credentials', operations: ['read'] }, + { scopeType: 'profile', operations: ['read'] }, + { scopeType: 'age', operations: ['read'] }, + { scopeType: 'roles', operations: ['read'] }, + { scopeType: 'premium', operations: ['read'] }, + ], + nonce: 'id-roblox', + codeChallengeMethod: 's256', + codeChallenge, + state, + }), + }); + + if (!authResponse.ok) { + const body = await authResponse.text(); + throw new Error( + `OAuth authorization failed: ${authResponse.status} ${body}` + ); + } + + const authData = (await authResponse.json()) as { location: string }; + const locationUrl = new URL(authData.location); + const authCode = locationUrl.searchParams.get('code'); + if (!authCode) { + throw new Error('OAuth authorization response missing code'); + } + + OutputHelper.verbose('OAuth authorization code obtained.'); + + // Step 4: Exchange authorization code for tokens + const tokenResponse = await fetch(`${ROBLOX_OAUTH_API}/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code: authCode, + code_verifier: codeVerifier, + client_id: STUDIO_OAUTH_CLIENT_ID, + }).toString(), + }); + + if (!tokenResponse.ok) { + const body = await tokenResponse.text(); + throw new Error(`OAuth token exchange failed: ${tokenResponse.status} ${body}`); + } + + const tokenData = (await tokenResponse.json()) as { + refresh_token: string; + access_token: string; + }; + + if (!tokenData.refresh_token) { + throw new Error('OAuth token response missing refresh_token'); + } + + OutputHelper.verbose( + `OAuth refresh token obtained (${tokenData.refresh_token.length} chars).` + ); + + // Step 5: Inject the refresh token into Wine's Credential Manager + const target = `https://www.roblox.com:RobloxStudioAuthoauth2RefreshToken${userId}`; + OutputHelper.verbose(`Writing OAuth2 credential: ${target}`); + const result = await execa( + 'wine', + [writeCredExe, target, tokenData.refresh_token], + { env, reject: false, timeout: 15000 } + ); + + if (result.exitCode !== 0) { + const stderr = result.stderr || result.stdout || 'unknown error'; + throw new Error(`Failed to write OAuth2 refresh token credential: ${stderr}`); + } + + OutputHelper.verbose('OAuth2 refresh token injected into Wine Credential Manager.'); +} + +/** + * Get a CSRF token from Roblox's auth API. The first request returns 403 + * with the token in the x-csrf-token header. + */ +async function getCsrfTokenAsync(cookie: string): Promise { + const response = await fetch( + `${ROBLOX_AUTH_API}/v1/authentication-ticket/`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Cookie: `${COOKIE_NAME}=${cookie}`, + Referer: 'https://www.roblox.com/', + }, + body: '{}', + } + ); + + const csrfToken = response.headers.get('x-csrf-token'); + if (!csrfToken) { + throw new Error( + `Failed to obtain CSRF token: ${response.status} ${response.statusText}` + ); + } + + return csrfToken; +} + +/** + * Resolve the authenticated user's numeric ID from a .ROBLOSECURITY cookie. + */ +async function resolveUserIdAsync(cookie: string): Promise { + const response = await fetch( + `${ROBLOX_USERS_API}/v1/users/authenticated`, + { + headers: { + Cookie: `${COOKIE_NAME}=${cookie}`, + }, + } + ); + + if (!response.ok) { + throw new Error( + `Failed to resolve user ID: ${response.status} ${response.statusText}. Is your cookie valid?` + ); + } + + const data = (await response.json()) as { id: number }; + return data.id; +} diff --git a/tools/studio-bridge/src/linux/linux-display-manager.ts b/tools/studio-bridge/src/linux/linux-display-manager.ts new file mode 100644 index 0000000000..de79dbc40c --- /dev/null +++ b/tools/studio-bridge/src/linux/linux-display-manager.ts @@ -0,0 +1,110 @@ +/** + * Manage the Xvfb virtual framebuffer and openbox window manager + * required for Studio to render under Wine. + */ + +import { execSync } from 'child_process'; +import { execa } from 'execa'; +import type { LinuxStudioConfig } from './linux-config.js'; + +/** + * Ensure Xvfb is running on the configured DISPLAY. + * If already running, this is a no-op. + */ +export async function ensureDisplayAsync( + config: LinuxStudioConfig +): Promise { + const display = config.display; + const displayNum = display.replace(':', ''); + + if (isXvfbRunning(displayNum)) { + return; + } + + // Start Xvfb detached with 1024x768 24-bit color + const xvfb = execa( + 'Xvfb', + [display, '-screen', '0', '1024x768x24'], + { + detached: true, + stdio: 'ignore', + reject: false, + env: { ...process.env }, + } + ); + xvfb.unref?.(); + + // Give it a moment to start + await sleep(500); + + if (!isXvfbRunning(displayNum)) { + throw new Error(`Failed to start Xvfb on display ${display}`); + } +} + +/** + * Ensure openbox window manager is running on the display. + * Required for Studio's modal dialogs to function. + */ +export async function ensureWindowManagerAsync( + config: LinuxStudioConfig +): Promise { + if (isOpenboxRunning()) { + return; + } + + const openbox = execa('openbox', [], { + detached: true, + stdio: 'ignore', + reject: false, + env: { + ...process.env, + DISPLAY: config.display, + }, + }); + openbox.unref?.(); + + await sleep(500); +} + +/** + * Check if Xvfb is running on a given display. + */ +export function isXvfbRunning(displayNum?: string): boolean { + try { + if (displayNum) { + const output = execSync(`pgrep -a Xvfb`, { + encoding: 'utf-8', + timeout: 3000, + stdio: ['pipe', 'pipe', 'pipe'], + }); + return output.includes(`:${displayNum}`); + } + execSync('pgrep -x Xvfb', { + timeout: 3000, + stdio: ['pipe', 'pipe', 'pipe'], + }); + return true; + } catch { + return false; + } +} + +/** + * Check if openbox is running. + */ +export function isOpenboxRunning(): boolean { + try { + execSync('pgrep -x openbox', { + timeout: 3000, + stdio: ['pipe', 'pipe', 'pipe'], + }); + return true; + } catch { + return false; + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/tools/studio-bridge/src/linux/linux-env-guard.test.ts b/tools/studio-bridge/src/linux/linux-env-guard.test.ts new file mode 100644 index 0000000000..f0b34ae19d --- /dev/null +++ b/tools/studio-bridge/src/linux/linux-env-guard.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('os', () => ({ + platform: vi.fn(), +})); + +vi.mock('child_process', () => ({ + execFile: vi.fn(), +})); + +vi.mock('util', () => ({ + promisify: (fn: any) => fn, +})); + +import { platform } from 'os'; +import { execFile } from 'child_process'; +import { checkLinuxEnvironmentAsync } from './linux-env-guard.js'; + +const mockPlatform = vi.mocked(platform); +const mockExecFile = vi.mocked(execFile); + +describe('checkLinuxEnvironmentAsync', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('returns an error message on non-Linux platforms', async () => { + mockPlatform.mockReturnValue('win32'); + + const result = await checkLinuxEnvironmentAsync(); + + expect(result).toBeDefined(); + expect(result).toContain('linux commands require a Linux environment'); + expect(result).toContain('studio-bridge process run'); + expect(mockExecFile).not.toHaveBeenCalled(); + }); + + it('returns undefined when on Linux with Wine installed', async () => { + mockPlatform.mockReturnValue('linux'); + mockExecFile.mockResolvedValue({ stdout: '/usr/bin/wine\n', stderr: '' } as any); + + const result = await checkLinuxEnvironmentAsync(); + + expect(result).toBeUndefined(); + expect(mockExecFile).toHaveBeenCalledWith('which', ['wine']); + }); + + it('returns an error message on Linux without Wine', async () => { + mockPlatform.mockReturnValue('linux'); + mockExecFile.mockRejectedValue(new Error('not found')); + + const result = await checkLinuxEnvironmentAsync(); + + expect(result).toBeDefined(); + expect(result).toContain('Wine is not installed'); + expect(result).toContain('studio-bridge process run'); + expect(result).toContain('docker run'); + }); +}); diff --git a/tools/studio-bridge/src/linux/linux-env-guard.ts b/tools/studio-bridge/src/linux/linux-env-guard.ts new file mode 100644 index 0000000000..fe17cf6561 --- /dev/null +++ b/tools/studio-bridge/src/linux/linux-env-guard.ts @@ -0,0 +1,38 @@ +/** + * Environment guard for `linux *` subcommands. + * + * These commands require Wine and related tools that are only available inside + * the Docker image or a properly configured Linux box. This guard lets them + * fail early with a helpful message instead of crashing mid-way. + */ + +import * as os from 'os'; +import { execFile } from 'child_process'; +import { promisify } from 'util'; + +const execFileAsync = promisify(execFile); + +/** + * Checks if the current environment can run linux/* commands. + * Returns an error message if not, or undefined if OK. + */ +export async function checkLinuxEnvironmentAsync(): Promise { + if (os.platform() !== 'linux') { + return ( + "linux commands require a Linux environment. On Windows/macOS, Studio runs natively — use 'studio-bridge process run' instead." + ); + } + + try { + await execFileAsync('which', ['wine']); + } catch { + return ( + 'Wine is not installed. These commands require Wine and related tools.\n\n' + + "To run scripts, use 'studio-bridge process run' which auto-delegates to Docker.\n" + + 'To set up a full Wine environment, run inside the Docker image:\n' + + ' docker run --rm -it ghcr.io/quenty/nevermore-studio-linux:latest bash' + ); + } + + return undefined; +} diff --git a/tools/studio-bridge/src/linux/linux-fflags.test.ts b/tools/studio-bridge/src/linux/linux-fflags.test.ts new file mode 100644 index 0000000000..890edc70e6 --- /dev/null +++ b/tools/studio-bridge/src/linux/linux-fflags.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import * as fs from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; +import { writeFflagsAsync } from './linux-fflags.js'; +import type { LinuxStudioConfig } from './linux-config.js'; + +describe('writeFflagsAsync', () => { + let tmpDir: string; + + afterEach(async () => { + if (tmpDir) { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + + function makeConfig(): LinuxStudioConfig { + const clientSettingsPath = path.join( + tmpDir, + 'ClientSettings', + 'ClientAppSettings.json' + ); + return { + studioDir: tmpDir, + winePrefix: '/tmp/fake-wine', + display: ':99', + studioExe: path.join(tmpDir, 'RobloxStudioBeta.exe'), + clientSettingsPath, + shadersDir: path.join(tmpDir, 'shaders'), + pluginsDir: path.join(tmpDir, 'Plugins'), + writeCredExe: path.join(tmpDir, 'write-cred.exe'), + }; + } + + it('writes valid JSON with all 5 required FFlags', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'fflags-test-')); + const config = makeConfig(); + + await writeFflagsAsync(config); + + const content = JSON.parse( + await fs.readFile(config.clientSettingsPath, 'utf-8') + ); + expect(content.FFlagDebugGraphicsPreferD3D11).toBe(true); + expect(content.FFlagDebugGraphicsDisableVulkan).toBe(true); + expect(content.FFlagDebugGraphicsDisableD3D11FL10).toBe(true); + expect(content.FFlagDebugGraphicsDisableOpenGL).toBe(true); + expect(content.FIntStudioLowMemoryThresholdPercentage).toBe(0); + }); + + it('creates parent directories', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'fflags-test-')); + const config = makeConfig(); + + // Directory shouldn't exist yet + await expect( + fs.access(path.dirname(config.clientSettingsPath)) + ).rejects.toThrow(); + + await writeFflagsAsync(config); + + // Now it should + await fs.access(path.dirname(config.clientSettingsPath)); + }); + + it('merges extra flags without dropping defaults', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'fflags-test-')); + const config = makeConfig(); + + await writeFflagsAsync(config, { FIntCustomFlag: 42 }); + + const content = JSON.parse( + await fs.readFile(config.clientSettingsPath, 'utf-8') + ); + expect(content.FIntCustomFlag).toBe(42); + expect(content.FFlagDebugGraphicsPreferD3D11).toBe(true); + }); +}); diff --git a/tools/studio-bridge/src/linux/linux-fflags.ts b/tools/studio-bridge/src/linux/linux-fflags.ts new file mode 100644 index 0000000000..215a92ab9c --- /dev/null +++ b/tools/studio-bridge/src/linux/linux-fflags.ts @@ -0,0 +1,48 @@ +/** + * Write the FFlags required for Studio to render correctly under Wine/Mesa. + * + * Key flags: + * - FFlagDebugGraphicsPreferD3D11: Use D3D11 via WineD3D (avoids Wine's + * EGL swapchain bug that breaks DockWidgets in OpenGL mode) + * - FFlagDebugGraphicsDisableVulkan/OpenGL/D3D11FL10: Prevent fallback + * to renderers that don't work under Wine + * - FIntStudioLowMemoryThresholdPercentage: Disable OOM warning dialog + */ + +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { OutputHelper } from '@quenty/cli-output-helpers'; +import type { LinuxStudioConfig } from './linux-config.js'; + +const DEFAULT_FFLAGS: Record = { + FFlagDebugGraphicsPreferD3D11: true, + FFlagDebugGraphicsDisableVulkan: true, + FFlagDebugGraphicsDisableD3D11FL10: true, + FFlagDebugGraphicsDisableOpenGL: true, + FIntStudioLowMemoryThresholdPercentage: 0, +}; + +/** + * Write ClientAppSettings.json with the FFlags needed for Wine rendering. + * Merges with any existing flags to avoid clobbering user customizations. + */ +export async function writeFflagsAsync( + config: LinuxStudioConfig, + extraFlags?: Record +): Promise { + const settingsDir = path.dirname(config.clientSettingsPath); + await fs.mkdir(settingsDir, { recursive: true }); + + // Merge defaults + extras + const flags = { ...DEFAULT_FFLAGS, ...extraFlags }; + + await fs.writeFile( + config.clientSettingsPath, + JSON.stringify(flags, null, 2) + '\n', + 'utf-8' + ); + + OutputHelper.verbose( + `Wrote ${Object.keys(flags).length} FFlags to ${config.clientSettingsPath}` + ); +} diff --git a/tools/studio-bridge/src/linux/linux-prerequisites.ts b/tools/studio-bridge/src/linux/linux-prerequisites.ts new file mode 100644 index 0000000000..351686581a --- /dev/null +++ b/tools/studio-bridge/src/linux/linux-prerequisites.ts @@ -0,0 +1,142 @@ +/** + * Check system prerequisites for running Studio under Wine on Linux. + */ + +import { execSync } from 'child_process'; +import { OutputHelper } from '@quenty/cli-output-helpers'; + +export interface PrerequisiteResult { + name: string; + available: boolean; + version?: string; + hint?: string; +} + +const PREREQUISITES: Array<{ + name: string; + command: string; + hint: string; +}> = [ + { + name: 'wine', + command: 'wine --version', + hint: 'Install Wine 11+: https://wiki.winehq.org/Download', + }, + { + name: 'Xvfb', + command: 'Xvfb -help 2>&1 | head -1', + hint: 'apt-get install xvfb', + }, + { + name: 'openbox', + command: 'openbox --version', + hint: 'apt-get install openbox', + }, + { + name: 'x86_64-w64-mingw32-gcc', + command: 'x86_64-w64-mingw32-gcc --version', + hint: 'apt-get install gcc-mingw-w64-x86-64', + }, + { + name: 'unzip', + command: 'unzip -v 2>&1 | head -1', + hint: 'apt-get install unzip', + }, +]; + +/** + * Check all required tools are present. Returns an array of results + * including version strings where parseable. + */ +export function checkPrerequisites(): PrerequisiteResult[] { + return PREREQUISITES.map(({ name, command, hint }) => { + try { + const output = execSync(command, { + encoding: 'utf-8', + timeout: 5000, + stdio: ['pipe', 'pipe', 'pipe'], + }).trim(); + + const version = extractVersion(output); + return { name, available: true, version }; + } catch { + return { name, available: false, hint }; + } + }); +} + +/** + * Returns true if all prerequisites are satisfied. + */ +export function allPrerequisitesMet(): boolean { + return checkPrerequisites().every((r) => r.available); +} + +function extractVersion(output: string): string | undefined { + const match = output.match(/(\d+\.\d+[\w.-]*)/); + return match?.[1]; +} + +/** + * Install missing system dependencies. Requires sudo. + * Only runs on Debian/Ubuntu (apt-get). + */ +export async function installDependenciesAsync(): Promise { + const { execa } = await import('execa'); + + await execa('sudo', ['dpkg', '--add-architecture', 'i386'], { + stdio: 'inherit', + }); + + // Try WineHQ repo first for latest builds, fall back to distro packages + let useWineHQ = false; + try { + await execa('sudo', ['mkdir', '-pm755', '/etc/apt/keyrings'], { + stdio: 'inherit', + }); + await execa( + 'sudo', + [ + 'curl', '-sL', + 'https://dl.winehq.org/wine-builds/winehq.key', + '-o', '/etc/apt/keyrings/winehq-archive.key', + ], + { stdio: 'inherit' } + ); + + let codename = 'noble'; + try { + codename = execSync('lsb_release -cs', { encoding: 'utf-8' }).trim(); + } catch { + // Fall back to noble (Ubuntu 24.04) + } + + await execa( + 'sudo', + [ + 'curl', '-sfL', + `https://dl.winehq.org/wine-builds/ubuntu/dists/${codename}/winehq-${codename}.sources`, + '-o', `/etc/apt/sources.list.d/winehq-${codename}.sources`, + ], + { stdio: 'inherit' } + ); + useWineHQ = true; + } catch { + OutputHelper.warn( + 'WineHQ repo not available for this distro, using system packages' + ); + } + + await execa('sudo', ['apt-get', 'update'], { stdio: 'inherit' }); + + const winePackage = useWineHQ ? 'winehq-stable' : 'wine'; + await execa( + 'sudo', + [ + 'apt-get', 'install', '-y', + winePackage, 'xvfb', 'mesa-utils', 'openbox', + 'gcc-mingw-w64-x86-64', 'unzip', + ], + { stdio: 'inherit' } + ); +} diff --git a/tools/studio-bridge/src/linux/linux-shader-patcher.test.ts b/tools/studio-bridge/src/linux/linux-shader-patcher.test.ts new file mode 100644 index 0000000000..7fe774fae3 --- /dev/null +++ b/tools/studio-bridge/src/linux/linux-shader-patcher.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import * as fs from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; +import { patchShadersAsync } from './linux-shader-patcher.js'; +import type { LinuxStudioConfig } from './linux-config.js'; + +describe('patchShadersAsync', () => { + let tmpDir: string; + + afterEach(async () => { + if (tmpDir) { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + + function makeConfig(): LinuxStudioConfig { + return { + studioDir: tmpDir, + winePrefix: '/tmp/fake-wine', + display: ':99', + studioExe: path.join(tmpDir, 'RobloxStudioBeta.exe'), + clientSettingsPath: path.join(tmpDir, 'ClientSettings', 'ClientAppSettings.json'), + shadersDir: path.join(tmpDir, 'shaders'), + pluginsDir: path.join(tmpDir, 'Plugins'), + writeCredExe: path.join(tmpDir, 'write-cred.exe'), + }; + } + + async function writeFakeShaderPack(content: Buffer): Promise { + const dir = path.join(tmpDir, 'shaders'); + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile(path.join(dir, 'shaders_glsl3.pack'), content); + } + + it('replaces #version 150 with #version 420', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'shader-test-')); + const config = makeConfig(); + + // Create a fake shader pack with two occurrences + const data = Buffer.from( + 'header#version 150middle#version 150tail' + ); + await writeFakeShaderPack(data); + + const count = await patchShadersAsync(config); + expect(count).toBe(2); + + const patched = await fs.readFile( + path.join(config.shadersDir, 'shaders_glsl3.pack') + ); + expect(patched.toString()).toBe( + 'header#version 420middle#version 420tail' + ); + }); + + it('returns 0 on second run (idempotent)', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'shader-test-')); + const config = makeConfig(); + + await writeFakeShaderPack(Buffer.from('data#version 150end')); + + await patchShadersAsync(config); + const count = await patchShadersAsync(config); + expect(count).toBe(0); + }); + + it('throws if shader pack not found', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'shader-test-')); + const config = makeConfig(); + + await expect(patchShadersAsync(config)).rejects.toThrow( + 'Shader pack not found' + ); + }); +}); diff --git a/tools/studio-bridge/src/linux/linux-shader-patcher.ts b/tools/studio-bridge/src/linux/linux-shader-patcher.ts new file mode 100644 index 0000000000..f09a8b4c95 --- /dev/null +++ b/tools/studio-bridge/src/linux/linux-shader-patcher.ts @@ -0,0 +1,56 @@ +/** + * Binary-patch GLSL shaders from #version 150 to #version 420. + * + * Mesa's llvmpipe strictly enforces the GLSL spec — shaders using + * unpackHalf2x16() (GLSL 4.20+) but declaring #version 150 are rejected. + * Both version strings are exactly 12 bytes, so the patch is a safe + * in-place replacement. + */ + +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { OutputHelper } from '@quenty/cli-output-helpers'; +import type { LinuxStudioConfig } from './linux-config.js'; + +const SHADER_PACK_NAME = 'shaders_glsl3.pack'; +const OLD_VERSION = Buffer.from('#version 150'); +const NEW_VERSION = Buffer.from('#version 420'); + +/** + * Patch the GLSL3 shader pack in-place, replacing all occurrences of + * `#version 150` with `#version 420`. + * + * Returns the number of replacements made. + */ +export async function patchShadersAsync( + config: LinuxStudioConfig +): Promise { + const shaderPath = path.join(config.shadersDir, SHADER_PACK_NAME); + + let data: Buffer; + try { + data = await fs.readFile(shaderPath); + } catch { + throw new Error(`Shader pack not found: ${shaderPath}`); + } + + let count = 0; + let offset = 0; + + while (true) { + const idx = data.indexOf(OLD_VERSION, offset); + if (idx === -1) break; + NEW_VERSION.copy(data, idx); + count++; + offset = idx + NEW_VERSION.length; + } + + if (count === 0) { + OutputHelper.verbose('Shaders already patched (no #version 150 found).'); + return 0; + } + + await fs.writeFile(shaderPath, data); + OutputHelper.info(`Patched ${count} shaders (#version 150 → #version 420).`); + return count; +} diff --git a/tools/studio-bridge/src/linux/linux-studio-installer.ts b/tools/studio-bridge/src/linux/linux-studio-installer.ts new file mode 100644 index 0000000000..8bd1a038ed --- /dev/null +++ b/tools/studio-bridge/src/linux/linux-studio-installer.ts @@ -0,0 +1,108 @@ +/** + * Download and install Roblox Studio from CDN zip packages. + */ + +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { execSync } from 'child_process'; +import { OutputHelper } from '@quenty/cli-output-helpers'; +import { + ROBLOX_CDN_BASE, + STUDIO_PACKAGES, + type LinuxStudioConfig, +} from './linux-config.js'; +import { + writeInstalledVersionAsync, +} from './linux-version-resolver.js'; + +const DOWNLOAD_DIR = '/tmp/roblox-pkgs'; + +/** + * Download all Studio packages from CDN and extract them to studioDir. + * Writes AppSettings.xml on completion. + */ +export async function installStudioAsync( + config: LinuxStudioConfig, + version: string +): Promise { + const { studioDir } = config; + + // Clean existing install + await fs.rm(studioDir, { recursive: true, force: true }); + await fs.mkdir(studioDir, { recursive: true }); + + // Ensure download cache exists + await fs.mkdir(DOWNLOAD_DIR, { recursive: true }); + + const packageNames = Object.keys(STUDIO_PACKAGES); + OutputHelper.info(`Downloading ${packageNames.length} packages...`); + + // Download packages (sequential to avoid rate limiting) + for (const pkg of packageNames) { + await downloadPackageAsync(version, pkg); + } + + OutputHelper.info('Extracting packages...'); + + // Extract each package to its target directory + for (const [pkg, subdir] of Object.entries(STUDIO_PACKAGES)) { + const target = path.join(studioDir, subdir); + await fs.mkdir(target, { recursive: true }); + + const zipPath = path.join(DOWNLOAD_DIR, pkg); + try { + execSync(`unzip -qo ${JSON.stringify(zipPath)} -d ${JSON.stringify(target)}`, { + stdio: ['pipe', 'pipe', 'pipe'], + timeout: 60000, + }); + } catch { + OutputHelper.warn(`Failed to extract ${pkg} (non-fatal)`); + } + } + + // Write AppSettings.xml + const appSettings = [ + '', + '', + ' content', + ' http://www.roblox.com', + '', + ].join('\n'); + await fs.writeFile(path.join(studioDir, 'AppSettings.xml'), appSettings, 'utf-8'); + + // Record installed version + await writeInstalledVersionAsync(studioDir, version); + + OutputHelper.info('Studio installation complete.'); +} + +async function downloadPackageAsync( + version: string, + pkg: string +): Promise { + const dest = path.join(DOWNLOAD_DIR, pkg); + + // Skip if already downloaded + try { + const stat = await fs.stat(dest); + if (stat.size > 0) { + OutputHelper.verbose(`Cached: ${pkg}`); + return; + } + } catch { + // File doesn't exist, download it + } + + const url = `${ROBLOX_CDN_BASE}/${version}-${pkg}`; + OutputHelper.verbose(`Downloading ${pkg}...`); + + const response = await fetch(url); + if (!response.ok) { + throw new Error( + `Failed to download ${pkg}: ${response.status} ${response.statusText}` + ); + } + + const buffer = Buffer.from(await response.arrayBuffer()); + await fs.writeFile(dest, buffer); +} diff --git a/tools/studio-bridge/src/linux/linux-studio-launcher.ts b/tools/studio-bridge/src/linux/linux-studio-launcher.ts new file mode 100644 index 0000000000..c7200ed34e --- /dev/null +++ b/tools/studio-bridge/src/linux/linux-studio-launcher.ts @@ -0,0 +1,85 @@ +/** + * Linux-specific Studio launch via Wine. Called by the process manager + * when `process.platform === 'linux'`. + */ + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { spawn } from 'child_process'; +import { OutputHelper } from '@quenty/cli-output-helpers'; +import { resolveLinuxConfig } from './linux-config.js'; +import { buildWineEnv } from './linux-wine-env.js'; +import { ensureDisplayAsync, ensureWindowManagerAsync } from './linux-display-manager.js'; +import type { StudioProcess } from '../process/studio-process-manager.js'; + +/** + * Launch Studio under Wine with proper display and environment setup. + */ +export async function launchStudioLinuxAsync( + studioExe: string, + placePath: string, +): Promise { + const config = resolveLinuxConfig(); + + // Ensure virtual display is running + await ensureDisplayAsync(config); + await ensureWindowManagerAsync(config); + + const env = buildWineEnv(config); + OutputHelper.verbose(`[StudioBridge] wine ${studioExe} "${placePath}"`); + + // Write Wine stderr to a log file so we can diagnose launch failures. + const logPath = path.join(os.tmpdir(), 'studio-bridge-wine.log'); + const logFd = fs.openSync(logPath, 'w'); + OutputHelper.verbose(`[StudioBridge] Wine log: ${logPath}`); + + const proc = spawn('wine', [studioExe, placePath], { + detached: true, + stdio: ['ignore', logFd, logFd], + env, + }); + + // Close our copy of the fd — the child owns it now + fs.closeSync(logFd); + + // Detect early process exit (crash or failure to start) + proc.on('exit', (code, signal) => { + OutputHelper.verbose( + `[StudioBridge] Wine process exited (code=${code}, signal=${signal})` + ); + }); + + // Allow our Node process to exit without waiting for Studio + proc.unref(); + + // Tail the Wine log in verbose mode so diagnostics appear + const tailProc = spawn('tail', ['-f', logPath], { stdio: ['ignore', 'pipe', 'ignore'] }); + tailProc.stdout?.on('data', (chunk: Buffer) => { + const lines = chunk.toString('utf-8').trim().split('\n'); + for (const line of lines) { + if (line) { + OutputHelper.verbose(`[Wine] ${line}`); + } + } + }); + tailProc.unref(); + + let killed = false; + const killAsync = async () => { + if (killed) return; + killed = true; + try { + tailProc.kill('SIGTERM'); + } catch { + // Best effort + } + try { + proc.kill('SIGTERM'); + } catch { + // Best effort + } + }; + + return { process: proc, killAsync }; +} diff --git a/tools/studio-bridge/src/linux/linux-version-resolver.ts b/tools/studio-bridge/src/linux/linux-version-resolver.ts new file mode 100644 index 0000000000..22a890eecd --- /dev/null +++ b/tools/studio-bridge/src/linux/linux-version-resolver.ts @@ -0,0 +1,72 @@ +/** + * Resolve the latest Roblox Studio version hash from the CDN. + */ + +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { OutputHelper } from '@quenty/cli-output-helpers'; + +const CLIENT_SETTINGS_URL = + 'https://clientsettingscdn.roblox.com/v2/client-version/WindowsStudio64'; + +interface ClientVersionResponse { + version: string; + clientVersionUpload: string; + bootstrapperVersion: string; +} + +/** + * Fetch the current Studio version string from the Roblox client settings API. + * Returns a version hash like "version-e095049f34844c41". + */ +export async function resolveStudioVersionAsync( + explicitVersion?: string +): Promise { + if (explicitVersion) { + return explicitVersion; + } + + OutputHelper.verbose('Fetching latest Studio version from client settings...'); + + const response = await fetch(CLIENT_SETTINGS_URL); + if (!response.ok) { + throw new Error( + `Failed to fetch Studio version: ${response.status} ${response.statusText}` + ); + } + + const data = (await response.json()) as ClientVersionResponse; + const version = data.clientVersionUpload; + if (!version.startsWith('version-')) { + throw new Error(`Unexpected version format: ${version}`); + } + + OutputHelper.verbose(`Latest Studio version: ${version} (${data.version})`); + return version; +} + +/** + * Read the installed version from a .studio-version marker file. + * Returns undefined if no version is installed. + */ +export async function readInstalledVersionAsync( + studioDir: string +): Promise { + const versionFile = path.join(studioDir, '.studio-version'); + try { + return (await fs.readFile(versionFile, 'utf-8')).trim(); + } catch { + return undefined; + } +} + +/** + * Write the installed version to a .studio-version marker file. + */ +export async function writeInstalledVersionAsync( + studioDir: string, + version: string +): Promise { + const versionFile = path.join(studioDir, '.studio-version'); + await fs.writeFile(versionFile, version + '\n', 'utf-8'); +} diff --git a/tools/studio-bridge/src/linux/linux-wine-env.test.ts b/tools/studio-bridge/src/linux/linux-wine-env.test.ts new file mode 100644 index 0000000000..8fa404fbb4 --- /dev/null +++ b/tools/studio-bridge/src/linux/linux-wine-env.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'vitest'; +import { buildWineEnv } from './linux-wine-env.js'; +import type { LinuxStudioConfig } from './linux-config.js'; + +function makeConfig(overrides?: Partial): LinuxStudioConfig { + return { + studioDir: '/home/test/roblox-studio', + winePrefix: '/home/test/.wine', + display: ':99', + studioExe: '/home/test/roblox-studio/RobloxStudioBeta.exe', + clientSettingsPath: '/home/test/roblox-studio/ClientSettings/ClientAppSettings.json', + shadersDir: '/home/test/roblox-studio/shaders', + pluginsDir: '/home/test/roblox-studio/Plugins', + writeCredExe: '/home/test/roblox-studio/write-cred.exe', + ...overrides, + }; +} + +describe('buildWineEnv', () => { + it('includes DISPLAY from config', () => { + const env = buildWineEnv(makeConfig({ display: ':42' })); + expect(env.DISPLAY).toBe(':42'); + }); + + it('includes WINEPREFIX from config', () => { + const env = buildWineEnv(makeConfig({ winePrefix: '/tmp/wine' })); + expect(env.WINEPREFIX).toBe('/tmp/wine'); + }); + + it('sets WINEARCH to win64', () => { + const env = buildWineEnv(makeConfig()); + expect(env.WINEARCH).toBe('win64'); + }); + + it('suppresses Wine debug output', () => { + const env = buildWineEnv(makeConfig()); + expect(env.WINEDEBUG).toBe('-all'); + }); + + it('suppresses Mono/Gecko install dialogs', () => { + const env = buildWineEnv(makeConfig()); + expect(env.WINEDLLOVERRIDES).toBe('mscoree=d;mshtml=d'); + }); + + it('sets Mesa GL version overrides', () => { + const env = buildWineEnv(makeConfig()); + expect(env.MESA_GL_VERSION_OVERRIDE).toBe('4.5'); + expect(env.MESA_GLSL_VERSION_OVERRIDE).toBe('450'); + }); + + it('preserves PATH from process.env', () => { + const env = buildWineEnv(makeConfig()); + expect(env.PATH).toBe(process.env.PATH); + }); +}); diff --git a/tools/studio-bridge/src/linux/linux-wine-env.ts b/tools/studio-bridge/src/linux/linux-wine-env.ts new file mode 100644 index 0000000000..e6aa209023 --- /dev/null +++ b/tools/studio-bridge/src/linux/linux-wine-env.ts @@ -0,0 +1,40 @@ +/** + * Assemble the environment variables needed to run Wine processes + * with the correct display, Mesa overrides, and prefix. + */ + +import type { LinuxStudioConfig } from './linux-config.js'; + +/** + * Build a complete env dictionary for running `wine` subprocesses. + * Merges with process.env so the child inherits PATH, HOME, etc. + */ +export function buildWineEnv( + config: LinuxStudioConfig +): Record { + return { + ...stripUndefined(process.env), + DISPLAY: config.display, + WINEPREFIX: config.winePrefix, + WINEARCH: 'win64', + WINEDEBUG: process.env.WINEDEBUG ?? '-all', + // Suppress Mono/Gecko install dialogs that block headless runs + WINEDLLOVERRIDES: 'mscoree=d;mshtml=d', + // Mesa llvmpipe needs these overrides so Wine's WineD3D layer + // sees GL 4.5 / GLSL 4.50 (required for the patched shaders) + MESA_GL_VERSION_OVERRIDE: '4.5', + MESA_GLSL_VERSION_OVERRIDE: '450', + }; +} + +function stripUndefined( + env: NodeJS.ProcessEnv +): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(env)) { + if (value !== undefined) { + result[key] = value; + } + } + return result; +} diff --git a/tools/studio-bridge/src/linux/write-cred.c b/tools/studio-bridge/src/linux/write-cred.c new file mode 100644 index 0000000000..fffb2e822f --- /dev/null +++ b/tools/studio-bridge/src/linux/write-cred.c @@ -0,0 +1,48 @@ +#include +#include +#include +#include + +/** + * Write one or more credentials to the Windows Credential Manager. + * Accepts pairs of arguments: [ ...] + * This batching avoids repeated Wine process startup overhead. + */ +int main(int argc, char *argv[]) { + if (argc < 3 || (argc - 1) % 2 != 0) { + printf("Usage: write-cred.exe [ ...]\n"); + printf("Example: write-cred.exe \"target1\" \"val1\" \"target2\" \"val2\"\n"); + return 1; + } + + int pairs = (argc - 1) / 2; + int failures = 0; + + for (int i = 0; i < pairs; i++) { + const char *target = argv[1 + i * 2]; + const char *value = argv[2 + i * 2]; + + int tlen = MultiByteToWideChar(CP_UTF8, 0, target, -1, NULL, 0); + WCHAR *wtarget = (WCHAR *)malloc(tlen * sizeof(WCHAR)); + MultiByteToWideChar(CP_UTF8, 0, target, -1, wtarget, tlen); + + CREDENTIALW cred; + memset(&cred, 0, sizeof(cred)); + cred.Type = CRED_TYPE_GENERIC; + cred.TargetName = wtarget; + cred.CredentialBlobSize = (DWORD)strlen(value); + cred.CredentialBlob = (BYTE *)value; + cred.Persist = CRED_PERSIST_LOCAL_MACHINE; + + if (!CredWriteW(&cred, 0)) { + printf("CredWriteW failed for '%s': %lu\n", target, GetLastError()); + failures++; + } else { + printf("Credential written: target='%s', value_len=%d\n", target, (int)strlen(value)); + } + + free(wtarget); + } + + return failures > 0 ? 1 : 0; +} diff --git a/tools/studio-bridge/src/mcp/mcp-server.test.ts b/tools/studio-bridge/src/mcp/mcp-server.test.ts index 27993ee75e..0fe4b6565e 100644 --- a/tools/studio-bridge/src/mcp/mcp-server.test.ts +++ b/tools/studio-bridge/src/mcp/mcp-server.test.ts @@ -60,8 +60,8 @@ describe('buildToolDefinitions', () => { const connection = createMockConnection(); const tools = buildToolDefinitions(connection); - // 8 commands with mcp config: exec, logs, query, screenshot, info, list, close, action - expect(tools).toHaveLength(8); + // 7 commands with mcp config: exec, logs, query, screenshot, info, list, close + expect(tools).toHaveLength(7); }); it('registers tools with correct group-based names', () => { @@ -76,7 +76,6 @@ describe('buildToolDefinitions', () => { expect(names).toContain('studio_process_info'); expect(names).toContain('studio_process_list'); expect(names).toContain('studio_process_close'); - expect(names).toContain('studio_action'); }); it('all tools have descriptions', () => { @@ -129,7 +128,6 @@ describe('buildToolDefinitions', () => { 'studio_viewport_screenshot', 'studio_process_info', 'studio_process_close', - 'studio_action', ]; for (const name of sessionTools) { diff --git a/tools/studio-bridge/src/mcp/mcp-server.ts b/tools/studio-bridge/src/mcp/mcp-server.ts index 5581a3fcaa..781cc79545 100644 --- a/tools/studio-bridge/src/mcp/mcp-server.ts +++ b/tools/studio-bridge/src/mcp/mcp-server.ts @@ -28,8 +28,6 @@ import { screenshotCommand } from '../commands/viewport/screenshot/screenshot.js import { infoCommand } from '../commands/process/info/info.js'; import { listCommand } from '../commands/process/list/list.js'; import { processCloseCommand } from '../commands/process/close/close.js'; -import { actionCommand } from '../commands/action/action.js'; - // All commands that opt into MCP (those with an `mcp` config) const MCP_COMMANDS = [ execCommand, @@ -39,7 +37,6 @@ const MCP_COMMANDS = [ infoCommand, listCommand, processCloseCommand, - actionCommand, ]; // --------------------------------------------------------------------------- diff --git a/tools/studio-bridge/src/process/studio-process-manager.test.ts b/tools/studio-bridge/src/process/studio-process-manager.test.ts index 0151dbb1b8..3d60015c16 100644 --- a/tools/studio-bridge/src/process/studio-process-manager.test.ts +++ b/tools/studio-bridge/src/process/studio-process-manager.test.ts @@ -31,8 +31,29 @@ describe('findPluginsFolder', () => { expect(result).toMatch(/Roblox[/\\]Plugins$/); }); - it('throws on unsupported platform', () => { + it('returns correct Linux path using WINEPREFIX', () => { + Object.defineProperty(process, 'platform', { value: 'linux' }); + process.env.WINEPREFIX = '/home/test/.wine'; + process.env.USER = 'testuser'; + + const result = findPluginsFolder(); + expect(result).toMatch(/Plugins$/); + expect(result).toContain('/home/test/.wine/drive_c/users/testuser'); + expect(result).toContain('Roblox'); + }); + + it('returns correct Linux path with default Wine prefix', () => { Object.defineProperty(process, 'platform', { value: 'linux' }); + delete process.env.WINEPREFIX; + + const result = findPluginsFolder(); + expect(result).toMatch(/Plugins$/); + expect(result).toContain('.wine'); + expect(result).toContain('Roblox'); + }); + + it('throws on unsupported platform', () => { + Object.defineProperty(process, 'platform', { value: 'freebsd' }); expect(() => findPluginsFolder()).toThrow('Unsupported platform'); }); diff --git a/tools/studio-bridge/src/process/studio-process-manager.ts b/tools/studio-bridge/src/process/studio-process-manager.ts index c08b9fe400..b31c2742df 100644 --- a/tools/studio-bridge/src/process/studio-process-manager.ts +++ b/tools/studio-bridge/src/process/studio-process-manager.ts @@ -1,9 +1,10 @@ /** * Locates a Roblox Studio installation and manages the Studio process - * lifecycle. Supports Windows and macOS. + * lifecycle. Supports Windows, macOS, and Linux (via Wine). */ import * as fs from 'fs/promises'; +import * as os from 'os'; import * as path from 'path'; import { spawn, type ChildProcess } from 'child_process'; import { execa } from 'execa'; @@ -22,6 +23,8 @@ export async function findStudioPathAsync(): Promise { return findStudioPathWindowsAsync(); } else if (process.platform === 'darwin') { return findStudioPathMacAsync(); + } else if (process.platform === 'linux') { + return findStudioPathLinuxAsync(); } throw new Error(`Unsupported platform: ${process.platform}`); } @@ -68,6 +71,20 @@ async function findStudioPathMacAsync(): Promise { } } +async function findStudioPathLinuxAsync(): Promise { + const { resolveLinuxConfig } = await import('../linux/linux-config.js'); + const config = resolveLinuxConfig(); + + try { + await fs.access(config.studioExe); + return config.studioExe; + } catch { + throw new Error( + `Could not find Roblox Studio at ${config.studioExe}. Run "studio-bridge linux setup" first.` + ); + } +} + /** * Resolve the Studio plugins folder for the current platform. */ @@ -84,6 +101,14 @@ export function findPluginsFolder(): string { throw new Error('HOME environment variable is not set'); } return path.join(home, 'Documents', 'Roblox', 'Plugins'); + } else if (process.platform === 'linux') { + // Studio runs under Wine and resolves plugins via %LOCALAPPDATA% + const winePrefix = process.env.WINEPREFIX || path.join(os.homedir(), '.wine'); + const wineUser = process.env.USER || os.userInfo().username; + return path.join( + winePrefix, 'drive_c', 'users', wineUser, + 'AppData', 'Local', 'Roblox', 'Plugins', + ); } throw new Error(`Unsupported platform: ${process.platform}`); } @@ -109,6 +134,10 @@ export interface StudioProcess { export async function launchStudioAsync( placePath: string ): Promise { + if (process.platform === 'linux') { + return launchStudioLinuxAsync(placePath); + } + const studioExe = await findStudioPathAsync(); OutputHelper.verbose(`[StudioBridge] ${studioExe} "${placePath}"`); @@ -142,3 +171,13 @@ export async function launchStudioAsync( return { process: proc, killAsync }; } + +async function launchStudioLinuxAsync( + placePath: string +): Promise { + const { launchStudioLinuxAsync: launch } = await import( + '../linux/linux-studio-launcher.js' + ); + const studioExe = await findStudioPathAsync(); + return launch(studioExe, placePath); +} diff --git a/tools/studio-bridge/src/server/studio-bridge-server.test.ts b/tools/studio-bridge/src/server/studio-bridge-server.test.ts index 4f0be97861..385d1c6012 100644 --- a/tools/studio-bridge/src/server/studio-bridge-server.test.ts +++ b/tools/studio-bridge/src/server/studio-bridge-server.test.ts @@ -906,7 +906,7 @@ describe('StudioBridgeServer', () => { const { ws, welcome } = await connectAndRegister(port, sessionId, { protocolVersion: 2, - capabilities: ['execute', 'queryState', 'subscribe'], + capabilities: ['execute', 'queryState'], }); client = ws; @@ -914,9 +914,9 @@ describe('StudioBridgeServer', () => { const payload = welcome.payload as Record; expect(payload.protocolVersion).toBe(2); - expect(payload.capabilities).toEqual(['execute', 'queryState', 'subscribe']); + expect(payload.capabilities).toEqual(['execute', 'queryState']); expect(server.protocolVersion).toBe(2); - expect([...server.capabilities]).toEqual(['execute', 'queryState', 'subscribe']); + expect([...server.capabilities]).toEqual(['execute', 'queryState']); }); it('register message negotiates capabilities to intersection', async () => { diff --git a/tools/studio-bridge/src/server/studio-bridge-server.ts b/tools/studio-bridge/src/server/studio-bridge-server.ts index cd8f5a0e0e..20e81634df 100644 --- a/tools/studio-bridge/src/server/studio-bridge-server.ts +++ b/tools/studio-bridge/src/server/studio-bridge-server.ts @@ -30,6 +30,10 @@ import { decodePluginMessage, } from './web-socket-protocol.js'; import { ActionDispatcher } from './action-dispatcher.js'; +import { + loadActionSourcesAsync, + type ActionSource, +} from '../commands/framework/action-loader.js'; import { injectPluginAsync, type InjectedPlugin, @@ -194,8 +198,6 @@ const ACTION_CAPABILITIES: Record = { captureScreenshot: 'captureScreenshot', queryDataModel: 'queryDataModel', queryLogs: 'queryLogs', - subscribe: 'subscribe', - unsubscribe: 'subscribe', execute: 'execute', }; @@ -220,6 +222,8 @@ export class StudioBridgeServer { private _negotiatedCapabilities: Capability[] = ['execute']; private _lastHeartbeatTimestamp: number | undefined; private _actionDispatcher = new ActionDispatcher(); + private _actionsReady = false; + private _actionSources: ActionSource[] | undefined; constructor(options: StudioBridgeServerOptions = {}) { this._options = options; @@ -422,6 +426,10 @@ export class StudioBridgeServer { throw new Error('Cannot execute: no connected client'); } + // Sync action modules before first execute (state must still be 'ready' + // because performActionAsync checks for it). + await this._ensureActionsAsync(); + this._state = 'executing'; this._onPhase?.('executing'); @@ -485,6 +493,75 @@ export class StudioBridgeServer { this._state = 'stopped'; } + // ----------------------------------------------------------------------- + // Private: _ensureActionsAsync + // ----------------------------------------------------------------------- + + /** + * Ensure action modules (like `execute.luau`) are synced to the plugin + * before first use. Uses `syncActions` to check which actions the plugin + * is missing, then registers them via `registerAction`. + * + * Only works with v2 plugins. v1 plugins skip this step (they would need + * actions baked in, which the current plugin architecture does not support). + */ + private async _ensureActionsAsync(): Promise { + if (this._actionsReady) return; + + if (this._negotiatedProtocolVersion < 2) { + this._actionsReady = true; + return; + } + + if (!this._actionSources) { + this._actionSources = await loadActionSourcesAsync(); + OutputHelper.verbose( + `[StudioBridge] Loaded ${this._actionSources.length} action source(s): ${this._actionSources.map((a) => a.name).join(', ') || '(none)'}`, + ); + } + + if (this._actionSources.length === 0) { + this._actionsReady = true; + return; + } + + const actions: Record = {}; + for (const action of this._actionSources) { + actions[action.name] = action.hash; + } + + OutputHelper.verbose('[StudioBridge] Syncing actions with plugin'); + const syncResult = await this.performActionAsync({ + type: 'syncActions', + payload: { actions }, + }, 10_000); + + if (syncResult.type === 'syncActionsResult') { + const needed = (syncResult.payload as Record).needed as string[]; + OutputHelper.verbose( + `[StudioBridge] ${needed.length} action(s) need registering${needed.length > 0 ? ': ' + needed.join(', ') : ''}`, + ); + + for (const actionName of needed) { + const action = this._actionSources.find((a) => a.name === actionName); + if (!action) continue; + + OutputHelper.verbose(`[StudioBridge] Registering action: ${actionName}`); + await this.performActionAsync({ + type: 'registerAction', + payload: { + name: action.name, + source: action.source, + hash: action.hash, + }, + }, 10_000); + } + } + + this._actionsReady = true; + OutputHelper.verbose('[StudioBridge] Action sync complete'); + } + // ----------------------------------------------------------------------- // Private: _injectPluginAsync // ----------------------------------------------------------------------- @@ -579,7 +656,6 @@ export class StudioBridgeServer { 'captureScreenshot', 'queryDataModel', 'queryLogs', - 'subscribe', ]; if (msg.type === 'hello') { @@ -694,7 +770,6 @@ export class StudioBridgeServer { 'captureScreenshot', 'queryDataModel', 'queryLogs', - 'subscribe', ]; return new Promise((resolve, reject) => { @@ -944,6 +1019,14 @@ export class StudioBridgeServer { (msg.payload.error ? ` error=${msg.payload.error}` : '') ); + // Extract captured output from the scriptComplete payload + if (msg.payload.output) { + for (const entry of msg.payload.output) { + logLines.push(entry.body); + options.onOutput?.(entry.level as OutputLevel, entry.body); + } + } + if (msg.payload.error) { logLines.push(msg.payload.error); } @@ -954,6 +1037,19 @@ export class StudioBridgeServer { }); break; } + + case 'error': { + const errorPayload = msg.payload as { code: string; message: string }; + OutputHelper.verbose( + `[StudioBridge] Plugin error: ${errorPayload.code} — ${errorPayload.message}` + ); + logLines.push(`[StudioBridge] Plugin error: ${errorPayload.code} — ${errorPayload.message}`); + finish({ + success: false, + logs: logLines.join('\n'), + }); + break; + } } }; diff --git a/tools/studio-bridge/src/server/web-socket-protocol-v2.test.ts b/tools/studio-bridge/src/server/web-socket-protocol-v2.test.ts index 4049a68ca7..7eb2b0bace 100644 --- a/tools/studio-bridge/src/server/web-socket-protocol-v2.test.ts +++ b/tools/studio-bridge/src/server/web-socket-protocol-v2.test.ts @@ -348,63 +348,31 @@ describe('decodePluginMessage (v2)', () => { }); }); - it('returns null with missing pendingRequests', () => { - expect(roundTripPlugin({ + it('accepts heartbeat with missing fields using defaults', () => { + const msg = roundTripPlugin({ type: 'heartbeat', sessionId: 'sess-1', payload: { uptimeMs: 60000, state: 'Edit' }, - })).toBeNull(); - }); - }); - - describe('subscribeResult', () => { - it('decodes a valid subscribeResult', () => { - const msg = roundTripPlugin({ - type: 'subscribeResult', - sessionId: 'sess-1', - requestId: 'req-5', - payload: { events: ['stateChange', 'logPush'] }, }); expect(msg).toEqual({ - type: 'subscribeResult', + type: 'heartbeat', sessionId: 'sess-1', - requestId: 'req-5', - payload: { events: ['stateChange', 'logPush'] }, + payload: { uptimeMs: 60000, state: 'Edit', pendingRequests: 0 }, }); }); - it('returns null without requestId', () => { - expect(roundTripPlugin({ - type: 'subscribeResult', - sessionId: 'sess-1', - payload: { events: ['stateChange'] }, - })).toBeNull(); - }); - }); - - describe('unsubscribeResult', () => { - it('decodes a valid unsubscribeResult', () => { + it('accepts empty payload with all defaults', () => { const msg = roundTripPlugin({ - type: 'unsubscribeResult', + type: 'heartbeat', sessionId: 'sess-1', - requestId: 'req-6', - payload: { events: ['logPush'] }, + payload: {}, }); expect(msg).toEqual({ - type: 'unsubscribeResult', + type: 'heartbeat', sessionId: 'sess-1', - requestId: 'req-6', - payload: { events: ['logPush'] }, + payload: { uptimeMs: 0, state: 'Edit', pendingRequests: 0 }, }); }); - - it('returns null without requestId', () => { - expect(roundTripPlugin({ - type: 'unsubscribeResult', - sessionId: 'sess-1', - payload: { events: [] }, - })).toBeNull(); - }); }); describe('error (plugin)', () => { @@ -648,64 +616,6 @@ describe('encodeMessage / decodeServerMessage (v2)', () => { }); }); - describe('subscribe', () => { - it('round-trips subscribe', () => { - const msg: ServerMessage = { - type: 'subscribe', - sessionId: 'sess-1', - requestId: 'req-14', - payload: { events: ['stateChange', 'logPush'] }, - }; - expect(roundTripServer(msg)).toEqual(msg); - }); - - it('returns null without requestId', () => { - expect(decodeServerMessage(JSON.stringify({ - type: 'subscribe', - sessionId: 'sess-1', - payload: { events: ['stateChange'] }, - }))).toBeNull(); - }); - - it('returns null without events array', () => { - expect(decodeServerMessage(JSON.stringify({ - type: 'subscribe', - sessionId: 'sess-1', - requestId: 'req-14', - payload: {}, - }))).toBeNull(); - }); - }); - - describe('unsubscribe', () => { - it('round-trips unsubscribe', () => { - const msg: ServerMessage = { - type: 'unsubscribe', - sessionId: 'sess-1', - requestId: 'req-15', - payload: { events: ['logPush'] }, - }; - expect(roundTripServer(msg)).toEqual(msg); - }); - - it('returns null without requestId', () => { - expect(decodeServerMessage(JSON.stringify({ - type: 'unsubscribe', - sessionId: 'sess-1', - payload: { events: [] }, - }))).toBeNull(); - }); - - it('returns null without events array', () => { - expect(decodeServerMessage(JSON.stringify({ - type: 'unsubscribe', - sessionId: 'sess-1', - requestId: 'req-15', - payload: {}, - }))).toBeNull(); - }); - }); - describe('error (server)', () => { it('round-trips error with requestId', () => { const msg: ServerMessage = { diff --git a/tools/studio-bridge/src/server/web-socket-protocol.ts b/tools/studio-bridge/src/server/web-socket-protocol.ts index ee6a0090be..319d669351 100644 --- a/tools/studio-bridge/src/server/web-socket-protocol.ts +++ b/tools/studio-bridge/src/server/web-socket-protocol.ts @@ -4,9 +4,8 @@ * * v1 messages: hello, output, scriptComplete, welcome, execute, shutdown * v2 messages: register, queryState, stateResult, captureScreenshot, screenshotResult, - * queryDataModel, dataModelResult, queryLogs, logsResult, subscribe, subscribeResult, - * unsubscribe, unsubscribeResult, stateChange, heartbeat, error, - * registerAction, registerActionResult + * queryDataModel, dataModelResult, queryLogs, logsResult, stateChange, heartbeat, + * error, registerAction, registerActionResult */ // --------------------------------------------------------------------------- @@ -20,7 +19,6 @@ export type OutputLevel = 'Print' | 'Info' | 'Warning' | 'Error'; // --------------------------------------------------------------------------- export type StudioState = 'Edit' | 'Play' | 'Paused' | 'Run' | 'Server' | 'Client'; -export type SubscribableEvent = 'stateChange' | 'logPush'; export type Capability = | 'execute' @@ -28,7 +26,6 @@ export type Capability = | 'captureScreenshot' | 'queryDataModel' | 'queryLogs' - | 'subscribe' | 'heartbeat' | 'registerAction' | 'syncActions'; @@ -199,20 +196,6 @@ export interface HeartbeatMessage extends PushMessage { }; } -export interface SubscribeResultMessage extends RequestMessage { - type: 'subscribeResult'; - payload: { - events: SubscribableEvent[]; - }; -} - -export interface UnsubscribeResultMessage extends RequestMessage { - type: 'unsubscribeResult'; - payload: { - events: SubscribableEvent[]; - }; -} - export interface RegisterActionResultMessage extends RequestMessage { type: 'registerActionResult'; payload: { @@ -252,8 +235,6 @@ export type PluginMessage = | LogsResultMessage | StateChangeMessage | HeartbeatMessage - | SubscribeResultMessage - | UnsubscribeResultMessage | RegisterActionResultMessage | SyncActionsResultMessage | PluginErrorMessage; @@ -320,20 +301,6 @@ export interface QueryLogsMessage extends RequestMessage { }; } -export interface SubscribeMessage extends RequestMessage { - type: 'subscribe'; - payload: { - events: SubscribableEvent[]; - }; -} - -export interface UnsubscribeMessage extends RequestMessage { - type: 'unsubscribe'; - payload: { - events: SubscribableEvent[]; - }; -} - export interface RegisterActionMessage extends RequestMessage { type: 'registerAction'; payload: { @@ -369,8 +336,6 @@ export type ServerMessage = | CaptureScreenshotMessage | QueryDataModelMessage | QueryLogsMessage - | SubscribeMessage - | UnsubscribeMessage | RegisterActionMessage | SyncActionsMessage | ServerErrorMessage; @@ -587,44 +552,15 @@ export function decodePluginMessage(raw: string): PluginMessage | null { }; case 'heartbeat': - if ( - typeof payload.uptimeMs !== 'number' || - typeof payload.state !== 'string' || - typeof payload.pendingRequests !== 'number' - ) { - return null; - } + // Accept empty payloads (Luau empty table encodes as []) with defaults. + // v1 plugins send payload: {}, v2 plugins send rich data. return { type: 'heartbeat', sessionId, payload: { - uptimeMs: payload.uptimeMs, - state: payload.state as StudioState, - pendingRequests: payload.pendingRequests, - }, - }; - - case 'subscribeResult': - if (requestId === undefined) return null; - if (!Array.isArray(payload.events)) return null; - return { - type: 'subscribeResult', - sessionId, - requestId, - payload: { - events: payload.events as SubscribableEvent[], - }, - }; - - case 'unsubscribeResult': - if (requestId === undefined) return null; - if (!Array.isArray(payload.events)) return null; - return { - type: 'unsubscribeResult', - sessionId, - requestId, - payload: { - events: payload.events as SubscribableEvent[], + uptimeMs: typeof payload.uptimeMs === 'number' ? payload.uptimeMs : 0, + state: typeof payload.state === 'string' ? (payload.state as StudioState) : 'Edit', + pendingRequests: typeof payload.pendingRequests === 'number' ? payload.pendingRequests : 0, }, }; @@ -770,30 +706,6 @@ export function decodeServerMessage(raw: string): ServerMessage | null { }, }; - case 'subscribe': - if (requestId === undefined) return null; - if (!Array.isArray(payload.events)) return null; - return { - type: 'subscribe', - sessionId, - requestId, - payload: { - events: payload.events as SubscribableEvent[], - }, - }; - - case 'unsubscribe': - if (requestId === undefined) return null; - if (!Array.isArray(payload.events)) return null; - return { - type: 'unsubscribe', - sessionId, - requestId, - payload: { - events: payload.events as SubscribableEvent[], - }, - }; - case 'registerAction': if (requestId === undefined) return null; if (typeof payload.name !== 'string' || typeof payload.source !== 'string') return null; diff --git a/tools/studio-bridge/templates/studio-bridge-plugin-test/test/execute-handler.test.luau b/tools/studio-bridge/templates/studio-bridge-plugin-test/test/execute-handler.test.luau index 8dfcafde0f..30c3e5eb16 100644 --- a/tools/studio-bridge/templates/studio-bridge-plugin-test/test/execute-handler.test.luau +++ b/tools/studio-bridge/templates/studio-bridge-plugin-test/test/execute-handler.test.luau @@ -11,7 +11,7 @@ ]] local ActionRouter = require("../../studio-bridge-plugin/src/Shared/ActionRouter") -local ExecuteAction = require("../../studio-bridge-plugin/src/Actions/ExecuteAction") +local ExecuteAction = require("../../../src/commands/console/exec/execute") -- --------------------------------------------------------------------------- -- Test helpers diff --git a/tools/studio-bridge/templates/studio-bridge-plugin-test/test/new-actions.test.luau b/tools/studio-bridge/templates/studio-bridge-plugin-test/test/new-actions.test.luau index 5ccadb0bce..27593d0848 100644 --- a/tools/studio-bridge/templates/studio-bridge-plugin-test/test/new-actions.test.luau +++ b/tools/studio-bridge/templates/studio-bridge-plugin-test/test/new-actions.test.luau @@ -1,18 +1,17 @@ --[[ - Tests for new action handlers: execute field fix, QueryLogsAction, - CaptureScreenshotAction stub, and SubscribeAction stubs. + Tests for action handlers: execute field fix, QueryLogsAction, + and CaptureScreenshotAction stub. QueryStateAction requires Roblox services (RunService, game) and cannot be tested under Lune. It is tested manually via - `studio-bridge state` after installation. + `studio-bridge process info` after installation. ]] local ActionRouter = require("../../studio-bridge-plugin/src/Shared/ActionRouter") -local CaptureScreenshotAction = require("../../studio-bridge-plugin/src/Actions/CaptureScreenshotAction") -local ExecuteAction = require("../../studio-bridge-plugin/src/Actions/ExecuteAction") +local CaptureScreenshotAction = require("../../../src/commands/viewport/screenshot/capture-screenshot") +local ExecuteAction = require("../../../src/commands/console/exec/execute") local MessageBuffer = require("../../studio-bridge-plugin/src/Shared/MessageBuffer") -local QueryLogsAction = require("../../studio-bridge-plugin/src/Actions/QueryLogsAction") -local SubscribeAction = require("../../studio-bridge-plugin/src/Actions/SubscribeAction") +local QueryLogsAction = require("../../../src/commands/console/logs/query-logs") -- --------------------------------------------------------------------------- -- Test helpers @@ -337,68 +336,4 @@ table.insert(tests, { end, }) --- =========================================================================== --- SubscribeAction stubs --- =========================================================================== - -table.insert(tests, { - name = "SubscribeAction: echoes back requested events", - fn = function() - local router = ActionRouter.new() - SubscribeAction.register(router) - - local response = router:dispatch({ - type = "subscribe", - sessionId = "sess-1", - requestId = "req-1", - payload = { events = { "stateChange", "logPush" } }, - }) - - assertNotNil(response) - assertEqual(response.type, "subscribeResult") - assertEqual(#response.payload.events, 2) - assertEqual(response.payload.events[1], "stateChange") - assertEqual(response.payload.events[2], "logPush") - end, -}) - -table.insert(tests, { - name = "UnsubscribeAction: echoes back requested events", - fn = function() - local router = ActionRouter.new() - SubscribeAction.register(router) - - local response = router:dispatch({ - type = "unsubscribe", - sessionId = "sess-1", - requestId = "req-1", - payload = { events = { "stateChange" } }, - }) - - assertNotNil(response) - assertEqual(response.type, "unsubscribeResult") - assertEqual(#response.payload.events, 1) - assertEqual(response.payload.events[1], "stateChange") - end, -}) - -table.insert(tests, { - name = "SubscribeAction: handles nil events gracefully", - fn = function() - local router = ActionRouter.new() - SubscribeAction.register(router) - - local response = router:dispatch({ - type = "subscribe", - sessionId = "sess-1", - requestId = "req-1", - payload = {}, - }) - - assertNotNil(response) - assertEqual(response.type, "subscribeResult") - assertEqual(#response.payload.events, 0, "empty events when nil") - end, -}) - return tests diff --git a/tools/studio-bridge/templates/studio-bridge-plugin-test/test/plugin-entry.test.luau b/tools/studio-bridge/templates/studio-bridge-plugin-test/test/plugin-entry.test.luau index b04ca15769..4e82dd82e3 100644 --- a/tools/studio-bridge/templates/studio-bridge-plugin-test/test/plugin-entry.test.luau +++ b/tools/studio-bridge/templates/studio-bridge-plugin-test/test/plugin-entry.test.luau @@ -10,7 +10,7 @@ ]] local ActionRouter = require("../../studio-bridge-plugin/src/Shared/ActionRouter") -local ExecuteAction = require("../../studio-bridge-plugin/src/Actions/ExecuteAction") +local ExecuteAction = require("../../../src/commands/console/exec/execute") local MessageBuffer = require("../../studio-bridge-plugin/src/Shared/MessageBuffer") local mocks = require("./roblox-mocks") diff --git a/tools/studio-bridge/templates/studio-bridge-plugin/src/Actions/CaptureScreenshotAction.luau b/tools/studio-bridge/templates/studio-bridge-plugin/src/Actions/CaptureScreenshotAction.luau deleted file mode 120000 index 7cbd43bb1b..0000000000 --- a/tools/studio-bridge/templates/studio-bridge-plugin/src/Actions/CaptureScreenshotAction.luau +++ /dev/null @@ -1 +0,0 @@ -../../../../src/commands/viewport/screenshot/capture-screenshot.luau \ No newline at end of file diff --git a/tools/studio-bridge/templates/studio-bridge-plugin/src/Actions/ExecuteAction.luau b/tools/studio-bridge/templates/studio-bridge-plugin/src/Actions/ExecuteAction.luau deleted file mode 120000 index 8b3f8c5c9d..0000000000 --- a/tools/studio-bridge/templates/studio-bridge-plugin/src/Actions/ExecuteAction.luau +++ /dev/null @@ -1 +0,0 @@ -../../../../src/commands/console/exec/execute.luau \ No newline at end of file diff --git a/tools/studio-bridge/templates/studio-bridge-plugin/src/Actions/InvokeAction.luau b/tools/studio-bridge/templates/studio-bridge-plugin/src/Actions/InvokeAction.luau deleted file mode 120000 index 70a0e32893..0000000000 --- a/tools/studio-bridge/templates/studio-bridge-plugin/src/Actions/InvokeAction.luau +++ /dev/null @@ -1 +0,0 @@ -../../../../src/commands/action/invoke-action.luau \ No newline at end of file diff --git a/tools/studio-bridge/templates/studio-bridge-plugin/src/Actions/QueryDataModelAction.luau b/tools/studio-bridge/templates/studio-bridge-plugin/src/Actions/QueryDataModelAction.luau deleted file mode 120000 index ca3c53bdac..0000000000 --- a/tools/studio-bridge/templates/studio-bridge-plugin/src/Actions/QueryDataModelAction.luau +++ /dev/null @@ -1 +0,0 @@ -../../../../src/commands/explorer/query/query-data-model.luau \ No newline at end of file diff --git a/tools/studio-bridge/templates/studio-bridge-plugin/src/Actions/QueryLogsAction.luau b/tools/studio-bridge/templates/studio-bridge-plugin/src/Actions/QueryLogsAction.luau deleted file mode 120000 index 9627ec5b1a..0000000000 --- a/tools/studio-bridge/templates/studio-bridge-plugin/src/Actions/QueryLogsAction.luau +++ /dev/null @@ -1 +0,0 @@ -../../../../src/commands/console/logs/query-logs.luau \ No newline at end of file diff --git a/tools/studio-bridge/templates/studio-bridge-plugin/src/Actions/QueryStateAction.luau b/tools/studio-bridge/templates/studio-bridge-plugin/src/Actions/QueryStateAction.luau deleted file mode 120000 index 07a4cf0a60..0000000000 --- a/tools/studio-bridge/templates/studio-bridge-plugin/src/Actions/QueryStateAction.luau +++ /dev/null @@ -1 +0,0 @@ -../../../../src/commands/process/info/query-state.luau \ No newline at end of file diff --git a/tools/studio-bridge/templates/studio-bridge-plugin/src/Actions/SubscribeAction.luau b/tools/studio-bridge/templates/studio-bridge-plugin/src/Actions/SubscribeAction.luau deleted file mode 120000 index 5eb907f426..0000000000 --- a/tools/studio-bridge/templates/studio-bridge-plugin/src/Actions/SubscribeAction.luau +++ /dev/null @@ -1 +0,0 @@ -../../../../src/commands/framework/subscribe.luau \ No newline at end of file diff --git a/tools/studio-bridge/templates/studio-bridge-plugin/src/StudioBridgePlugin.server.lua b/tools/studio-bridge/templates/studio-bridge-plugin/src/StudioBridgePlugin.server.lua index ef84aae35c..d373c4424e 100644 --- a/tools/studio-bridge/templates/studio-bridge-plugin/src/StudioBridgePlugin.server.lua +++ b/tools/studio-bridge/templates/studio-bridge-plugin/src/StudioBridgePlugin.server.lua @@ -280,11 +280,20 @@ local function wireConnection(ws, sessionId, connectLabel) connected = false end) - -- Heartbeat coroutine + -- Heartbeat coroutine — send v2 rich heartbeat data + local heartbeatStart = os.clock() task.spawn(function() while connected do pcall(function() - ws:Send(jsonEncode({ type = "heartbeat", sessionId = sessionId, payload = {} })) + ws:Send(jsonEncode({ + type = "heartbeat", + sessionId = sessionId, + payload = { + uptimeMs = math.floor((os.clock() - heartbeatStart) * 1000), + state = detectContext() == "edit" and "Edit" or detectContext(), + pendingRequests = 0, + }, + })) end) task.wait(15) end