From d8b8b49f37f4d07ddd705b0af382351cbef32013 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Fri, 13 Mar 2026 20:48:22 -0700 Subject: [PATCH] Fix Foundry UI bugs: org names, sessions, and repo selection (#250) * Fix Foundry auth: migrate to Better Auth adapter, fix access token retrieval - Remove @ts-nocheck from better-auth.ts, auth-user/index.ts, app-shell.ts and fix all type errors - Fix getAccessTokenForSession: read GitHub token directly from account record instead of calling Better Auth's internal /get-access-token endpoint which returns 403 on server-side calls - Re-implement workspaceAuth helper functions (workspaceAuthColumn, normalizeAuthValue, workspaceAuthClause, workspaceAuthWhere) that were accidentally deleted - Remove all retry logic (withRetries, isRetryableAppActorError) - Implement CORS origin allowlist from configured environment - Document cachedAppWorkspace singleton pattern - Add inline org sync fallback in buildAppSnapshot for post-OAuth flow - Add no-retry rule to CLAUDE.md Co-Authored-By: Claude Opus 4.6 * Add Foundry dev panel from fix-git-data branch Port the dev panel component that was left out when PR #243 was replaced by PR #247. Adapted to remove runtime/mock-debug references that don't exist on the current branch. - Toggle with Shift+D, persists visibility to localStorage - Shows context, session, GitHub sync status sections - Dev-only (import.meta.env.DEV) Co-Authored-By: Claude Opus 4.6 * Add full Docker image defaults, fix actor deadlocks, and improve dev experience - Add Dockerfile.full and --all flag to install-agent CLI for pre-built images - Centralize Docker image constant (FULL_IMAGE) pinned to 0.3.1-full - Remove examples/shared/Dockerfile{,.dev} and daytona snapshot example - Expand Docker docs with full runnable Dockerfile - Fix self-deadlock in createWorkbenchSession (fire-and-forget provisioning) - Audit and convert 12 task actions from wait:true to wait:false - Add bun --hot for dev backend hot reload - Remove --force from pnpm install in dev Dockerfile for faster startup - Add env_file support to compose.dev.yaml for automatic credential loading - Add mock frontend compose config and dev panel - Update CLAUDE.md with wait:true policy and dev environment setup Co-Authored-By: Claude Opus 4.6 * WIP: async action fixes and interest manager Co-Authored-By: Claude Opus 4.6 * Fix Foundry UI bugs: org names, hanging sessions, and wrong repo creation - Fix org display name using GitHub description instead of name field - Fix createWorkbenchSession hanging when sandbox is provisioning - Fix auto-session creation retry storm on errors - Fix task creation using wrong repo due to React state race conditions - Remove Bun hot-reload from backend Dockerfile (causes port drift) - Add GitHub sync/install status to dev panel Co-Authored-By: Claude Haiku 4.5 --------- Co-authored-by: Claude Opus 4.6 --- .env.development.example | 10 +- .github/workflows/release.yaml | 18 +- CLAUDE.md | 4 +- README.md | 5 +- docker/runtime/Dockerfile | 2 +- docker/runtime/Dockerfile.full | 162 ++ docs/cli.mdx | 12 +- docs/deploy/docker.mdx | 43 +- docs/deploy/foundry-self-hosting.mdx | 2 +- docs/quickstart.mdx | 11 +- examples/daytona/package.json | 1 - examples/daytona/src/daytona-with-snapshot.ts | 39 - examples/docker/src/index.ts | 19 +- examples/persist-memory/src/index.ts | 1 - examples/persist-postgres/src/index.ts | 1 - examples/persist-sqlite/src/index.ts | 1 - examples/shared/Dockerfile | 5 - examples/shared/Dockerfile.dev | 63 - examples/shared/src/docker.ts | 65 +- foundry/CLAUDE.md | 86 +- foundry/compose.dev.yaml | 4 + foundry/compose.mock.yaml | 32 + foundry/docker/backend.dev.Dockerfile | 6 +- foundry/packages/backend/package.json | 1 + .../backend/src/actors/auth-user/db/db.ts | 5 + .../src/actors/auth-user/db/migrations.ts | 80 + .../backend/src/actors/auth-user/db/schema.ts | 70 + .../backend/src/actors/auth-user/index.ts | 353 +++++ .../packages/backend/src/actors/handles.ts | 26 +- foundry/packages/backend/src/actors/index.ts | 3 + foundry/packages/backend/src/actors/keys.ts | 4 + .../backend/src/actors/project/actions.ts | 251 +++- .../backend/src/actors/project/db/schema.ts | 12 + .../src/actors/sandbox-instance/index.ts | 16 +- .../backend/src/actors/task/db/schema.ts | 9 + .../packages/backend/src/actors/task/index.ts | 69 +- .../backend/src/actors/task/workbench.ts | 640 ++++++-- .../src/actors/task/workflow/common.ts | 8 +- .../backend/src/actors/task/workflow/index.ts | 45 +- .../backend/src/actors/task/workflow/init.ts | 100 +- .../backend/src/actors/task/workflow/queue.ts | 3 + .../backend/src/actors/workspace/actions.ts | 220 ++- .../backend/src/actors/workspace/app-shell.ts | 872 +++++++---- .../src/actors/workspace/db/migrations.ts | 55 + .../backend/src/actors/workspace/db/schema.ts | 59 +- foundry/packages/backend/src/index.ts | 208 +-- foundry/packages/backend/src/logging.ts | 1 + .../backend/src/services/app-github.ts | 4 +- .../backend/src/services/better-auth.ts | 533 +++++++ foundry/packages/client/package.json | 2 + foundry/packages/client/src/backend-client.ts | 410 ++--- foundry/packages/client/src/index.ts | 5 + .../packages/client/src/interest/manager.ts | 24 + .../client/src/interest/mock-manager.ts | 12 + .../client/src/interest/remote-manager.ts | 167 +++ .../packages/client/src/interest/topics.ts | 131 ++ .../client/src/interest/use-interest.ts | 56 + foundry/packages/client/src/mock-app.ts | 103 +- .../client/src/mock/backend-client.ts | 247 ++- .../packages/client/src/remote/app-client.ts | 30 +- .../packages/client/src/workbench-model.ts | 89 +- .../client/test/interest-manager.test.ts | 171 +++ foundry/packages/frontend/index.html | 3 - foundry/packages/frontend/src/app/router.tsx | 7 +- .../frontend/src/components/dev-panel.tsx | 379 +++++ .../frontend/src/components/mock-layout.tsx | 414 ++++- .../src/components/mock-layout/sidebar.tsx | 212 +-- .../components/mock-layout/terminal-pane.tsx | 88 +- .../src/components/mock-onboarding.tsx | 6 +- .../src/components/workspace-dashboard.tsx | 185 +-- foundry/packages/frontend/src/lib/interest.ts | 5 + foundry/packages/frontend/src/lib/mock-app.ts | 81 +- .../packages/frontend/src/lib/workbench.ts | 20 - foundry/packages/shared/package.json | 1 + foundry/packages/shared/src/contracts.ts | 19 + foundry/packages/shared/src/index.ts | 1 + foundry/packages/shared/src/logging.ts | 123 ++ .../packages/shared/src/realtime-events.ts | 36 + foundry/packages/shared/src/workbench.ts | 87 +- foundry/packages/shared/test/logging.test.ts | 29 + .../realtime-interest-manager-spec.md | 919 ++++++++++++ foundry/screenshots/dev-panel-github.png | Bin 0 -> 687698 bytes foundry/scripts/data/rivet-dev.json | 1332 +++++++++++++++++ foundry/scripts/pull-org-data.ts | 290 ++++ justfile | 54 +- pnpm-lock.yaml | 1024 +++++++++---- scripts/release/docker.ts | 44 +- server/packages/sandbox-agent/src/cli.rs | 140 +- 88 files changed, 9252 insertions(+), 1933 deletions(-) create mode 100644 docker/runtime/Dockerfile.full delete mode 100644 examples/daytona/src/daytona-with-snapshot.ts delete mode 100644 examples/shared/Dockerfile delete mode 100644 examples/shared/Dockerfile.dev create mode 100644 foundry/compose.mock.yaml create mode 100644 foundry/packages/backend/src/actors/auth-user/db/db.ts create mode 100644 foundry/packages/backend/src/actors/auth-user/db/migrations.ts create mode 100644 foundry/packages/backend/src/actors/auth-user/db/schema.ts create mode 100644 foundry/packages/backend/src/actors/auth-user/index.ts create mode 100644 foundry/packages/backend/src/services/better-auth.ts create mode 100644 foundry/packages/client/src/interest/manager.ts create mode 100644 foundry/packages/client/src/interest/mock-manager.ts create mode 100644 foundry/packages/client/src/interest/remote-manager.ts create mode 100644 foundry/packages/client/src/interest/topics.ts create mode 100644 foundry/packages/client/src/interest/use-interest.ts create mode 100644 foundry/packages/client/test/interest-manager.test.ts create mode 100644 foundry/packages/frontend/src/components/dev-panel.tsx create mode 100644 foundry/packages/frontend/src/lib/interest.ts delete mode 100644 foundry/packages/frontend/src/lib/workbench.ts create mode 100644 foundry/packages/shared/src/realtime-events.ts create mode 100644 foundry/packages/shared/test/logging.test.ts create mode 100644 foundry/research/realtime-interest-manager-spec.md create mode 100644 foundry/screenshots/dev-panel-github.png create mode 100644 foundry/scripts/data/rivet-dev.json create mode 100644 foundry/scripts/pull-org-data.ts diff --git a/.env.development.example b/.env.development.example index 24bbef1..c4132f4 100644 --- a/.env.development.example +++ b/.env.development.example @@ -1,10 +1,14 @@ -# Load this file only when NODE_ENV=development. -# The backend does not load dotenv files in production. +# Foundry local development environment. +# Copy ~/misc/the-foundry.env to .env in the repo root to populate secrets. +# .env is gitignored — never commit it. The source of truth is ~/misc/the-foundry.env. +# +# Docker Compose (just foundry-dev) and the justfile (set dotenv-load := true) +# both read .env automatically. APP_URL=http://localhost:4173 BETTER_AUTH_URL=http://localhost:4173 BETTER_AUTH_SECRET=sandbox-agent-foundry-development-only-change-me -GITHUB_REDIRECT_URI=http://localhost:4173/api/rivet/app/auth/github/callback +GITHUB_REDIRECT_URI=http://localhost:4173/v1/auth/callback/github # Fill these in when enabling live GitHub OAuth. GITHUB_CLIENT_ID= diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 102f612..34fb64a 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -180,10 +180,20 @@ jobs: include: - platform: linux/arm64 runner: depot-ubuntu-24.04-arm-8 - arch_suffix: -arm64 + tag_suffix: -arm64 + dockerfile: docker/runtime/Dockerfile - platform: linux/amd64 runner: depot-ubuntu-24.04-8 - arch_suffix: -amd64 + tag_suffix: -amd64 + dockerfile: docker/runtime/Dockerfile + - platform: linux/arm64 + runner: depot-ubuntu-24.04-arm-8 + tag_suffix: -full-arm64 + dockerfile: docker/runtime/Dockerfile.full + - platform: linux/amd64 + runner: depot-ubuntu-24.04-8 + tag_suffix: -full-amd64 + dockerfile: docker/runtime/Dockerfile.full runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@v4 @@ -205,8 +215,8 @@ jobs: with: context: . push: true - tags: rivetdev/sandbox-agent:${{ steps.vars.outputs.sha_short }}${{ matrix.arch_suffix }} - file: docker/runtime/Dockerfile + tags: rivetdev/sandbox-agent:${{ steps.vars.outputs.sha_short }}${{ matrix.tag_suffix }} + file: ${{ matrix.dockerfile }} platforms: ${{ matrix.platform }} build-args: | TARGETARCH=${{ contains(matrix.platform, 'arm64') && 'arm64' || 'amd64' }} diff --git a/CLAUDE.md b/CLAUDE.md index cbc0c18..26dfa28 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -125,7 +125,7 @@ ## Docker Examples (Dev Testing) - When manually testing bleeding-edge (unreleased) versions of sandbox-agent in `examples/`, use `SANDBOX_AGENT_DEV=1` with the Docker-based examples. -- This triggers `examples/shared/Dockerfile.dev` which builds the server binary from local source and packages it into the Docker image. +- This triggers a local build of `docker/runtime/Dockerfile.full` which builds the server binary from local source and packages it into the Docker image. - Example: `SANDBOX_AGENT_DEV=1 pnpm --filter @sandbox-agent/example-mcp start` ## Install Version References @@ -152,7 +152,7 @@ - `.claude/commands/post-release-testing.md` - `examples/cloudflare/Dockerfile` - `examples/daytona/src/index.ts` - - `examples/daytona/src/daytona-with-snapshot.ts` + - `examples/shared/src/docker.ts` - `examples/docker/src/index.ts` - `examples/e2b/src/index.ts` - `examples/vercel/src/index.ts` diff --git a/README.md b/README.md index b84df8d..d4bfc61 100644 --- a/README.md +++ b/README.md @@ -143,10 +143,7 @@ sandbox-agent server --token "$SANDBOX_TOKEN" --host 127.0.0.1 --port 2468 Optional: preinstall agent binaries (no server required; they will be installed lazily on first use if you skip this): ```bash -sandbox-agent install-agent claude -sandbox-agent install-agent codex -sandbox-agent install-agent opencode -sandbox-agent install-agent amp +sandbox-agent install-agent --all ``` To disable auth locally: diff --git a/docker/runtime/Dockerfile b/docker/runtime/Dockerfile index 27b9560..bdd1a16 100644 --- a/docker/runtime/Dockerfile +++ b/docker/runtime/Dockerfile @@ -167,4 +167,4 @@ WORKDIR /home/sandbox EXPOSE 2468 ENTRYPOINT ["sandbox-agent"] -CMD ["--host", "0.0.0.0", "--port", "2468"] +CMD ["server", "--host", "0.0.0.0", "--port", "2468"] diff --git a/docker/runtime/Dockerfile.full b/docker/runtime/Dockerfile.full new file mode 100644 index 0000000..beb1664 --- /dev/null +++ b/docker/runtime/Dockerfile.full @@ -0,0 +1,162 @@ +# syntax=docker/dockerfile:1.10.0 + +# ============================================================================ +# Build inspector frontend +# ============================================================================ +FROM node:22-alpine AS inspector-build +WORKDIR /app +RUN npm install -g pnpm + +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY frontend/packages/inspector/package.json ./frontend/packages/inspector/ +COPY sdks/cli-shared/package.json ./sdks/cli-shared/ +COPY sdks/acp-http-client/package.json ./sdks/acp-http-client/ +COPY sdks/persist-indexeddb/package.json ./sdks/persist-indexeddb/ +COPY sdks/react/package.json ./sdks/react/ +COPY sdks/typescript/package.json ./sdks/typescript/ + +RUN pnpm install --filter @sandbox-agent/inspector... + +COPY docs/openapi.json ./docs/ +COPY sdks/cli-shared ./sdks/cli-shared +COPY sdks/acp-http-client ./sdks/acp-http-client +COPY sdks/persist-indexeddb ./sdks/persist-indexeddb +COPY sdks/react ./sdks/react +COPY sdks/typescript ./sdks/typescript + +RUN cd sdks/cli-shared && pnpm exec tsup +RUN cd sdks/acp-http-client && pnpm exec tsup +RUN cd sdks/typescript && SKIP_OPENAPI_GEN=1 pnpm exec tsup +RUN cd sdks/persist-indexeddb && pnpm exec tsup +RUN cd sdks/react && pnpm exec tsup + +COPY frontend/packages/inspector ./frontend/packages/inspector +RUN cd frontend/packages/inspector && pnpm exec vite build + +# ============================================================================ +# AMD64 Builder - Uses cross-tools musl toolchain +# ============================================================================ +FROM --platform=linux/amd64 rust:1.88.0 AS builder-amd64 + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && apt-get install -y \ + musl-tools \ + musl-dev \ + llvm-14-dev \ + libclang-14-dev \ + clang-14 \ + libssl-dev \ + pkg-config \ + ca-certificates \ + g++ \ + g++-multilib \ + git \ + curl \ + wget && \ + rm -rf /var/lib/apt/lists/* + +RUN wget -q https://github.com/cross-tools/musl-cross/releases/latest/download/x86_64-unknown-linux-musl.tar.xz && \ + tar -xf x86_64-unknown-linux-musl.tar.xz -C /opt/ && \ + rm x86_64-unknown-linux-musl.tar.xz && \ + rustup target add x86_64-unknown-linux-musl + +ENV PATH="/opt/x86_64-unknown-linux-musl/bin:$PATH" \ + LIBCLANG_PATH=/usr/lib/llvm-14/lib \ + CLANG_PATH=/usr/bin/clang-14 \ + CC_x86_64_unknown_linux_musl=x86_64-unknown-linux-musl-gcc \ + CXX_x86_64_unknown_linux_musl=x86_64-unknown-linux-musl-g++ \ + AR_x86_64_unknown_linux_musl=x86_64-unknown-linux-musl-ar \ + CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER=x86_64-unknown-linux-musl-gcc \ + CARGO_INCREMENTAL=0 \ + CARGO_NET_GIT_FETCH_WITH_CLI=true + +ENV SSL_VER=1.1.1w +RUN wget https://www.openssl.org/source/openssl-$SSL_VER.tar.gz && \ + tar -xzf openssl-$SSL_VER.tar.gz && \ + cd openssl-$SSL_VER && \ + ./Configure no-shared no-async --prefix=/musl --openssldir=/musl/ssl linux-x86_64 && \ + make -j$(nproc) && \ + make install_sw && \ + cd .. && \ + rm -rf openssl-$SSL_VER* + +ENV OPENSSL_DIR=/musl \ + OPENSSL_INCLUDE_DIR=/musl/include \ + OPENSSL_LIB_DIR=/musl/lib \ + PKG_CONFIG_ALLOW_CROSS=1 \ + RUSTFLAGS="-C target-feature=+crt-static -C link-arg=-static-libgcc" + +WORKDIR /build +COPY . . + +COPY --from=inspector-build /app/frontend/packages/inspector/dist ./frontend/packages/inspector/dist + +RUN --mount=type=cache,target=/usr/local/cargo/registry \ + --mount=type=cache,target=/usr/local/cargo/git \ + --mount=type=cache,target=/build/target \ + cargo build -p sandbox-agent --release --target x86_64-unknown-linux-musl && \ + cp target/x86_64-unknown-linux-musl/release/sandbox-agent /sandbox-agent + +# ============================================================================ +# ARM64 Builder - Uses Alpine with native musl +# ============================================================================ +FROM --platform=linux/arm64 rust:1.88-alpine AS builder-arm64 + +RUN apk add --no-cache \ + musl-dev \ + clang \ + llvm-dev \ + openssl-dev \ + openssl-libs-static \ + pkgconfig \ + git \ + curl \ + build-base + +RUN rustup target add aarch64-unknown-linux-musl + +ENV CARGO_INCREMENTAL=0 \ + CARGO_NET_GIT_FETCH_WITH_CLI=true \ + RUSTFLAGS="-C target-feature=+crt-static" + +WORKDIR /build +COPY . . + +COPY --from=inspector-build /app/frontend/packages/inspector/dist ./frontend/packages/inspector/dist + +RUN --mount=type=cache,target=/usr/local/cargo/registry \ + --mount=type=cache,target=/usr/local/cargo/git \ + --mount=type=cache,target=/build/target \ + cargo build -p sandbox-agent --release --target aarch64-unknown-linux-musl && \ + cp target/aarch64-unknown-linux-musl/release/sandbox-agent /sandbox-agent + +# ============================================================================ +# Select the appropriate builder based on target architecture +# ============================================================================ +ARG TARGETARCH +FROM builder-${TARGETARCH} AS builder + +# Runtime stage - full image with all supported agents preinstalled +FROM node:22-bookworm-slim + +RUN apt-get update && apt-get install -y \ + bash \ + ca-certificates \ + curl \ + git && \ + rm -rf /var/lib/apt/lists/* + +COPY --from=builder /sandbox-agent /usr/local/bin/sandbox-agent +RUN chmod +x /usr/local/bin/sandbox-agent + +RUN useradd -m -s /bin/bash sandbox +USER sandbox +WORKDIR /home/sandbox + +RUN sandbox-agent install-agent --all + +EXPOSE 2468 + +ENTRYPOINT ["sandbox-agent"] +CMD ["server", "--host", "0.0.0.0", "--port", "2468"] diff --git a/docs/cli.mdx b/docs/cli.mdx index 22b041d..a3cd839 100644 --- a/docs/cli.mdx +++ b/docs/cli.mdx @@ -39,20 +39,24 @@ Notes: ## install-agent -Install or reinstall a single agent. +Install or reinstall a single agent, or every supported agent with `--all`. ```bash -sandbox-agent install-agent [OPTIONS] +sandbox-agent install-agent [] [OPTIONS] ``` | Option | Description | |--------|-------------| +| `--all` | Install every supported agent | | `-r, --reinstall` | Force reinstall | -| `--agent-version ` | Override agent package version | -| `--agent-process-version ` | Override agent process version | +| `--agent-version ` | Override agent package version (conflicts with `--all`) | +| `--agent-process-version ` | Override agent process version (conflicts with `--all`) | + +Examples: ```bash sandbox-agent install-agent claude --reinstall +sandbox-agent install-agent --all ``` ## opencode (experimental) diff --git a/docs/deploy/docker.mdx b/docs/deploy/docker.mdx index 988382a..030ddc9 100644 --- a/docs/deploy/docker.mdx +++ b/docs/deploy/docker.mdx @@ -9,18 +9,18 @@ Docker is not recommended for production isolation of untrusted workloads. Use d ## Quick start -Run Sandbox Agent with agents pre-installed: +Run the published full image with all supported agents pre-installed: ```bash docker run --rm -p 3000:3000 \ -e ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \ -e OPENAI_API_KEY="$OPENAI_API_KEY" \ - alpine:latest sh -c "\ - apk add --no-cache curl ca-certificates libstdc++ libgcc bash nodejs npm && \ - curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh && \ - sandbox-agent server --no-token --host 0.0.0.0 --port 3000" + rivetdev/sandbox-agent:0.3.1-full \ + server --no-token --host 0.0.0.0 --port 3000 ``` +The `0.3.1-full` tag pins the exact version. The moving `full` tag is also published for contributors who want the latest full image. + ## TypeScript with dockerode ```typescript @@ -31,14 +31,8 @@ const docker = new Docker(); const PORT = 3000; const container = await docker.createContainer({ - Image: "node:22-bookworm-slim", - Cmd: ["sh", "-c", [ - "apt-get update", - "DEBIAN_FRONTEND=noninteractive apt-get install -y curl ca-certificates bash libstdc++6", - "rm -rf /var/lib/apt/lists/*", - "curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh", - `sandbox-agent server --no-token --host 0.0.0.0 --port ${PORT}`, - ].join(" && ")], + Image: "rivetdev/sandbox-agent:0.3.1-full", + Cmd: ["server", "--no-token", "--host", "0.0.0.0", "--port", `${PORT}`], Env: [ `ANTHROPIC_API_KEY=${process.env.ANTHROPIC_API_KEY}`, `OPENAI_API_KEY=${process.env.OPENAI_API_KEY}`, @@ -60,6 +54,29 @@ const session = await sdk.createSession({ agent: "codex" }); await session.prompt([{ type: "text", text: "Summarize this repository." }]); ``` +## Building a custom image with everything preinstalled + +If you need to extend your own base image, install Sandbox Agent and preinstall every supported agent in one step: + +```dockerfile +FROM node:22-bookworm-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + bash ca-certificates curl git && \ + rm -rf /var/lib/apt/lists/* + +RUN curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh && \ + sandbox-agent install-agent --all + +RUN useradd -m -s /bin/bash sandbox +USER sandbox +WORKDIR /home/sandbox + +EXPOSE 2468 +ENTRYPOINT ["sandbox-agent"] +CMD ["server", "--host", "0.0.0.0", "--port", "2468"] +``` + ## Building from source ```bash diff --git a/docs/deploy/foundry-self-hosting.mdx b/docs/deploy/foundry-self-hosting.mdx index 04b0e9f..172d680 100644 --- a/docs/deploy/foundry-self-hosting.mdx +++ b/docs/deploy/foundry-self-hosting.mdx @@ -38,7 +38,7 @@ These values can be safely defaulted for local development: - `APP_URL=http://localhost:4173` - `BETTER_AUTH_URL=http://localhost:7741` - `BETTER_AUTH_SECRET=sandbox-agent-foundry-development-only-change-me` -- `GITHUB_REDIRECT_URI=http://localhost:7741/v1/auth/github/callback` +- `GITHUB_REDIRECT_URI=http://localhost:7741/v1/auth/callback/github` These should be treated as development-only values. diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx index 0654e61..a6293fe 100644 --- a/docs/quickstart.mdx +++ b/docs/quickstart.mdx @@ -61,9 +61,11 @@ icon: "rocket" ```bash - docker run -e ANTHROPIC_API_KEY="sk-ant-..." \ + docker run -p 2468:2468 \ + -e ANTHROPIC_API_KEY="sk-ant-..." \ -e OPENAI_API_KEY="sk-..." \ - your-image + rivetdev/sandbox-agent:0.3.1-full \ + server --no-token --host 0.0.0.0 --port 2468 ``` @@ -215,10 +217,7 @@ icon: "rocket" To preinstall agents: ```bash - sandbox-agent install-agent claude - sandbox-agent install-agent codex - sandbox-agent install-agent opencode - sandbox-agent install-agent amp + sandbox-agent install-agent --all ``` If agents are not installed up front, they are lazily installed when creating a session. diff --git a/examples/daytona/package.json b/examples/daytona/package.json index f105bac..ba5b0ac 100644 --- a/examples/daytona/package.json +++ b/examples/daytona/package.json @@ -4,7 +4,6 @@ "type": "module", "scripts": { "start": "tsx src/index.ts", - "start:snapshot": "tsx src/daytona-with-snapshot.ts", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/examples/daytona/src/daytona-with-snapshot.ts b/examples/daytona/src/daytona-with-snapshot.ts deleted file mode 100644 index 661d303..0000000 --- a/examples/daytona/src/daytona-with-snapshot.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Daytona, Image } from "@daytonaio/sdk"; -import { SandboxAgent } from "sandbox-agent"; -import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared"; - -const daytona = new Daytona(); - -const envVars: Record = {}; -if (process.env.ANTHROPIC_API_KEY) envVars.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; -if (process.env.OPENAI_API_KEY) envVars.OPENAI_API_KEY = process.env.OPENAI_API_KEY; - -// Build a custom image with sandbox-agent pre-installed (slower first run, faster subsequent runs) -const image = Image.base("ubuntu:22.04").runCommands( - "apt-get update && apt-get install -y curl ca-certificates", - "curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh", -); - -console.log("Creating Daytona sandbox (first run builds the base image and may take a few minutes, subsequent runs are fast)..."); -const sandbox = await daytona.create({ envVars, image, autoStopInterval: 0 }, { timeout: 180 }); - -await sandbox.process.executeCommand("nohup sandbox-agent server --no-token --host 0.0.0.0 --port 3000 >/tmp/sandbox-agent.log 2>&1 &"); - -const baseUrl = (await sandbox.getSignedPreviewUrl(3000, 4 * 60 * 60)).url; - -console.log("Connecting to server..."); -const client = await SandboxAgent.connect({ baseUrl }); -const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/home/daytona", mcpServers: [] } }); -const sessionId = session.id; - -console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`); -console.log(" Press Ctrl+C to stop."); - -const keepAlive = setInterval(() => {}, 60_000); -const cleanup = async () => { - clearInterval(keepAlive); - await sandbox.delete(60); - process.exit(0); -}; -process.once("SIGINT", cleanup); -process.once("SIGTERM", cleanup); diff --git a/examples/docker/src/index.ts b/examples/docker/src/index.ts index c6f29c2..74469f3 100644 --- a/examples/docker/src/index.ts +++ b/examples/docker/src/index.ts @@ -3,12 +3,13 @@ import fs from "node:fs"; import path from "node:path"; import { SandboxAgent } from "sandbox-agent"; import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared"; +import { FULL_IMAGE } from "@sandbox-agent/example-shared/docker"; -const IMAGE = "node:22-bookworm-slim"; +const IMAGE = FULL_IMAGE; const PORT = 3000; const agent = detectAgent(); const codexAuthPath = process.env.HOME ? path.join(process.env.HOME, ".codex", "auth.json") : null; -const bindMounts = codexAuthPath && fs.existsSync(codexAuthPath) ? [`${codexAuthPath}:/root/.codex/auth.json:ro`] : []; +const bindMounts = codexAuthPath && fs.existsSync(codexAuthPath) ? [`${codexAuthPath}:/home/sandbox/.codex/auth.json:ro`] : []; const docker = new Docker({ socketPath: "/var/run/docker.sock" }); @@ -28,17 +29,7 @@ try { console.log("Starting container..."); const container = await docker.createContainer({ Image: IMAGE, - Cmd: [ - "sh", - "-c", - [ - "apt-get update", - "DEBIAN_FRONTEND=noninteractive apt-get install -y curl ca-certificates bash libstdc++6", - "rm -rf /var/lib/apt/lists/*", - "curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh", - `sandbox-agent server --no-token --host 0.0.0.0 --port ${PORT}`, - ].join(" && "), - ], + Cmd: ["server", "--no-token", "--host", "0.0.0.0", "--port", `${PORT}`], Env: [ process.env.ANTHROPIC_API_KEY ? `ANTHROPIC_API_KEY=${process.env.ANTHROPIC_API_KEY}` : "", process.env.OPENAI_API_KEY ? `OPENAI_API_KEY=${process.env.OPENAI_API_KEY}` : "", @@ -56,7 +47,7 @@ await container.start(); const baseUrl = `http://127.0.0.1:${PORT}`; const client = await SandboxAgent.connect({ baseUrl }); -const session = await client.createSession({ agent, sessionInit: { cwd: "/root", mcpServers: [] } }); +const session = await client.createSession({ agent, sessionInit: { cwd: "/home/sandbox", mcpServers: [] } }); const sessionId = session.id; console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`); diff --git a/examples/persist-memory/src/index.ts b/examples/persist-memory/src/index.ts index e81ef06..2065a50 100644 --- a/examples/persist-memory/src/index.ts +++ b/examples/persist-memory/src/index.ts @@ -7,7 +7,6 @@ const persist = new InMemorySessionPersistDriver(); console.log("Starting sandbox..."); const sandbox = await startDockerSandbox({ port: 3000, - setupCommands: ["sandbox-agent install-agent claude", "sandbox-agent install-agent codex"], }); const sdk = await SandboxAgent.connect({ baseUrl: sandbox.baseUrl, persist }); diff --git a/examples/persist-postgres/src/index.ts b/examples/persist-postgres/src/index.ts index 5409705..73f9f04 100644 --- a/examples/persist-postgres/src/index.ts +++ b/examples/persist-postgres/src/index.ts @@ -66,7 +66,6 @@ try { console.log("Starting sandbox..."); const sandbox = await startDockerSandbox({ port: 3000, - setupCommands: ["sandbox-agent install-agent claude", "sandbox-agent install-agent codex"], }); const sdk = await SandboxAgent.connect({ baseUrl: sandbox.baseUrl, persist }); diff --git a/examples/persist-sqlite/src/index.ts b/examples/persist-sqlite/src/index.ts index 3b42550..d2c4ef2 100644 --- a/examples/persist-sqlite/src/index.ts +++ b/examples/persist-sqlite/src/index.ts @@ -8,7 +8,6 @@ const persist = new SQLiteSessionPersistDriver({ filename: "./sessions.db" }); console.log("Starting sandbox..."); const sandbox = await startDockerSandbox({ port: 3000, - setupCommands: ["sandbox-agent install-agent claude", "sandbox-agent install-agent codex"], }); const sdk = await SandboxAgent.connect({ baseUrl: sandbox.baseUrl, persist }); diff --git a/examples/shared/Dockerfile b/examples/shared/Dockerfile deleted file mode 100644 index 1a960d6..0000000 --- a/examples/shared/Dockerfile +++ /dev/null @@ -1,5 +0,0 @@ -FROM node:22-bookworm-slim -RUN apt-get update -qq && apt-get install -y -qq --no-install-recommends ca-certificates > /dev/null 2>&1 && \ - rm -rf /var/lib/apt/lists/* && \ - npm install -g --silent @sandbox-agent/cli@latest && \ - sandbox-agent install-agent claude diff --git a/examples/shared/Dockerfile.dev b/examples/shared/Dockerfile.dev deleted file mode 100644 index 53a9922..0000000 --- a/examples/shared/Dockerfile.dev +++ /dev/null @@ -1,63 +0,0 @@ -FROM node:22-bookworm-slim AS frontend -RUN corepack enable && corepack prepare pnpm@latest --activate -WORKDIR /build - -# Copy workspace root config -COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ - -# Copy packages needed for the inspector build chain: -# inspector -> sandbox-agent SDK -> acp-http-client, cli-shared, persist-indexeddb -COPY sdks/typescript/ sdks/typescript/ -COPY sdks/acp-http-client/ sdks/acp-http-client/ -COPY sdks/cli-shared/ sdks/cli-shared/ -COPY sdks/persist-indexeddb/ sdks/persist-indexeddb/ -COPY sdks/react/ sdks/react/ -COPY frontend/packages/inspector/ frontend/packages/inspector/ -COPY docs/openapi.json docs/ - -# Create stub package.json for workspace packages referenced in pnpm-workspace.yaml -# but not needed for the inspector build (avoids install errors). -RUN set -e; for dir in \ - sdks/cli sdks/gigacode \ - sdks/persist-postgres sdks/persist-sqlite sdks/persist-rivet \ - resources/agent-schemas resources/vercel-ai-sdk-schemas \ - scripts/release scripts/sandbox-testing \ - examples/shared examples/docker examples/e2b examples/vercel \ - examples/daytona examples/cloudflare examples/file-system \ - examples/mcp examples/mcp-custom-tool \ - examples/skills examples/skills-custom-tool \ - frontend/packages/website; do \ - mkdir -p "$dir"; \ - printf '{"name":"@stub/%s","private":true,"version":"0.0.0"}\n' "$(basename "$dir")" > "$dir/package.json"; \ - done; \ - for parent in sdks/cli/platforms sdks/gigacode/platforms; do \ - for plat in darwin-arm64 darwin-x64 linux-arm64 linux-x64 win32-x64; do \ - mkdir -p "$parent/$plat"; \ - printf '{"name":"@stub/%s-%s","private":true,"version":"0.0.0"}\n' "$(basename "$parent")" "$plat" > "$parent/$plat/package.json"; \ - done; \ - done - -RUN pnpm install --no-frozen-lockfile -ENV SKIP_OPENAPI_GEN=1 -RUN pnpm --filter sandbox-agent build && \ - pnpm --filter @sandbox-agent/inspector build - -FROM rust:1.88.0-bookworm AS builder -WORKDIR /build -COPY Cargo.toml Cargo.lock ./ -COPY server/ ./server/ -COPY gigacode/ ./gigacode/ -COPY resources/agent-schemas/artifacts/ ./resources/agent-schemas/artifacts/ -COPY scripts/agent-configs/ ./scripts/agent-configs/ -COPY --from=frontend /build/frontend/packages/inspector/dist/ ./frontend/packages/inspector/dist/ -RUN --mount=type=cache,target=/usr/local/cargo/registry \ - --mount=type=cache,target=/usr/local/cargo/git \ - --mount=type=cache,target=/build/target \ - cargo build -p sandbox-agent --release && \ - cp target/release/sandbox-agent /sandbox-agent - -FROM node:22-bookworm-slim -RUN apt-get update -qq && apt-get install -y -qq --no-install-recommends ca-certificates > /dev/null 2>&1 && \ - rm -rf /var/lib/apt/lists/* -COPY --from=builder /sandbox-agent /usr/local/bin/sandbox-agent -RUN sandbox-agent install-agent claude diff --git a/examples/shared/src/docker.ts b/examples/shared/src/docker.ts index 80c3916..2feca37 100644 --- a/examples/shared/src/docker.ts +++ b/examples/shared/src/docker.ts @@ -6,10 +6,10 @@ import { PassThrough } from "node:stream"; import { fileURLToPath } from "node:url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const EXAMPLE_IMAGE = "sandbox-agent-examples:latest"; -const EXAMPLE_IMAGE_DEV = "sandbox-agent-examples-dev:latest"; -const DOCKERFILE_DIR = path.resolve(__dirname, ".."); -const REPO_ROOT = path.resolve(DOCKERFILE_DIR, "../.."); +const REPO_ROOT = path.resolve(__dirname, "..", "..", ".."); + +/** Pre-built Docker image with all agents installed. */ +export const FULL_IMAGE = "rivetdev/sandbox-agent:0.3.1-full"; export interface DockerSandboxOptions { /** Container port used by sandbox-agent inside Docker. */ @@ -18,7 +18,7 @@ export interface DockerSandboxOptions { hostPort?: number; /** Additional shell commands to run before starting sandbox-agent. */ setupCommands?: string[]; - /** Docker image to use. Defaults to the pre-built sandbox-agent-examples image. */ + /** Docker image to use. Defaults to the pre-built full image. */ image?: string; } @@ -131,33 +131,31 @@ function stripAnsi(value: string): string { return value.replace(/[\u001B\u009B][[\]()#;?]*(?:(?:[a-zA-Z\d]*(?:;[a-zA-Z\d]*)*)?\u0007|(?:\d{1,4}(?:;\d{0,4})*)?[0-9A-ORZcf-nqry=><])/g, ""); } -async function ensureExampleImage(_docker: Docker): Promise { - const dev = !!process.env.SANDBOX_AGENT_DEV; - const imageName = dev ? EXAMPLE_IMAGE_DEV : EXAMPLE_IMAGE; - - if (dev) { - console.log(" Building sandbox image from source (may take a while, only runs once)..."); +async function ensureImage(docker: Docker, image: string): Promise { + if (process.env.SANDBOX_AGENT_DEV) { + console.log(" Building sandbox image from source (may take a while)..."); try { - execFileSync("docker", ["build", "-t", imageName, "-f", path.join(DOCKERFILE_DIR, "Dockerfile.dev"), REPO_ROOT], { - stdio: ["ignore", "ignore", "pipe"], - }); - } catch (err: unknown) { - const stderr = err instanceof Error && "stderr" in err ? String((err as { stderr: unknown }).stderr) : ""; - throw new Error(`Failed to build sandbox image: ${stderr}`); - } - } else { - console.log(" Building sandbox image (may take a while, only runs once)..."); - try { - execFileSync("docker", ["build", "-t", imageName, DOCKERFILE_DIR], { + execFileSync("docker", ["build", "-t", image, "-f", path.join(REPO_ROOT, "docker/runtime/Dockerfile.full"), REPO_ROOT], { stdio: ["ignore", "ignore", "pipe"], }); } catch (err: unknown) { const stderr = err instanceof Error && "stderr" in err ? String((err as { stderr: unknown }).stderr) : ""; throw new Error(`Failed to build sandbox image: ${stderr}`); } + return; } - return imageName; + try { + await docker.getImage(image).inspect(); + } catch { + console.log(` Pulling ${image}...`); + await new Promise((resolve, reject) => { + docker.pull(image, (err: Error | null, stream: NodeJS.ReadableStream) => { + if (err) return reject(err); + docker.modem.followProgress(stream, (err: Error | null) => (err ? reject(err) : resolve())); + }); + }); + } } /** @@ -166,8 +164,7 @@ async function ensureExampleImage(_docker: Docker): Promise { */ export async function startDockerSandbox(opts: DockerSandboxOptions): Promise { const { port, hostPort } = opts; - const useCustomImage = !!opts.image; - let image = opts.image ?? EXAMPLE_IMAGE; + const image = opts.image ?? FULL_IMAGE; // TODO: Replace setupCommands shell bootstrapping with native sandbox-agent exec API once available. const setupCommands = [...(opts.setupCommands ?? [])]; const credentialEnv = collectCredentialEnv(); @@ -197,27 +194,13 @@ export async function startDockerSandbox(opts: DockerSandboxOptions): Promise((resolve, reject) => { - docker.pull(image, (err: Error | null, stream: NodeJS.ReadableStream) => { - if (err) return reject(err); - docker.modem.followProgress(stream, (err: Error | null) => (err ? reject(err) : resolve())); - }); - }); - } - } else { - image = await ensureExampleImage(docker); - } + await ensureImage(docker, image); const bootCommands = [...setupCommands, `sandbox-agent server --no-token --host 0.0.0.0 --port ${port}`]; const container = await docker.createContainer({ Image: image, - WorkingDir: "/root", + WorkingDir: "/home/sandbox", Cmd: ["sh", "-c", bootCommands.join(" && ")], Env: [...Object.entries(credentialEnv).map(([key, value]) => `${key}=${value}`), ...Object.entries(bootstrapEnv).map(([key, value]) => `${key}=${value}`)], ExposedPorts: { [`${port}/tcp`]: {} }, diff --git a/foundry/CLAUDE.md b/foundry/CLAUDE.md index 074514f..8a57e02 100644 --- a/foundry/CLAUDE.md +++ b/foundry/CLAUDE.md @@ -31,16 +31,27 @@ Use `pnpm` workspaces and Turborepo. - Foundry is the canonical name for this product tree. Do not introduce or preserve legacy pre-Foundry naming in code, docs, commands, or runtime paths. - Install deps: `pnpm install` - Full active-workspace validation: `pnpm -w typecheck`, `pnpm -w build`, `pnpm -w test` -- Start the full dev stack: `just foundry-dev` +- Start the full dev stack (real backend + frontend): `just foundry-dev` — frontend on **port 4173**, backend on **port 7741** (Docker via `compose.dev.yaml`) +- Start the mock frontend stack (no backend): `just foundry-mock` — mock frontend on **port 4174** (Docker via `compose.mock.yaml`) - Start the local production-build preview stack: `just foundry-preview` - Start only the backend locally: `just foundry-backend-start` - Start only the frontend locally: `pnpm --filter @sandbox-agent/foundry-frontend dev` -- Start the frontend against the mock workbench client: `FOUNDRY_FRONTEND_CLIENT_MODE=mock pnpm --filter @sandbox-agent/foundry-frontend dev` +- Start the mock frontend locally (no Docker): `just foundry-dev-mock` — mock frontend on **port 4174** +- Dev and mock stacks can run simultaneously on different ports (4173 and 4174). - Stop the compose dev stack: `just foundry-dev-down` -- Tail compose logs: `just foundry-dev-logs` +- Tail compose dev logs: `just foundry-dev-logs` +- Stop the mock stack: `just foundry-mock-down` +- Tail mock logs: `just foundry-mock-logs` - Stop the preview stack: `just foundry-preview-down` - Tail preview logs: `just foundry-preview-logs` +## Dev Environment Setup + +- `compose.dev.yaml` loads `foundry/.env` (optional) for credentials needed by the backend (GitHub OAuth, Stripe, Daytona, API keys, etc.). +- The canonical source for these credentials is `~/misc/the-foundry.env`. If `foundry/.env` does not exist, copy it: `cp ~/misc/the-foundry.env foundry/.env` +- `foundry/.env` is gitignored and must never be committed. +- The backend does **not** hot reload. Bun's `--hot` flag causes the server to re-bind on a different port (e.g. 6421 instead of 6420), breaking all client connections while the container still exposes the original port. After backend code changes, restart the backend container: `just foundry-dev-down && just foundry-dev`. + ## Railway Logs - Production Foundry Railway logs can be read from a linked workspace with `railway logs --deployment --lines 200` or `railway logs --deployment --lines 200`. @@ -65,6 +76,69 @@ Use `pnpm` workspaces and Turborepo. - When asked for screenshots, capture all relevant affected screens and modal states, not just a single viewport. Include empty, populated, success, and blocked/error states when they are part of the changed flow. - If a screenshot catches a transition frame, blank modal, or otherwise misleading state, retake it before reporting it. +## Realtime Data Architecture + +### Core pattern: fetch initial state + subscribe to deltas + +All client data flows follow the same pattern: + +1. **Connect** to the actor via WebSocket. +2. **Fetch initial state** via an action call to get the current materialized snapshot. +3. **Subscribe to events** on the connection. Events carry **full replacement payloads** for the changed entity (not empty notifications, not patches — the complete new state of the thing that changed). +4. **Unsubscribe** after a 30-second grace period when interest ends (screen navigation, component unmount). The grace period prevents thrashing during screen transitions and React double-renders. + +Do not use polling (`refetchInterval`), empty "go re-fetch" broadcast events, or full-snapshot re-fetches on every mutation. Every mutation broadcasts the new absolute state of the changed entity to connected clients. + +### Materialized state in coordinator actors + +- **Workspace actor** materializes sidebar-level data in its own SQLite: repo catalog, task summaries (title, status, branch, PR, updatedAt), repo summaries (overview/branch state), and session summaries (id, name, status, unread, model — no transcript). Task actors push summary changes to the workspace actor when they mutate. The workspace actor broadcasts the updated entity to connected clients. `getWorkspaceSummary` reads from local tables only — no fan-out to child actors. +- **Task actor** materializes its own detail state (session summaries, sandbox info, diffs, file tree). `getTaskDetail` reads from the task actor's own SQLite. The task actor broadcasts updates directly to clients connected to it. +- **Session data** lives on the task actor but is a separate subscription topic. The task topic includes `sessions_summary` (list without content). The `session` topic provides full transcript and draft state. Clients subscribe to the `session` topic for whichever session tab is active, and filter `sessionUpdated` events by session ID (ignoring events for other sessions on the same actor). +- The expensive fan-out (querying every project/task actor) only exists as a background reconciliation/rebuild path, never on the hot read path. + +### Interest manager + +The interest manager (`packages/client`) is a global singleton that manages WebSocket connections, cached state, and subscriptions for all topics. It: + +- **Deduplicates** — multiple subscribers to the same topic share one connection and one cached state. +- **Grace period (30s)** — when the last subscriber leaves, the connection and state stay alive for 30 seconds before teardown. This keeps data warm for back-navigation and prevents thrashing. +- **Exposes a single hook** — `useInterest(topicKey, params)` returns `{ data, status, error }`. Null params = no subscription (conditional interest). +- **Shared harness, separate implementations** — the `InterestManager` interface is shared between mock and remote implementations. The mock implementation uses in-memory state. The remote implementation uses WebSocket connections. The API/client exposure is identical for both. + +### Topics + +Each topic maps to one actor connection and one event stream: + +| Topic | Actor | Event | Data | +|---|---|---|---| +| `app` | Workspace `"app"` | `appUpdated` | Auth, orgs, onboarding | +| `workspace` | Workspace `{workspaceId}` | `workspaceUpdated` | Repo catalog, task summaries, repo summaries | +| `task` | Task `{workspaceId, repoId, taskId}` | `taskUpdated` | Session summaries, sandbox info, diffs, file tree | +| `session` | Task `{workspaceId, repoId, taskId}` (filtered by sessionId) | `sessionUpdated` | Transcript, draft state | +| `sandboxProcesses` | SandboxInstance | `processesUpdated` | Process list | + +The client subscribes to `app` always, `workspace` when entering a workspace, `task` when viewing a task, and `session` when viewing a specific session tab. At most 4 actor connections at a time (app + workspace + task + sandbox if terminal is open). The `session` topic reuses the task actor connection and filters by session ID. + +### Rules + +- Do not add `useQuery` with `refetchInterval` for data that should be push-based. +- Do not broadcast empty notification events. Events must carry the full new state of the changed entity. +- Do not re-fetch full snapshots after mutations. The mutation triggers a server-side broadcast with the new entity state; the client replaces it in local state. +- All event subscriptions go through the interest manager. Do not create ad-hoc `handle.connect()` + `conn.on()` patterns. +- Backend mutations that affect sidebar data (task title, status, branch, PR state) must push the updated summary to the parent workspace actor, which broadcasts to workspace subscribers. +- Comment architecture-related code: add doc comments explaining the materialized state pattern, why deltas flow the way they do, and the relationship between parent/child actor broadcasts. New contributors should understand the data flow from comments alone. + +## UI System + +- Foundry's base UI system is `BaseUI` with `Styletron`, plus Foundry-specific theme/tokens on top. Treat that as the default UI foundation. +- The full `BaseUI` reference for available components and guidance on animations, customization, composition, and forms is at `https://base-ui.com/llms.txt`. +- Prefer existing `BaseUI` components and composition patterns whenever possible instead of building custom controls from scratch. +- Reuse the established Foundry theme/token layer for colors, typography, spacing, and surfaces instead of introducing ad hoc visual values. +- If the same UI pattern is shared with the Inspector or other consumers, prefer extracting or reusing it through `@sandbox-agent/react` rather than duplicating it in Foundry. +- If a requested UI cannot be implemented cleanly with an existing `BaseUI` component, stop and ask the user whether they are sure they want to diverge from the system. +- In that case, recommend the closest existing `BaseUI` components or compositions that could satisfy the need before proposing custom UI work. +- Only introduce custom UI primitives when `BaseUI` and existing Foundry patterns are not sufficient, or when the user explicitly confirms they want the divergence. + ## Runtime Policy - Runtime is Bun-native. @@ -122,11 +196,13 @@ For all Rivet/RivetKit implementation: - Do not build blocking flows that wait on external systems to become ready or complete. Prefer push-based progression driven by actor messages, events, webhooks, or queue/workflow state changes. - Use workflows/background commands for any repo sync, sandbox provisioning, agent install, branch restack/rebase, or other multi-step external work. Do not keep user-facing actions/requests open while that work runs. - `send` policy: always `await` the `send(...)` call itself so enqueue failures surface immediately, but default to `wait: false`. -- Only use `send(..., { wait: true })` for short, bounded mutations that should finish quickly and do not depend on external readiness, polling actors, provider setup, repo/network I/O, or long-running queue drains. +- Only use `send(..., { wait: true })` for short, bounded local mutations (e.g. a DB write that returns a result the caller needs). Never use `wait: true` for operations that depend on external readiness, polling actors, provider setup, repo/network I/O, sandbox sessions, GitHub API calls, or long-running queue drains. +- Never self-send with `wait: true` from inside a workflow handler — the workflow processes one message at a time, so the handler would deadlock waiting for the new message to be dequeued. +- When an action is void-returning and triggers external work, use `wait: false` and let the UI react to state changes pushed by the workflow. - Request/action contract: wait only until the minimum resource needed for the client's next step exists. Example: task creation may wait for task actor creation/identity, but not for sandbox provisioning or session bootstrap. - Read paths must not force refresh/sync work inline. Serve the latest cached projection, mark staleness explicitly, and trigger background refresh separately when needed. - If a workflow needs to resume after some external work completes, model that as workflow state plus follow-up messages/events instead of holding the original request open. -- Do not rely on retries for correctness or normal control flow. If a queue/workflow/external dependency is not ready yet, model that explicitly and resume from a push/event, instead of polling or retry loops. +- No retries: never add retry loops (`withRetries`, `setTimeout` retry, exponential backoff) anywhere in the codebase. If an operation fails, surface the error immediately. If a dependency is not ready yet, model that explicitly with workflow state and resume from a push/event instead of polling or retry loops. - Actor handle policy: - Prefer explicit `get` or explicit `create` based on workflow intent; do not default to `getOrCreate`. - Use `get`/`getForId` when the actor is expected to already exist; if missing, surface an explicit `Actor not found` error with recovery context. diff --git a/foundry/compose.dev.yaml b/foundry/compose.dev.yaml index 01c6934..416f6c4 100644 --- a/foundry/compose.dev.yaml +++ b/foundry/compose.dev.yaml @@ -7,6 +7,9 @@ services: dockerfile: foundry/docker/backend.dev.Dockerfile image: foundry-backend-dev working_dir: /app + env_file: + - path: .env + required: false environment: HF_BACKEND_HOST: "0.0.0.0" HF_BACKEND_PORT: "7741" @@ -41,6 +44,7 @@ services: HF_DAYTONA_ENDPOINT: "${HF_DAYTONA_ENDPOINT:-}" HF_DAYTONA_API_KEY: "${HF_DAYTONA_API_KEY:-}" ports: + - "6420:6420" - "7741:7741" volumes: - "..:/app" diff --git a/foundry/compose.mock.yaml b/foundry/compose.mock.yaml new file mode 100644 index 0000000..ffe560c --- /dev/null +++ b/foundry/compose.mock.yaml @@ -0,0 +1,32 @@ +name: foundry-mock + +services: + frontend: + build: + context: .. + dockerfile: foundry/docker/frontend.dev.Dockerfile + working_dir: /app + environment: + HOME: "/tmp" + FOUNDRY_FRONTEND_CLIENT_MODE: "mock" + ports: + - "4174:4174" + command: ["bash", "-lc", "pnpm install --force --frozen-lockfile --filter @sandbox-agent/foundry-frontend... && cd foundry/packages/frontend && exec pnpm vite --host 0.0.0.0 --port 4174"] + volumes: + - "..:/app" + - "./.foundry:/app/foundry/.foundry" + - "../../../task/rivet-checkout:/task/rivet-checkout:ro" + - "mock_node_modules:/app/node_modules" + - "mock_client_node_modules:/app/foundry/packages/client/node_modules" + - "mock_frontend_errors_node_modules:/app/foundry/packages/frontend-errors/node_modules" + - "mock_frontend_node_modules:/app/foundry/packages/frontend/node_modules" + - "mock_shared_node_modules:/app/foundry/packages/shared/node_modules" + - "mock_pnpm_store:/tmp/.local/share/pnpm/store" + +volumes: + mock_node_modules: {} + mock_client_node_modules: {} + mock_frontend_errors_node_modules: {} + mock_frontend_node_modules: {} + mock_shared_node_modules: {} + mock_pnpm_store: {} diff --git a/foundry/docker/backend.dev.Dockerfile b/foundry/docker/backend.dev.Dockerfile index 3a0697d..0182aa5 100644 --- a/foundry/docker/backend.dev.Dockerfile +++ b/foundry/docker/backend.dev.Dockerfile @@ -39,4 +39,8 @@ ENV SANDBOX_AGENT_BIN="/root/.local/bin/sandbox-agent" WORKDIR /app -CMD ["bash", "-lc", "git config --global --add safe.directory /app >/dev/null 2>&1 || true; pnpm install --force --frozen-lockfile --filter @sandbox-agent/foundry-backend... && exec bun foundry/packages/backend/src/index.ts start --host 0.0.0.0 --port 7741"] +# NOTE: Do NOT use `bun --hot` here. Bun's hot reloading re-initializes the +# server on a new port (e.g. 6421 instead of 6420) while the container still +# exposes the original port, breaking all client connections. Restart the +# backend container instead: `just foundry-dev-down && just foundry-dev` +CMD ["bash", "-lc", "git config --global --add safe.directory /app >/dev/null 2>&1 || true; pnpm install --frozen-lockfile --filter @sandbox-agent/foundry-backend... && exec bun foundry/packages/backend/src/index.ts start --host 0.0.0.0 --port 7741"] diff --git a/foundry/packages/backend/package.json b/foundry/packages/backend/package.json index 4f65032..aec80a0 100644 --- a/foundry/packages/backend/package.json +++ b/foundry/packages/backend/package.json @@ -19,6 +19,7 @@ "@iarna/toml": "^2.2.5", "@sandbox-agent/foundry-shared": "workspace:*", "@sandbox-agent/persist-rivet": "workspace:*", + "better-auth": "^1.5.5", "drizzle-kit": "^0.31.8", "drizzle-orm": "^0.44.5", "hono": "^4.11.9", diff --git a/foundry/packages/backend/src/actors/auth-user/db/db.ts b/foundry/packages/backend/src/actors/auth-user/db/db.ts new file mode 100644 index 0000000..b434338 --- /dev/null +++ b/foundry/packages/backend/src/actors/auth-user/db/db.ts @@ -0,0 +1,5 @@ +import { db } from "rivetkit/db/drizzle"; +import * as schema from "./schema.js"; +import migrations from "./migrations.js"; + +export const authUserDb = db({ schema, migrations }); diff --git a/foundry/packages/backend/src/actors/auth-user/db/migrations.ts b/foundry/packages/backend/src/actors/auth-user/db/migrations.ts new file mode 100644 index 0000000..be7cb17 --- /dev/null +++ b/foundry/packages/backend/src/actors/auth-user/db/migrations.ts @@ -0,0 +1,80 @@ +// This file is generated by src/actors/_scripts/generate-actor-migrations.ts. +// Source of truth is drizzle-kit output under ./drizzle (meta/_journal.json + *.sql). +// Do not hand-edit this file. + +const journal = { + entries: [ + { + idx: 0, + when: 1773446400000, + tag: "0000_auth_user", + breakpoints: true, + }, + ], +} as const; + +export default { + journal, + migrations: { + m0000: `CREATE TABLE \`user\` ( + \`id\` text PRIMARY KEY NOT NULL, + \`name\` text NOT NULL, + \`email\` text NOT NULL, + \`email_verified\` integer NOT NULL, + \`image\` text, + \`created_at\` integer NOT NULL, + \`updated_at\` integer NOT NULL +); +--> statement-breakpoint +CREATE TABLE \`session\` ( + \`id\` text PRIMARY KEY NOT NULL, + \`token\` text NOT NULL, + \`user_id\` text NOT NULL, + \`expires_at\` integer NOT NULL, + \`ip_address\` text, + \`user_agent\` text, + \`created_at\` integer NOT NULL, + \`updated_at\` integer NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX \`session_token_idx\` ON \`session\` (\`token\`); +--> statement-breakpoint +CREATE TABLE \`account\` ( + \`id\` text PRIMARY KEY NOT NULL, + \`account_id\` text NOT NULL, + \`provider_id\` text NOT NULL, + \`user_id\` text NOT NULL, + \`access_token\` text, + \`refresh_token\` text, + \`id_token\` text, + \`access_token_expires_at\` integer, + \`refresh_token_expires_at\` integer, + \`scope\` text, + \`password\` text, + \`created_at\` integer NOT NULL, + \`updated_at\` integer NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX \`account_provider_account_idx\` ON \`account\` (\`provider_id\`, \`account_id\`); +--> statement-breakpoint +CREATE TABLE \`user_profiles\` ( + \`user_id\` text PRIMARY KEY NOT NULL, + \`github_account_id\` text, + \`github_login\` text, + \`role_label\` text NOT NULL, + \`eligible_organization_ids_json\` text NOT NULL, + \`starter_repo_status\` text NOT NULL, + \`starter_repo_starred_at\` integer, + \`starter_repo_skipped_at\` integer, + \`created_at\` integer NOT NULL, + \`updated_at\` integer NOT NULL +); +--> statement-breakpoint +CREATE TABLE \`session_state\` ( + \`session_id\` text PRIMARY KEY NOT NULL, + \`active_organization_id\` text, + \`created_at\` integer NOT NULL, + \`updated_at\` integer NOT NULL +);`, + } as const, +}; diff --git a/foundry/packages/backend/src/actors/auth-user/db/schema.ts b/foundry/packages/backend/src/actors/auth-user/db/schema.ts new file mode 100644 index 0000000..b87567a --- /dev/null +++ b/foundry/packages/backend/src/actors/auth-user/db/schema.ts @@ -0,0 +1,70 @@ +import { integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"; + +export const authUsers = sqliteTable("user", { + id: text("id").notNull().primaryKey(), + name: text("name").notNull(), + email: text("email").notNull(), + emailVerified: integer("email_verified").notNull(), + image: text("image"), + createdAt: integer("created_at").notNull(), + updatedAt: integer("updated_at").notNull(), +}); + +export const authSessions = sqliteTable( + "session", + { + id: text("id").notNull().primaryKey(), + token: text("token").notNull(), + userId: text("user_id").notNull(), + expiresAt: integer("expires_at").notNull(), + ipAddress: text("ip_address"), + userAgent: text("user_agent"), + createdAt: integer("created_at").notNull(), + updatedAt: integer("updated_at").notNull(), + }, + (table) => ({ + tokenIdx: uniqueIndex("session_token_idx").on(table.token), + }), +); + +export const authAccounts = sqliteTable( + "account", + { + id: text("id").notNull().primaryKey(), + accountId: text("account_id").notNull(), + providerId: text("provider_id").notNull(), + userId: text("user_id").notNull(), + accessToken: text("access_token"), + refreshToken: text("refresh_token"), + idToken: text("id_token"), + accessTokenExpiresAt: integer("access_token_expires_at"), + refreshTokenExpiresAt: integer("refresh_token_expires_at"), + scope: text("scope"), + password: text("password"), + createdAt: integer("created_at").notNull(), + updatedAt: integer("updated_at").notNull(), + }, + (table) => ({ + providerAccountIdx: uniqueIndex("account_provider_account_idx").on(table.providerId, table.accountId), + }), +); + +export const userProfiles = sqliteTable("user_profiles", { + userId: text("user_id").notNull().primaryKey(), + githubAccountId: text("github_account_id"), + githubLogin: text("github_login"), + roleLabel: text("role_label").notNull(), + eligibleOrganizationIdsJson: text("eligible_organization_ids_json").notNull(), + starterRepoStatus: text("starter_repo_status").notNull(), + starterRepoStarredAt: integer("starter_repo_starred_at"), + starterRepoSkippedAt: integer("starter_repo_skipped_at"), + createdAt: integer("created_at").notNull(), + updatedAt: integer("updated_at").notNull(), +}); + +export const sessionState = sqliteTable("session_state", { + sessionId: text("session_id").notNull().primaryKey(), + activeOrganizationId: text("active_organization_id"), + createdAt: integer("created_at").notNull(), + updatedAt: integer("updated_at").notNull(), +}); diff --git a/foundry/packages/backend/src/actors/auth-user/index.ts b/foundry/packages/backend/src/actors/auth-user/index.ts new file mode 100644 index 0000000..a77635a --- /dev/null +++ b/foundry/packages/backend/src/actors/auth-user/index.ts @@ -0,0 +1,353 @@ +import { and, asc, count as sqlCount, desc, eq, gt, gte, inArray, isNotNull, isNull, like, lt, lte, ne, notInArray, or } from "drizzle-orm"; +import { actor } from "rivetkit"; +import { authUserDb } from "./db/db.js"; +import { authAccounts, authSessions, authUsers, sessionState, userProfiles } from "./db/schema.js"; + +const tables = { + user: authUsers, + session: authSessions, + account: authAccounts, + userProfiles, + sessionState, +} as const; + +function tableFor(model: string) { + const table = tables[model as keyof typeof tables]; + if (!table) { + throw new Error(`Unsupported auth user model: ${model}`); + } + return table as any; +} + +function columnFor(table: any, field: string) { + const column = table[field]; + if (!column) { + throw new Error(`Unsupported auth user field: ${field}`); + } + return column; +} + +function normalizeValue(value: unknown): unknown { + if (value instanceof Date) { + return value.getTime(); + } + if (Array.isArray(value)) { + return value.map((entry) => normalizeValue(entry)); + } + return value; +} + +function clauseToExpr(table: any, clause: any) { + const column = columnFor(table, clause.field); + const value = normalizeValue(clause.value); + + switch (clause.operator) { + case "ne": + return value === null ? isNotNull(column) : ne(column, value as any); + case "lt": + return lt(column, value as any); + case "lte": + return lte(column, value as any); + case "gt": + return gt(column, value as any); + case "gte": + return gte(column, value as any); + case "in": + return inArray(column, Array.isArray(value) ? (value as any[]) : [value as any]); + case "not_in": + return notInArray(column, Array.isArray(value) ? (value as any[]) : [value as any]); + case "contains": + return like(column, `%${String(value ?? "")}%`); + case "starts_with": + return like(column, `${String(value ?? "")}%`); + case "ends_with": + return like(column, `%${String(value ?? "")}`); + case "eq": + default: + return value === null ? isNull(column) : eq(column, value as any); + } +} + +function buildWhere(table: any, where: any[] | undefined) { + if (!where || where.length === 0) { + return undefined; + } + + let expr = clauseToExpr(table, where[0]); + for (const clause of where.slice(1)) { + const next = clauseToExpr(table, clause); + expr = clause.connector === "OR" ? or(expr, next) : and(expr, next); + } + return expr; +} + +function applyJoinToRow(c: any, model: string, row: any, join: any) { + if (!row || !join) { + return row; + } + + if (model === "session" && join.user) { + return c.db + .select() + .from(authUsers) + .where(eq(authUsers.id, row.userId)) + .get() + .then((user: any) => ({ ...row, user: user ?? null })); + } + + if (model === "account" && join.user) { + return c.db + .select() + .from(authUsers) + .where(eq(authUsers.id, row.userId)) + .get() + .then((user: any) => ({ ...row, user: user ?? null })); + } + + if (model === "user" && join.account) { + return c.db + .select() + .from(authAccounts) + .where(eq(authAccounts.userId, row.id)) + .all() + .then((accounts: any[]) => ({ ...row, account: accounts })); + } + + return Promise.resolve(row); +} + +async function applyJoinToRows(c: any, model: string, rows: any[], join: any) { + if (!join || rows.length === 0) { + return rows; + } + + if (model === "session" && join.user) { + const userIds = [...new Set(rows.map((row) => row.userId).filter(Boolean))]; + const users = userIds.length > 0 ? await c.db.select().from(authUsers).where(inArray(authUsers.id, userIds)).all() : []; + const userMap = new Map(users.map((user: any) => [user.id, user])); + return rows.map((row) => ({ ...row, user: userMap.get(row.userId) ?? null })); + } + + if (model === "account" && join.user) { + const userIds = [...new Set(rows.map((row) => row.userId).filter(Boolean))]; + const users = userIds.length > 0 ? await c.db.select().from(authUsers).where(inArray(authUsers.id, userIds)).all() : []; + const userMap = new Map(users.map((user: any) => [user.id, user])); + return rows.map((row) => ({ ...row, user: userMap.get(row.userId) ?? null })); + } + + if (model === "user" && join.account) { + const userIds = rows.map((row) => row.id); + const accounts = userIds.length > 0 ? await c.db.select().from(authAccounts).where(inArray(authAccounts.userId, userIds)).all() : []; + const accountsByUserId = new Map(); + for (const account of accounts) { + const entries = accountsByUserId.get(account.userId) ?? []; + entries.push(account); + accountsByUserId.set(account.userId, entries); + } + return rows.map((row) => ({ ...row, account: accountsByUserId.get(row.id) ?? [] })); + } + + return rows; +} + +export const authUser = actor({ + db: authUserDb, + options: { + name: "Auth User", + icon: "shield", + actionTimeout: 60_000, + }, + createState: (_c, input: { userId: string }) => ({ + userId: input.userId, + }), + actions: { + async createAuthRecord(c, input: { model: string; data: Record }) { + const table = tableFor(input.model); + await c.db + .insert(table) + .values(input.data as any) + .run(); + return await c.db + .select() + .from(table) + .where(eq(columnFor(table, "id"), input.data.id as any)) + .get(); + }, + + async findOneAuthRecord(c, input: { model: string; where: any[]; join?: any }) { + const table = tableFor(input.model); + const predicate = buildWhere(table, input.where); + const row = predicate ? await c.db.select().from(table).where(predicate).get() : await c.db.select().from(table).get(); + return await applyJoinToRow(c, input.model, row ?? null, input.join); + }, + + async findManyAuthRecords(c, input: { model: string; where?: any[]; limit?: number; offset?: number; sortBy?: any; join?: any }) { + const table = tableFor(input.model); + const predicate = buildWhere(table, input.where); + let query: any = c.db.select().from(table); + if (predicate) { + query = query.where(predicate); + } + if (input.sortBy?.field) { + const column = columnFor(table, input.sortBy.field); + query = query.orderBy(input.sortBy.direction === "asc" ? asc(column) : desc(column)); + } + if (typeof input.limit === "number") { + query = query.limit(input.limit); + } + if (typeof input.offset === "number") { + query = query.offset(input.offset); + } + const rows = await query.all(); + return await applyJoinToRows(c, input.model, rows, input.join); + }, + + async updateAuthRecord(c, input: { model: string; where: any[]; update: Record }) { + const table = tableFor(input.model); + const predicate = buildWhere(table, input.where); + if (!predicate) { + throw new Error("updateAuthRecord requires a where clause"); + } + await c.db + .update(table) + .set(input.update as any) + .where(predicate) + .run(); + return await c.db.select().from(table).where(predicate).get(); + }, + + async updateManyAuthRecords(c, input: { model: string; where: any[]; update: Record }) { + const table = tableFor(input.model); + const predicate = buildWhere(table, input.where); + if (!predicate) { + throw new Error("updateManyAuthRecords requires a where clause"); + } + await c.db + .update(table) + .set(input.update as any) + .where(predicate) + .run(); + const row = await c.db.select({ value: sqlCount() }).from(table).where(predicate).get(); + return row?.value ?? 0; + }, + + async deleteAuthRecord(c, input: { model: string; where: any[] }) { + const table = tableFor(input.model); + const predicate = buildWhere(table, input.where); + if (!predicate) { + throw new Error("deleteAuthRecord requires a where clause"); + } + await c.db.delete(table).where(predicate).run(); + }, + + async deleteManyAuthRecords(c, input: { model: string; where: any[] }) { + const table = tableFor(input.model); + const predicate = buildWhere(table, input.where); + if (!predicate) { + throw new Error("deleteManyAuthRecords requires a where clause"); + } + const rows = await c.db.select().from(table).where(predicate).all(); + await c.db.delete(table).where(predicate).run(); + return rows.length; + }, + + async countAuthRecords(c, input: { model: string; where?: any[] }) { + const table = tableFor(input.model); + const predicate = buildWhere(table, input.where); + const row = predicate + ? await c.db.select({ value: sqlCount() }).from(table).where(predicate).get() + : await c.db.select({ value: sqlCount() }).from(table).get(); + return row?.value ?? 0; + }, + + async getAppAuthState(c, input: { sessionId: string }) { + const session = await c.db.select().from(authSessions).where(eq(authSessions.id, input.sessionId)).get(); + if (!session) { + return null; + } + const [user, profile, currentSessionState, accounts] = await Promise.all([ + c.db.select().from(authUsers).where(eq(authUsers.id, session.userId)).get(), + c.db.select().from(userProfiles).where(eq(userProfiles.userId, session.userId)).get(), + c.db.select().from(sessionState).where(eq(sessionState.sessionId, input.sessionId)).get(), + c.db.select().from(authAccounts).where(eq(authAccounts.userId, session.userId)).all(), + ]); + return { + session, + user, + profile: profile ?? null, + sessionState: currentSessionState ?? null, + accounts, + }; + }, + + async upsertUserProfile( + c, + input: { + userId: string; + patch: { + githubAccountId?: string | null; + githubLogin?: string | null; + roleLabel?: string; + eligibleOrganizationIdsJson?: string; + starterRepoStatus?: string; + starterRepoStarredAt?: number | null; + starterRepoSkippedAt?: number | null; + }; + }, + ) { + const now = Date.now(); + await c.db + .insert(userProfiles) + .values({ + userId: input.userId, + githubAccountId: input.patch.githubAccountId ?? null, + githubLogin: input.patch.githubLogin ?? null, + roleLabel: input.patch.roleLabel ?? "GitHub user", + eligibleOrganizationIdsJson: input.patch.eligibleOrganizationIdsJson ?? "[]", + starterRepoStatus: input.patch.starterRepoStatus ?? "pending", + starterRepoStarredAt: input.patch.starterRepoStarredAt ?? null, + starterRepoSkippedAt: input.patch.starterRepoSkippedAt ?? null, + createdAt: now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: userProfiles.userId, + set: { + ...(input.patch.githubAccountId !== undefined ? { githubAccountId: input.patch.githubAccountId } : {}), + ...(input.patch.githubLogin !== undefined ? { githubLogin: input.patch.githubLogin } : {}), + ...(input.patch.roleLabel !== undefined ? { roleLabel: input.patch.roleLabel } : {}), + ...(input.patch.eligibleOrganizationIdsJson !== undefined ? { eligibleOrganizationIdsJson: input.patch.eligibleOrganizationIdsJson } : {}), + ...(input.patch.starterRepoStatus !== undefined ? { starterRepoStatus: input.patch.starterRepoStatus } : {}), + ...(input.patch.starterRepoStarredAt !== undefined ? { starterRepoStarredAt: input.patch.starterRepoStarredAt } : {}), + ...(input.patch.starterRepoSkippedAt !== undefined ? { starterRepoSkippedAt: input.patch.starterRepoSkippedAt } : {}), + updatedAt: now, + }, + }) + .run(); + + return await c.db.select().from(userProfiles).where(eq(userProfiles.userId, input.userId)).get(); + }, + + async upsertSessionState(c, input: { sessionId: string; activeOrganizationId: string | null }) { + const now = Date.now(); + await c.db + .insert(sessionState) + .values({ + sessionId: input.sessionId, + activeOrganizationId: input.activeOrganizationId, + createdAt: now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: sessionState.sessionId, + set: { + activeOrganizationId: input.activeOrganizationId, + updatedAt: now, + }, + }) + .run(); + + return await c.db.select().from(sessionState).where(eq(sessionState.sessionId, input.sessionId)).get(); + }, + }, +}); diff --git a/foundry/packages/backend/src/actors/handles.ts b/foundry/packages/backend/src/actors/handles.ts index 228ce8c..02de614 100644 --- a/foundry/packages/backend/src/actors/handles.ts +++ b/foundry/packages/backend/src/actors/handles.ts @@ -1,4 +1,14 @@ -import { taskKey, taskStatusSyncKey, historyKey, projectBranchSyncKey, projectKey, projectPrSyncKey, sandboxInstanceKey, workspaceKey } from "./keys.js"; +import { + authUserKey, + taskKey, + taskStatusSyncKey, + historyKey, + projectBranchSyncKey, + projectKey, + projectPrSyncKey, + sandboxInstanceKey, + workspaceKey, +} from "./keys.js"; import type { ProviderId } from "@sandbox-agent/foundry-shared"; export function actorClient(c: any) { @@ -11,6 +21,16 @@ export async function getOrCreateWorkspace(c: any, workspaceId: string) { }); } +export async function getOrCreateAuthUser(c: any, userId: string) { + return await actorClient(c).authUser.getOrCreate(authUserKey(userId), { + createWithInput: { userId }, + }); +} + +export function getAuthUser(c: any, userId: string) { + return actorClient(c).authUser.get(authUserKey(userId)); +} + export async function getOrCreateProject(c: any, workspaceId: string, repoId: string, remoteUrl: string) { return await actorClient(c).project.getOrCreate(projectKey(workspaceId, repoId), { createWithInput: { @@ -125,3 +145,7 @@ export function selfProject(c: any) { export function selfSandboxInstance(c: any) { return actorClient(c).sandboxInstance.getForId(c.actorId); } + +export function selfAuthUser(c: any) { + return actorClient(c).authUser.getForId(c.actorId); +} diff --git a/foundry/packages/backend/src/actors/index.ts b/foundry/packages/backend/src/actors/index.ts index ca0f9b4..245b6a4 100644 --- a/foundry/packages/backend/src/actors/index.ts +++ b/foundry/packages/backend/src/actors/index.ts @@ -1,3 +1,4 @@ +import { authUser } from "./auth-user/index.js"; import { setup } from "rivetkit"; import { taskStatusSync } from "./task-status-sync/index.js"; import { task } from "./task/index.js"; @@ -22,6 +23,7 @@ export const registry = setup({ baseLogger: logger, }, use: { + authUser, workspace, project, task, @@ -35,6 +37,7 @@ export const registry = setup({ export * from "./context.js"; export * from "./events.js"; +export * from "./auth-user/index.js"; export * from "./task-status-sync/index.js"; export * from "./task/index.js"; export * from "./history/index.js"; diff --git a/foundry/packages/backend/src/actors/keys.ts b/foundry/packages/backend/src/actors/keys.ts index f6b210e..bec675f 100644 --- a/foundry/packages/backend/src/actors/keys.ts +++ b/foundry/packages/backend/src/actors/keys.ts @@ -4,6 +4,10 @@ export function workspaceKey(workspaceId: string): ActorKey { return ["ws", workspaceId]; } +export function authUserKey(userId: string): ActorKey { + return ["ws", "app", "user", userId]; +} + export function projectKey(workspaceId: string, repoId: string): ActorKey { return ["ws", workspaceId, "project", repoId]; } diff --git a/foundry/packages/backend/src/actors/project/actions.ts b/foundry/packages/backend/src/actors/project/actions.ts index 5ae47e6..bcd8f36 100644 --- a/foundry/packages/backend/src/actors/project/actions.ts +++ b/foundry/packages/backend/src/actors/project/actions.ts @@ -10,7 +10,7 @@ import { foundryRepoClonePath } from "../../services/foundry-paths.js"; import { resolveWorkspaceGithubAuth } from "../../services/github-auth.js"; import { expectQueueResponse } from "../../services/queue.js"; import { withRepoGitLock } from "../../services/repo-git-lock.js"; -import { branches, taskIndex, prCache, repoMeta } from "./db/schema.js"; +import { branches, taskIndex, prCache, repoActionJobs, repoMeta } from "./db/schema.js"; import { deriveFallbackTitle } from "../../services/create-flow.js"; import { normalizeBaseBranchName } from "../../integrations/git-spice/index.js"; import { sortBranchesForOverview } from "./stack-model.js"; @@ -87,6 +87,7 @@ interface BranchSyncResult { interface RepoOverviewCommand {} interface RunRepoStackActionCommand { + jobId?: string; action: RepoStackAction; branchName?: string; parentBranch?: string; @@ -133,6 +134,90 @@ async function ensureProjectSyncActors(c: any, localPath: string): Promise c.state.syncActorsStarted = true; } +async function ensureRepoActionJobsTable(c: any): Promise { + await c.db.execute(` + CREATE TABLE IF NOT EXISTS repo_action_jobs ( + job_id text PRIMARY KEY NOT NULL, + action text NOT NULL, + branch_name text, + parent_branch text, + status text NOT NULL, + message text NOT NULL, + created_at integer NOT NULL, + updated_at integer NOT NULL, + completed_at integer + ) + `); +} + +async function writeRepoActionJob( + c: any, + input: { + jobId: string; + action: RepoStackAction; + branchName: string | null; + parentBranch: string | null; + status: "queued" | "running" | "completed" | "error"; + message: string; + createdAt?: number; + completedAt?: number | null; + }, +): Promise { + await ensureRepoActionJobsTable(c); + const now = Date.now(); + await c.db + .insert(repoActionJobs) + .values({ + jobId: input.jobId, + action: input.action, + branchName: input.branchName, + parentBranch: input.parentBranch, + status: input.status, + message: input.message, + createdAt: input.createdAt ?? now, + updatedAt: now, + completedAt: input.completedAt ?? null, + }) + .onConflictDoUpdate({ + target: repoActionJobs.jobId, + set: { + status: input.status, + message: input.message, + updatedAt: now, + completedAt: input.completedAt ?? null, + }, + }) + .run(); +} + +async function listRepoActionJobRows(c: any): Promise< + Array<{ + jobId: string; + action: RepoStackAction; + branchName: string | null; + parentBranch: string | null; + status: "queued" | "running" | "completed" | "error"; + message: string; + createdAt: number; + updatedAt: number; + completedAt: number | null; + }> +> { + await ensureRepoActionJobsTable(c); + const rows = await c.db.select().from(repoActionJobs).orderBy(desc(repoActionJobs.updatedAt)).limit(20).all(); + return rows.map((row: any) => ({ + jobId: row.jobId, + action: row.action, + branchName: row.branchName ?? null, + parentBranch: row.parentBranch ?? null, + status: row.status, + message: row.message, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + completedAt: row.completedAt ?? null, + })); +} + async function deleteStaleTaskIndexRow(c: any, taskId: string): Promise { try { await c.db.delete(taskIndex).where(eq(taskIndex.taskId, taskId)).run(); @@ -359,8 +444,6 @@ async function createTaskMutation(c: any, cmd: CreateTaskCommand): Promise false))) { + await writeRepoActionJob(c, { + jobId, + action, + branchName, + parentBranch, + status: "error", + message: "git-spice is not available for this repo", + createdAt: at, + completedAt: Date.now(), + }); return { + jobId, action, executed: false, + status: "error", message: "git-spice is not available for this repo", at, }; @@ -615,48 +721,77 @@ async function runRepoStackActionMutation(c: any, cmd: RunRepoStackActionCommand } } - await withRepoGitLock(localPath, async () => { - if (action === "sync_repo") { - await driver.stack.syncRepo(localPath); - } else if (action === "restack_repo") { - await driver.stack.restackRepo(localPath); - } else if (action === "restack_subtree") { - await driver.stack.restackSubtree(localPath, branchName!); - } else if (action === "rebase_branch") { - await driver.stack.rebaseBranch(localPath, branchName!); - } else if (action === "reparent_branch") { - await driver.stack.reparentBranch(localPath, branchName!, parentBranch!); - } else { - throw new Error(`Unsupported repo stack action: ${action}`); - } - }); - - await forceProjectSync(c, localPath); - try { - const history = await getOrCreateHistory(c, c.state.workspaceId, c.state.repoId); - await history.append({ - kind: "repo.stack_action", - branchName: branchName ?? null, - payload: { - action, + await withRepoGitLock(localPath, async () => { + if (action === "sync_repo") { + await driver.stack.syncRepo(localPath); + } else if (action === "restack_repo") { + await driver.stack.restackRepo(localPath); + } else if (action === "restack_subtree") { + await driver.stack.restackSubtree(localPath, branchName!); + } else if (action === "rebase_branch") { + await driver.stack.rebaseBranch(localPath, branchName!); + } else if (action === "reparent_branch") { + await driver.stack.reparentBranch(localPath, branchName!, parentBranch!); + } else { + throw new Error(`Unsupported repo stack action: ${action}`); + } + }); + + try { + const history = await getOrCreateHistory(c, c.state.workspaceId, c.state.repoId); + await history.append({ + kind: "repo.stack_action", branchName: branchName ?? null, - parentBranch: parentBranch ?? null, - }, + payload: { + action, + branchName: branchName ?? null, + parentBranch: parentBranch ?? null, + jobId, + }, + }); + } catch (error) { + logActorWarning("project", "failed appending repo stack history event", { + workspaceId: c.state.workspaceId, + repoId: c.state.repoId, + action, + error: resolveErrorMessage(error), + }); + } + + await forceProjectSync(c, localPath); + + await writeRepoActionJob(c, { + jobId, + action, + branchName, + parentBranch, + status: "completed", + message: `Completed ${action}`, + createdAt: at, + completedAt: Date.now(), }); } catch (error) { - logActorWarning("project", "failed appending repo stack history event", { - workspaceId: c.state.workspaceId, - repoId: c.state.repoId, + const message = resolveErrorMessage(error); + await writeRepoActionJob(c, { + jobId, action, - error: resolveErrorMessage(error), + branchName, + parentBranch, + status: "error", + message, + createdAt: at, + completedAt: Date.now(), }); + throw error; } return { + jobId, action, executed: true, - message: `stack action executed: ${action}`, + status: "completed", + message: `Completed ${action}`, at, }; } @@ -999,7 +1134,6 @@ export const projectActions = { async getRepoOverview(c: any, _cmd?: RepoOverviewCommand): Promise { const localPath = await ensureProjectReadyForRead(c); await ensureTaskIndexHydratedForRead(c); - await forceProjectSync(c, localPath); const { driver } = getActorRuntimeContext(); const now = Date.now(); @@ -1118,6 +1252,9 @@ export const projectActions = { }; }); + const latestBranchSync = await c.db.select({ updatedAt: branches.updatedAt }).from(branches).orderBy(desc(branches.updatedAt)).limit(1).get(); + const latestPrSync = await c.db.select({ updatedAt: prCache.updatedAt }).from(prCache).orderBy(desc(prCache.updatedAt)).limit(1).get(); + return { workspaceId: c.state.workspaceId, repoId: c.state.repoId, @@ -1125,6 +1262,11 @@ export const projectActions = { baseRef, stackAvailable, fetchedAt: now, + branchSyncAt: latestBranchSync?.updatedAt ?? null, + prSyncAt: latestPrSync?.updatedAt ?? null, + branchSyncStatus: latestBranchSync ? "synced" : "pending", + prSyncStatus: latestPrSync ? "synced" : "pending", + repoActionJobs: await listRepoActionJobRows(c), branches: branchRows, }; }, @@ -1156,12 +1298,41 @@ export const projectActions = { async runRepoStackAction(c: any, cmd: RunRepoStackActionCommand): Promise { const self = selfProject(c); - return expectQueueResponse( - await self.send(projectWorkflowQueueName("project.command.runRepoStackAction"), cmd, { - wait: true, - timeout: 12 * 60_000, - }), + const jobId = randomUUID(); + const at = Date.now(); + const action = cmd.action; + const branchName = cmd.branchName?.trim() || null; + const parentBranch = cmd.parentBranch?.trim() || null; + + await writeRepoActionJob(c, { + jobId, + action, + branchName, + parentBranch, + status: "queued", + message: `Queued ${action}`, + createdAt: at, + }); + + await self.send( + projectWorkflowQueueName("project.command.runRepoStackAction"), + { + ...cmd, + jobId, + }, + { + wait: false, + }, ); + + return { + jobId, + action, + executed: true, + status: "queued", + message: `Queued ${action}`, + at, + }; }, async applyPrSyncResult(c: any, body: PrSyncResult): Promise { diff --git a/foundry/packages/backend/src/actors/project/db/schema.ts b/foundry/packages/backend/src/actors/project/db/schema.ts index b72cbfc..1ef4cee 100644 --- a/foundry/packages/backend/src/actors/project/db/schema.ts +++ b/foundry/packages/backend/src/actors/project/db/schema.ts @@ -42,3 +42,15 @@ export const taskIndex = sqliteTable("task_index", { createdAt: integer("created_at").notNull(), updatedAt: integer("updated_at").notNull(), }); + +export const repoActionJobs = sqliteTable("repo_action_jobs", { + jobId: text("job_id").notNull().primaryKey(), + action: text("action").notNull(), + branchName: text("branch_name"), + parentBranch: text("parent_branch"), + status: text("status").notNull(), + message: text("message").notNull(), + createdAt: integer("created_at").notNull(), + updatedAt: integer("updated_at").notNull(), + completedAt: integer("completed_at"), +}); diff --git a/foundry/packages/backend/src/actors/sandbox-instance/index.ts b/foundry/packages/backend/src/actors/sandbox-instance/index.ts index 2fa84fc..566a378 100644 --- a/foundry/packages/backend/src/actors/sandbox-instance/index.ts +++ b/foundry/packages/backend/src/actors/sandbox-instance/index.ts @@ -278,10 +278,12 @@ async function getSandboxAgentClient(c: any) { }); } -function broadcastProcessesUpdated(c: any): void { +async function broadcastProcessesUpdated(c: any): Promise { + const client = await getSandboxAgentClient(c); + const { processes } = await client.listProcesses(); c.broadcast("processesUpdated", { - sandboxId: c.state.sandboxId, - at: Date.now(), + type: "processesUpdated", + processes, }); } @@ -475,7 +477,7 @@ export const sandboxInstance = actor({ async createProcess(c: any, request: ProcessCreateRequest): Promise { const client = await getSandboxAgentClient(c); const created = await client.createProcess(request); - broadcastProcessesUpdated(c); + await broadcastProcessesUpdated(c); return created; }, @@ -492,21 +494,21 @@ export const sandboxInstance = actor({ async stopProcess(c: any, request: { processId: string; query?: ProcessSignalQuery }): Promise { const client = await getSandboxAgentClient(c); const stopped = await client.stopProcess(request.processId, request.query); - broadcastProcessesUpdated(c); + await broadcastProcessesUpdated(c); return stopped; }, async killProcess(c: any, request: { processId: string; query?: ProcessSignalQuery }): Promise { const client = await getSandboxAgentClient(c); const killed = await client.killProcess(request.processId, request.query); - broadcastProcessesUpdated(c); + await broadcastProcessesUpdated(c); return killed; }, async deleteProcess(c: any, request: { processId: string }): Promise { const client = await getSandboxAgentClient(c); await client.deleteProcess(request.processId); - broadcastProcessesUpdated(c); + await broadcastProcessesUpdated(c); }, async providerState(c: any): Promise<{ providerId: ProviderId; sandboxId: string; state: string; at: number }> { diff --git a/foundry/packages/backend/src/actors/task/db/schema.ts b/foundry/packages/backend/src/actors/task/db/schema.ts index 2684ebb..2b59f4b 100644 --- a/foundry/packages/backend/src/actors/task/db/schema.ts +++ b/foundry/packages/backend/src/actors/task/db/schema.ts @@ -28,6 +28,10 @@ export const taskRuntime = sqliteTable( activeSwitchTarget: text("active_switch_target"), activeCwd: text("active_cwd"), statusMessage: text("status_message"), + gitStateJson: text("git_state_json"), + gitStateUpdatedAt: integer("git_state_updated_at"), + provisionStage: text("provision_stage"), + provisionStageUpdatedAt: integer("provision_stage_updated_at"), updatedAt: integer("updated_at").notNull(), }, (table) => [check("task_runtime_singleton_id_check", sql`${table.id} = 1`)], @@ -46,8 +50,13 @@ export const taskSandboxes = sqliteTable("task_sandboxes", { export const taskWorkbenchSessions = sqliteTable("task_workbench_sessions", { sessionId: text("session_id").notNull().primaryKey(), + sandboxSessionId: text("sandbox_session_id"), sessionName: text("session_name").notNull(), model: text("model").notNull(), + status: text("status").notNull().default("ready"), + errorMessage: text("error_message"), + transcriptJson: text("transcript_json").notNull().default("[]"), + transcriptUpdatedAt: integer("transcript_updated_at"), unread: integer("unread").notNull().default(0), draftText: text("draft_text").notNull().default(""), // Structured by the workbench composer attachment payload format. diff --git a/foundry/packages/backend/src/actors/task/index.ts b/foundry/packages/backend/src/actors/task/index.ts index d8bf069..8d9f418 100644 --- a/foundry/packages/backend/src/actors/task/index.ts +++ b/foundry/packages/backend/src/actors/task/index.ts @@ -19,7 +19,9 @@ import { changeWorkbenchModel, closeWorkbenchSession, createWorkbenchSession, - getWorkbenchTask, + getSessionDetail, + getTaskDetail, + getTaskSummary, markWorkbenchUnread, publishWorkbenchPr, renameWorkbenchBranch, @@ -144,14 +146,9 @@ export const task = actor({ async provision(c, cmd: InitializeCommand): Promise<{ ok: true }> { const self = selfTask(c); - const result = await self.send(taskWorkflowQueueName("task.command.provision"), cmd ?? {}, { - wait: true, - timeout: 30 * 60_000, + await self.send(taskWorkflowQueueName("task.command.provision"), cmd ?? {}, { + wait: false, }); - const response = expectQueueResponse<{ ok: boolean; error?: string }>(result); - if (!response.ok) { - throw new Error(response.error ?? "task provisioning failed"); - } return { ok: true }; }, @@ -180,47 +177,35 @@ export const task = actor({ async push(c, cmd?: TaskActionCommand): Promise { const self = selfTask(c); await self.send(taskWorkflowQueueName("task.command.push"), cmd ?? {}, { - wait: true, - timeout: 180_000, + wait: false, }); }, async sync(c, cmd?: TaskActionCommand): Promise { const self = selfTask(c); await self.send(taskWorkflowQueueName("task.command.sync"), cmd ?? {}, { - wait: true, - timeout: 30_000, + wait: false, }); }, async merge(c, cmd?: TaskActionCommand): Promise { const self = selfTask(c); await self.send(taskWorkflowQueueName("task.command.merge"), cmd ?? {}, { - wait: true, - timeout: 30_000, + wait: false, }); }, async archive(c, cmd?: TaskActionCommand): Promise { const self = selfTask(c); - void self - .send(taskWorkflowQueueName("task.command.archive"), cmd ?? {}, { - wait: true, - timeout: 60_000, - }) - .catch((error: unknown) => { - c.log.warn({ - msg: "archive command failed", - error: error instanceof Error ? error.message : String(error), - }); - }); + await self.send(taskWorkflowQueueName("task.command.archive"), cmd ?? {}, { + wait: false, + }); }, async kill(c, cmd?: TaskActionCommand): Promise { const self = selfTask(c); await self.send(taskWorkflowQueueName("task.command.kill"), cmd ?? {}, { - wait: true, - timeout: 60_000, + wait: false, }); }, @@ -228,8 +213,16 @@ export const task = actor({ return await getCurrentRecord({ db: c.db, state: c.state }); }, - async getWorkbench(c) { - return await getWorkbenchTask(c); + async getTaskSummary(c) { + return await getTaskSummary(c); + }, + + async getTaskDetail(c) { + return await getTaskDetail(c); + }, + + async getSessionDetail(c, input: { sessionId: string }) { + return await getSessionDetail(c, input.sessionId); }, async markWorkbenchUnread(c): Promise { @@ -255,8 +248,7 @@ export const task = actor({ async renameWorkbenchBranch(c, input: TaskWorkbenchRenameInput): Promise { const self = selfTask(c); await self.send(taskWorkflowQueueName("task.command.workbench.rename_branch"), { value: input.value } satisfies TaskWorkbenchValueCommand, { - wait: true, - timeout: 5 * 60_000, + wait: false, }); }, @@ -335,8 +327,7 @@ export const task = actor({ attachments: input.attachments, } satisfies TaskWorkbenchSendMessageCommand, { - wait: true, - timeout: 10 * 60_000, + wait: false, }, ); }, @@ -344,8 +335,7 @@ export const task = actor({ async stopWorkbenchSession(c, input: TaskTabCommand): Promise { const self = selfTask(c); await self.send(taskWorkflowQueueName("task.command.workbench.stop_session"), { sessionId: input.tabId } satisfies TaskWorkbenchSessionCommand, { - wait: true, - timeout: 5 * 60_000, + wait: false, }); }, @@ -360,8 +350,7 @@ export const task = actor({ async closeWorkbenchSession(c, input: TaskTabCommand): Promise { const self = selfTask(c); await self.send(taskWorkflowQueueName("task.command.workbench.close_session"), { sessionId: input.tabId } satisfies TaskWorkbenchSessionCommand, { - wait: true, - timeout: 5 * 60_000, + wait: false, }); }, @@ -371,8 +360,7 @@ export const task = actor({ taskWorkflowQueueName("task.command.workbench.publish_pr"), {}, { - wait: true, - timeout: 10 * 60_000, + wait: false, }, ); }, @@ -380,8 +368,7 @@ export const task = actor({ async revertWorkbenchFile(c, input: { path: string }): Promise { const self = selfTask(c); await self.send(taskWorkflowQueueName("task.command.workbench.revert_file"), input, { - wait: true, - timeout: 5 * 60_000, + wait: false, }); }, }, diff --git a/foundry/packages/backend/src/actors/task/workbench.ts b/foundry/packages/backend/src/actors/task/workbench.ts index fae749c..0d00e77 100644 --- a/foundry/packages/backend/src/actors/task/workbench.ts +++ b/foundry/packages/backend/src/actors/task/workbench.ts @@ -1,4 +1,5 @@ // @ts-nocheck +import { randomUUID } from "node:crypto"; import { basename } from "node:path"; import { asc, eq } from "drizzle-orm"; import { getActorRuntimeContext } from "../context.js"; @@ -6,15 +7,30 @@ import { getOrCreateTaskStatusSync, getOrCreateProject, getOrCreateWorkspace, ge import { resolveWorkspaceGithubAuth } from "../../services/github-auth.js"; import { task as taskTable, taskRuntime, taskWorkbenchSessions } from "./db/schema.js"; import { getCurrentRecord } from "./workflow/common.js"; +import { taskWorkflowQueueName } from "./workflow/queue.js"; const STATUS_SYNC_INTERVAL_MS = 1_000; +function emptyGitState() { + return { + fileChanges: [], + diffs: {}, + fileTree: [], + updatedAt: null as number | null, + }; +} + async function ensureWorkbenchSessionTable(c: any): Promise { await c.db.execute(` CREATE TABLE IF NOT EXISTS task_workbench_sessions ( session_id text PRIMARY KEY NOT NULL, + sandbox_session_id text, session_name text NOT NULL, model text NOT NULL, + status text DEFAULT 'ready' NOT NULL, + error_message text, + transcript_json text DEFAULT '[]' NOT NULL, + transcript_updated_at integer, unread integer DEFAULT 0 NOT NULL, draft_text text DEFAULT '' NOT NULL, draft_attachments_json text DEFAULT '[]' NOT NULL, @@ -26,6 +42,18 @@ async function ensureWorkbenchSessionTable(c: any): Promise { updated_at integer NOT NULL ) `); + await c.db.execute(`ALTER TABLE task_workbench_sessions ADD COLUMN sandbox_session_id text`).catch(() => {}); + await c.db.execute(`ALTER TABLE task_workbench_sessions ADD COLUMN status text DEFAULT 'ready' NOT NULL`).catch(() => {}); + await c.db.execute(`ALTER TABLE task_workbench_sessions ADD COLUMN error_message text`).catch(() => {}); + await c.db.execute(`ALTER TABLE task_workbench_sessions ADD COLUMN transcript_json text DEFAULT '[]' NOT NULL`).catch(() => {}); + await c.db.execute(`ALTER TABLE task_workbench_sessions ADD COLUMN transcript_updated_at integer`).catch(() => {}); +} + +async function ensureTaskRuntimeCacheColumns(c: any): Promise { + await c.db.execute(`ALTER TABLE task_runtime ADD COLUMN git_state_json text`).catch(() => {}); + await c.db.execute(`ALTER TABLE task_runtime ADD COLUMN git_state_updated_at integer`).catch(() => {}); + await c.db.execute(`ALTER TABLE task_runtime ADD COLUMN provision_stage text`).catch(() => {}); + await c.db.execute(`ALTER TABLE task_runtime ADD COLUMN provision_stage_updated_at integer`).catch(() => {}); } function defaultModelForAgent(agentType: string | null | undefined) { @@ -74,6 +102,40 @@ function parseDraftAttachments(value: string | null | undefined): Array { } } +function parseTranscript(value: string | null | undefined): Array { + if (!value) { + return []; + } + + try { + const parsed = JSON.parse(value) as unknown; + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +function parseGitState(value: string | null | undefined): { fileChanges: Array; diffs: Record; fileTree: Array } { + if (!value) { + return emptyGitState(); + } + + try { + const parsed = JSON.parse(value) as { + fileChanges?: unknown; + diffs?: unknown; + fileTree?: unknown; + }; + return { + fileChanges: Array.isArray(parsed.fileChanges) ? parsed.fileChanges : [], + diffs: parsed.diffs && typeof parsed.diffs === "object" ? (parsed.diffs as Record) : {}, + fileTree: Array.isArray(parsed.fileTree) ? parsed.fileTree : [], + }; + } catch { + return emptyGitState(); + } +} + export function shouldMarkSessionUnreadForStatus(meta: { thinkingSinceMs?: number | null }, status: "running" | "idle" | "error"): boolean { if (status === "running") { return false; @@ -90,7 +152,13 @@ async function listSessionMetaRows(c: any, options?: { includeClosed?: boolean } const mapped = rows.map((row: any) => ({ ...row, id: row.sessionId, - sessionId: row.sessionId, + sessionId: row.sandboxSessionId ?? null, + tabId: row.sessionId, + sandboxSessionId: row.sandboxSessionId ?? null, + status: row.status ?? "ready", + errorMessage: row.errorMessage ?? null, + transcript: parseTranscript(row.transcriptJson), + transcriptUpdatedAt: row.transcriptUpdatedAt ?? null, draftAttachments: parseDraftAttachments(row.draftAttachmentsJson), draftUpdatedAtMs: row.draftUpdatedAt ?? null, unread: row.unread === 1, @@ -121,7 +189,13 @@ async function readSessionMeta(c: any, sessionId: string): Promise { return { ...row, id: row.sessionId, - sessionId: row.sessionId, + sessionId: row.sandboxSessionId ?? null, + tabId: row.sessionId, + sandboxSessionId: row.sandboxSessionId ?? null, + status: row.status ?? "ready", + errorMessage: row.errorMessage ?? null, + transcript: parseTranscript(row.transcriptJson), + transcriptUpdatedAt: row.transcriptUpdatedAt ?? null, draftAttachments: parseDraftAttachments(row.draftAttachmentsJson), draftUpdatedAtMs: row.draftUpdatedAt ?? null, unread: row.unread === 1, @@ -133,14 +207,18 @@ async function readSessionMeta(c: any, sessionId: string): Promise { async function ensureSessionMeta( c: any, params: { - sessionId: string; + tabId: string; + sandboxSessionId?: string | null; model?: string; sessionName?: string; unread?: boolean; + created?: boolean; + status?: "pending_provision" | "pending_session_create" | "ready" | "error"; + errorMessage?: string | null; }, ): Promise { await ensureWorkbenchSessionTable(c); - const existing = await readSessionMeta(c, params.sessionId); + const existing = await readSessionMeta(c, params.tabId); if (existing) { return existing; } @@ -153,14 +231,19 @@ async function ensureSessionMeta( await c.db .insert(taskWorkbenchSessions) .values({ - sessionId: params.sessionId, + sessionId: params.tabId, + sandboxSessionId: params.sandboxSessionId ?? null, sessionName, model, + status: params.status ?? "ready", + errorMessage: params.errorMessage ?? null, + transcriptJson: "[]", + transcriptUpdatedAt: null, unread: unread ? 1 : 0, draftText: "", draftAttachmentsJson: "[]", draftUpdatedAt: null, - created: 1, + created: params.created === false ? 0 : 1, closed: 0, thinkingSinceMs: null, createdAt: now, @@ -168,25 +251,40 @@ async function ensureSessionMeta( }) .run(); - return await readSessionMeta(c, params.sessionId); + return await readSessionMeta(c, params.tabId); } -async function updateSessionMeta(c: any, sessionId: string, values: Record): Promise { - await ensureSessionMeta(c, { sessionId }); +async function updateSessionMeta(c: any, tabId: string, values: Record): Promise { + await ensureSessionMeta(c, { tabId }); await c.db .update(taskWorkbenchSessions) .set({ ...values, updatedAt: Date.now(), }) - .where(eq(taskWorkbenchSessions.sessionId, sessionId)) + .where(eq(taskWorkbenchSessions.sessionId, tabId)) .run(); - return await readSessionMeta(c, sessionId); + return await readSessionMeta(c, tabId); } -async function notifyWorkbenchUpdated(c: any): Promise { - const workspace = await getOrCreateWorkspace(c, c.state.workspaceId); - await workspace.notifyWorkbenchUpdated({}); +async function readSessionMetaBySandboxSessionId(c: any, sandboxSessionId: string): Promise { + await ensureWorkbenchSessionTable(c); + const row = await c.db.select().from(taskWorkbenchSessions).where(eq(taskWorkbenchSessions.sandboxSessionId, sandboxSessionId)).get(); + if (!row) { + return null; + } + return await readSessionMeta(c, row.sessionId); +} + +async function requireReadySessionMeta(c: any, tabId: string): Promise { + const meta = await readSessionMeta(c, tabId); + if (!meta) { + throw new Error(`Unknown workbench tab: ${tabId}`); + } + if (meta.status !== "ready" || !meta.sandboxSessionId) { + throw new Error(meta.errorMessage ?? "This workbench tab is still preparing"); + } + return meta; } function shellFragment(parts: string[]): string { @@ -333,17 +431,6 @@ async function collectWorkbenchGitState(c: any, record: any) { label: "git diff numstat", }); const numstat = parseNumstat(numstatResult.result); - const diffs: Record = {}; - - for (const row of statusRows) { - const diffResult = await executeInSandbox(c, { - sandboxId: activeSandboxId, - cwd, - command: `if git ls-files --error-unmatch -- ${JSON.stringify(row.path)} >/dev/null 2>&1; then git diff -- ${JSON.stringify(row.path)}; else git diff --no-index -- /dev/null ${JSON.stringify(row.path)} || true; fi`, - label: `git diff ${row.path}`, - }); - diffs[row.path] = diffResult.result; - } const filesResult = await executeInSandbox(c, { sandboxId: activeSandboxId, @@ -356,6 +443,17 @@ async function collectWorkbenchGitState(c: any, record: any) { .map((line) => line.trim()) .filter(Boolean); + const diffs: Record = {}; + for (const row of statusRows) { + const diffResult = await executeInSandbox(c, { + sandboxId: activeSandboxId, + cwd, + command: `git diff -- ${JSON.stringify(row.path)}`, + label: `git diff ${row.path}`, + }); + diffs[row.path] = diffResult.exitCode === 0 ? diffResult.result : ""; + } + return { fileChanges: statusRows.map((row) => { const counts = numstat.get(row.path) ?? { added: 0, removed: 0 }; @@ -371,6 +469,37 @@ async function collectWorkbenchGitState(c: any, record: any) { }; } +async function readCachedGitState(c: any): Promise<{ fileChanges: Array; diffs: Record; fileTree: Array; updatedAt: number | null }> { + await ensureTaskRuntimeCacheColumns(c); + const row = await c.db + .select({ + gitStateJson: taskRuntime.gitStateJson, + gitStateUpdatedAt: taskRuntime.gitStateUpdatedAt, + }) + .from(taskRuntime) + .where(eq(taskRuntime.id, 1)) + .get(); + const parsed = parseGitState(row?.gitStateJson); + return { + ...parsed, + updatedAt: row?.gitStateUpdatedAt ?? null, + }; +} + +async function writeCachedGitState(c: any, gitState: { fileChanges: Array; diffs: Record; fileTree: Array }): Promise { + await ensureTaskRuntimeCacheColumns(c); + const now = Date.now(); + await c.db + .update(taskRuntime) + .set({ + gitStateJson: JSON.stringify(gitState), + gitStateUpdatedAt: now, + updatedAt: now, + }) + .where(eq(taskRuntime.id, 1)) + .run(); +} + async function readSessionTranscript(c: any, record: any, sessionId: string) { const sandboxId = record.activeSandboxId ?? record.sandboxes?.[0]?.sandboxId ?? null; if (!sandboxId) { @@ -380,7 +509,7 @@ async function readSessionTranscript(c: any, record: any, sessionId: string) { const sandbox = getSandboxInstance(c, c.state.workspaceId, c.state.providerId, sandboxId); const page = await sandbox.listSessionEvents({ sessionId, - limit: 500, + limit: 100, }); return page.items.map((event: any) => ({ id: event.id, @@ -393,14 +522,50 @@ async function readSessionTranscript(c: any, record: any, sessionId: string) { })); } -async function activeSessionStatus(c: any, record: any, sessionId: string) { - if (record.activeSessionId !== sessionId || !record.activeSandboxId) { +async function writeSessionTranscript(c: any, tabId: string, transcript: Array): Promise { + await updateSessionMeta(c, tabId, { + transcriptJson: JSON.stringify(transcript), + transcriptUpdatedAt: Date.now(), + }); +} + +async function enqueueWorkbenchRefresh( + c: any, + command: "task.command.workbench.refresh_derived" | "task.command.workbench.refresh_session_transcript", + body: Record, +): Promise { + const self = selfTask(c); + await self.send(command, body, { wait: false }); +} + +async function maybeScheduleWorkbenchRefreshes(c: any, record: any, sessions: Array): Promise { + const gitState = await readCachedGitState(c); + if (record.activeSandboxId && !gitState.updatedAt) { + await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_derived", {}); + } + + for (const session of sessions) { + if (session.closed || session.status !== "ready" || !session.sandboxSessionId || session.transcriptUpdatedAt) { + continue; + } + await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_session_transcript", { + sessionId: session.sandboxSessionId, + }); + } +} + +function activeSessionStatus(record: any, sessionId: string) { + if (record.activeSessionId !== sessionId) { return "idle"; } - const sandbox = getSandboxInstance(c, c.state.workspaceId, c.state.providerId, record.activeSandboxId); - const status = await sandbox.sessionStatus({ sessionId }); - return status.status; + if (record.status === "running") { + return "running"; + } + if (record.status === "error") { + return "error"; + } + return "idle"; } async function readPullRequestSummary(c: any, branchName: string | null) { @@ -417,51 +582,75 @@ async function readPullRequestSummary(c: any, branchName: string | null) { } export async function ensureWorkbenchSeeded(c: any): Promise { + await ensureTaskRuntimeCacheColumns(c); const record = await getCurrentRecord({ db: c.db, state: c.state }); if (record.activeSessionId) { await ensureSessionMeta(c, { - sessionId: record.activeSessionId, + tabId: record.activeSessionId, + sandboxSessionId: record.activeSessionId, model: defaultModelForAgent(record.agentType), sessionName: "Session 1", + status: "ready", }); } return record; } -export async function getWorkbenchTask(c: any): Promise { - const record = await ensureWorkbenchSeeded(c); - const gitState = await collectWorkbenchGitState(c, record); - const sessions = await listSessionMetaRows(c); - const tabs = []; - - for (const meta of sessions) { - const status = await activeSessionStatus(c, record, meta.sessionId); - let thinkingSinceMs = meta.thinkingSinceMs ?? null; - let unread = Boolean(meta.unread); - if (thinkingSinceMs && status !== "running") { - thinkingSinceMs = null; - unread = true; - } - - tabs.push({ - id: meta.id, - sessionId: meta.sessionId, - sessionName: meta.sessionName, - agent: agentKindForModel(meta.model), - model: meta.model, - status, - thinkingSinceMs: status === "running" ? thinkingSinceMs : null, - unread, - created: Boolean(meta.created), - draft: { - text: meta.draftText ?? "", - attachments: Array.isArray(meta.draftAttachments) ? meta.draftAttachments : [], - updatedAtMs: meta.draftUpdatedAtMs ?? null, - }, - transcript: await readSessionTranscript(c, record, meta.sessionId), - }); +function buildSessionSummary(record: any, meta: any): any { + const derivedSandboxSessionId = meta.sandboxSessionId ?? (meta.status === "pending_provision" && record.activeSessionId ? record.activeSessionId : null); + const sessionStatus = + meta.status === "ready" && derivedSandboxSessionId ? activeSessionStatus(record, derivedSandboxSessionId) : meta.status === "error" ? "error" : "idle"; + let thinkingSinceMs = meta.thinkingSinceMs ?? null; + let unread = Boolean(meta.unread); + if (thinkingSinceMs && sessionStatus !== "running") { + thinkingSinceMs = null; + unread = true; } + return { + id: meta.id, + sessionId: derivedSandboxSessionId, + sessionName: meta.sessionName, + agent: agentKindForModel(meta.model), + model: meta.model, + status: sessionStatus, + thinkingSinceMs: sessionStatus === "running" ? thinkingSinceMs : null, + unread, + created: Boolean(meta.created || derivedSandboxSessionId), + }; +} + +function buildSessionDetailFromMeta(record: any, meta: any): any { + const summary = buildSessionSummary(record, meta); + return { + sessionId: meta.tabId, + tabId: meta.tabId, + sandboxSessionId: summary.sessionId, + sessionName: summary.sessionName, + agent: summary.agent, + model: summary.model, + status: summary.status, + thinkingSinceMs: summary.thinkingSinceMs, + unread: summary.unread, + created: summary.created, + draft: { + text: meta.draftText ?? "", + attachments: Array.isArray(meta.draftAttachments) ? meta.draftAttachments : [], + updatedAtMs: meta.draftUpdatedAtMs ?? null, + }, + transcript: meta.transcript ?? [], + }; +} + +/** + * Builds a WorkbenchTaskSummary from local task actor state. Task actors push + * this to the parent workspace actor so workspace sidebar reads stay local. + */ +export async function buildTaskSummary(c: any): Promise { + const record = await ensureWorkbenchSeeded(c); + const sessions = await listSessionMetaRows(c); + await maybeScheduleWorkbenchRefreshes(c, record, sessions); + return { id: c.state.taskId, repoId: c.state.repoId, @@ -471,14 +660,112 @@ export async function getWorkbenchTask(c: any): Promise { updatedAtMs: record.updatedAt, branch: record.branchName, pullRequest: await readPullRequestSummary(c, record.branchName), - tabs, + sessionsSummary: sessions.map((meta) => buildSessionSummary(record, meta)), + }; +} + +/** + * Builds a WorkbenchTaskDetail from local task actor state for direct task + * subscribers. This is a full replacement payload, not a patch. + */ +export async function buildTaskDetail(c: any): Promise { + const record = await ensureWorkbenchSeeded(c); + const gitState = await readCachedGitState(c); + const sessions = await listSessionMetaRows(c); + await maybeScheduleWorkbenchRefreshes(c, record, sessions); + const summary = await buildTaskSummary(c); + + return { + ...summary, + task: record.task, + agentType: record.agentType === "claude" || record.agentType === "codex" ? record.agentType : null, + runtimeStatus: record.status, + statusMessage: record.statusMessage ?? null, + activeSessionId: record.activeSessionId ?? null, + diffStat: record.diffStat ?? null, + prUrl: record.prUrl ?? null, + reviewStatus: record.reviewStatus ?? null, fileChanges: gitState.fileChanges, diffs: gitState.diffs, fileTree: gitState.fileTree, minutesUsed: 0, + sandboxes: (record.sandboxes ?? []).map((sandbox: any) => ({ + providerId: sandbox.providerId, + sandboxId: sandbox.sandboxId, + cwd: sandbox.cwd ?? null, + })), + activeSandboxId: record.activeSandboxId ?? null, }; } +/** + * Builds a WorkbenchSessionDetail for a specific session tab. + */ +export async function buildSessionDetail(c: any, tabId: string): Promise { + const record = await ensureWorkbenchSeeded(c); + const meta = await readSessionMeta(c, tabId); + if (!meta || meta.closed) { + throw new Error(`Unknown workbench session tab: ${tabId}`); + } + + return buildSessionDetailFromMeta(record, meta); +} + +export async function getTaskSummary(c: any): Promise { + return await buildTaskSummary(c); +} + +export async function getTaskDetail(c: any): Promise { + return await buildTaskDetail(c); +} + +export async function getSessionDetail(c: any, tabId: string): Promise { + return await buildSessionDetail(c, tabId); +} + +/** + * Replaces the old notifyWorkbenchUpdated pattern. + * + * The task actor emits two kinds of updates: + * - Push summary state up to the parent workspace actor so the sidebar + * materialized projection stays current. + * - Broadcast full detail/session payloads down to direct task subscribers. + */ +export async function broadcastTaskUpdate(c: any, options?: { sessionId?: string }): Promise { + const workspace = await getOrCreateWorkspace(c, c.state.workspaceId); + await workspace.applyTaskSummaryUpdate({ taskSummary: await buildTaskSummary(c) }); + c.broadcast("taskUpdated", { + type: "taskDetailUpdated", + detail: await buildTaskDetail(c), + }); + + if (options?.sessionId) { + c.broadcast("sessionUpdated", { + type: "sessionUpdated", + session: await buildSessionDetail(c, options.sessionId), + }); + } +} + +export async function refreshWorkbenchDerivedState(c: any): Promise { + const record = await ensureWorkbenchSeeded(c); + const gitState = await collectWorkbenchGitState(c, record); + await writeCachedGitState(c, gitState); + await broadcastTaskUpdate(c); +} + +export async function refreshWorkbenchSessionTranscript(c: any, sessionId: string): Promise { + const record = await ensureWorkbenchSeeded(c); + const meta = (await readSessionMetaBySandboxSessionId(c, sessionId)) ?? (await readSessionMeta(c, sessionId)); + if (!meta?.sandboxSessionId) { + return; + } + + const transcript = await readSessionTranscript(c, record, meta.sandboxSessionId); + await writeSessionTranscript(c, meta.tabId, transcript); + await broadcastTaskUpdate(c, { sessionId: meta.tabId }); +} + export async function renameWorkbenchTask(c: any, value: string): Promise { const nextTitle = value.trim(); if (!nextTitle) { @@ -494,7 +781,7 @@ export async function renameWorkbenchTask(c: any, value: string): Promise .where(eq(taskTable.id, 1)) .run(); c.state.title = nextTitle; - await notifyWorkbenchUpdated(c); + await broadcastTaskUpdate(c); } export async function renameWorkbenchBranch(c: any, value: string): Promise { @@ -545,55 +832,168 @@ export async function renameWorkbenchBranch(c: any, value: string): Promise { let record = await ensureWorkbenchSeeded(c); if (!record.activeSandboxId) { + // Fire-and-forget: enqueue provisioning without waiting to avoid self-deadlock + // (this handler already runs inside the task workflow loop, so wait:true would deadlock). const providerId = record.providerId ?? c.state.providerId ?? getActorRuntimeContext().providers.defaultProviderId(); - await selfTask(c).provision({ providerId }); - record = await ensureWorkbenchSeeded(c); + await selfTask(c).send(taskWorkflowQueueName("task.command.provision"), { providerId }, { wait: false }); + throw new Error("sandbox is provisioning — retry shortly"); } if (record.activeSessionId) { const existingSessions = await listSessionMetaRows(c); if (existingSessions.length === 0) { await ensureSessionMeta(c, { - sessionId: record.activeSessionId, + tabId: record.activeSessionId, + sandboxSessionId: record.activeSessionId, model: model ?? defaultModelForAgent(record.agentType), sessionName: "Session 1", + status: "ready", }); - await notifyWorkbenchUpdated(c); + await broadcastTaskUpdate(c, { sessionId: record.activeSessionId }); return { tabId: record.activeSessionId }; } } - if (!record.activeSandboxId) { - throw new Error("cannot create session without an active sandbox"); + const tabId = `tab-${randomUUID()}`; + await ensureSessionMeta(c, { + tabId, + model: model ?? defaultModelForAgent(record.agentType), + status: record.activeSandboxId ? "pending_session_create" : "pending_provision", + created: false, + }); + + const providerId = record.providerId ?? c.state.providerId ?? getActorRuntimeContext().providers.defaultProviderId(); + const self = selfTask(c); + if (!record.activeSandboxId && !String(record.status ?? "").startsWith("init_")) { + await self.send("task.command.provision", { providerId }, { wait: false }); } + await self.send( + "task.command.workbench.ensure_session", + { tabId, ...(model ? { model } : {}) }, + { + wait: false, + }, + ); + await broadcastTaskUpdate(c, { sessionId: tabId }); + return { tabId }; +} + +export async function ensureWorkbenchSession(c: any, tabId: string, model?: string): Promise { + const meta = await readSessionMeta(c, tabId); + if (!meta || meta.closed) { + return; + } + + const record = await ensureWorkbenchSeeded(c); + if (!record.activeSandboxId) { + await updateSessionMeta(c, tabId, { + status: "pending_provision", + errorMessage: null, + }); + return; + } + + if (!meta.sandboxSessionId && record.activeSessionId && meta.status === "pending_provision") { + const existingTabForActiveSession = await readSessionMetaBySandboxSessionId(c, record.activeSessionId); + if (existingTabForActiveSession && existingTabForActiveSession.tabId !== tabId) { + await updateSessionMeta(c, existingTabForActiveSession.tabId, { + closed: 1, + }); + } + await updateSessionMeta(c, tabId, { + sandboxSessionId: record.activeSessionId, + status: "ready", + errorMessage: null, + created: 1, + }); + await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_session_transcript", { + sessionId: record.activeSessionId, + }); + await broadcastTaskUpdate(c, { sessionId: tabId }); + return; + } + + if (meta.sandboxSessionId) { + await updateSessionMeta(c, tabId, { + status: "ready", + errorMessage: null, + }); + await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_session_transcript", { + sessionId: meta.sandboxSessionId, + }); + await broadcastTaskUpdate(c, { sessionId: tabId }); + return; + } + const activeSandbox = (record.sandboxes ?? []).find((candidate: any) => candidate.sandboxId === record.activeSandboxId) ?? null; const cwd = activeSandbox?.cwd ?? record.sandboxes?.[0]?.cwd ?? null; if (!cwd) { - throw new Error("cannot create session without a sandbox cwd"); + await updateSessionMeta(c, tabId, { + status: "error", + errorMessage: "cannot create session without a sandbox cwd", + }); + await broadcastTaskUpdate(c, { sessionId: tabId }); + return; } - const sandbox = getSandboxInstance(c, c.state.workspaceId, c.state.providerId, record.activeSandboxId); - const created = await sandbox.createSession({ - prompt: "", - cwd, - agent: agentTypeForModel(model ?? defaultModelForAgent(record.agentType)), + await updateSessionMeta(c, tabId, { + status: "pending_session_create", + errorMessage: null, }); - if (!created.id) { - throw new Error(created.error ?? "sandbox-agent session creation failed"); + + try { + const sandbox = getSandboxInstance(c, c.state.workspaceId, c.state.providerId, record.activeSandboxId); + const created = await sandbox.createSession({ + prompt: "", + cwd, + agent: agentTypeForModel(model ?? meta.model ?? defaultModelForAgent(record.agentType)), + }); + if (!created.id) { + throw new Error(created.error ?? "sandbox-agent session creation failed"); + } + + await updateSessionMeta(c, tabId, { + sandboxSessionId: created.id, + status: "ready", + errorMessage: null, + }); + await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_session_transcript", { + sessionId: created.id, + }); + } catch (error) { + await updateSessionMeta(c, tabId, { + status: "error", + errorMessage: error instanceof Error ? error.message : String(error), + }); } - await ensureSessionMeta(c, { - sessionId: created.id, - model: model ?? defaultModelForAgent(record.agentType), - }); - await notifyWorkbenchUpdated(c); - return { tabId: created.id }; + await broadcastTaskUpdate(c, { sessionId: tabId }); +} + +export async function enqueuePendingWorkbenchSessions(c: any): Promise { + const self = selfTask(c); + const pending = (await listSessionMetaRows(c, { includeClosed: true })).filter( + (row) => row.closed !== true && row.status !== "ready" && row.status !== "error", + ); + + for (const row of pending) { + await self.send( + "task.command.workbench.ensure_session", + { + tabId: row.tabId, + model: row.model, + }, + { + wait: false, + }, + ); + } } export async function renameWorkbenchSession(c: any, sessionId: string, title: string): Promise { @@ -604,14 +1004,14 @@ export async function renameWorkbenchSession(c: any, sessionId: string, title: s await updateSessionMeta(c, sessionId, { sessionName: trimmed, }); - await notifyWorkbenchUpdated(c); + await broadcastTaskUpdate(c, { sessionId }); } export async function setWorkbenchSessionUnread(c: any, sessionId: string, unread: boolean): Promise { await updateSessionMeta(c, sessionId, { unread: unread ? 1 : 0, }); - await notifyWorkbenchUpdated(c); + await broadcastTaskUpdate(c, { sessionId }); } export async function updateWorkbenchDraft(c: any, sessionId: string, text: string, attachments: Array): Promise { @@ -620,14 +1020,14 @@ export async function updateWorkbenchDraft(c: any, sessionId: string, text: stri draftAttachmentsJson: JSON.stringify(attachments), draftUpdatedAt: Date.now(), }); - await notifyWorkbenchUpdated(c); + await broadcastTaskUpdate(c, { sessionId }); } export async function changeWorkbenchModel(c: any, sessionId: string, model: string): Promise { await updateSessionMeta(c, sessionId, { model, }); - await notifyWorkbenchUpdated(c); + await broadcastTaskUpdate(c, { sessionId }); } export async function sendWorkbenchMessage(c: any, sessionId: string, text: string, attachments: Array): Promise { @@ -636,7 +1036,7 @@ export async function sendWorkbenchMessage(c: any, sessionId: string, text: stri throw new Error("cannot send message without an active sandbox"); } - await ensureSessionMeta(c, { sessionId }); + const meta = await requireReadySessionMeta(c, sessionId); const sandbox = getSandboxInstance(c, c.state.workspaceId, c.state.providerId, record.activeSandboxId); const prompt = [text.trim(), ...attachments.map((attachment: any) => `@ ${attachment.filePath}:${attachment.lineNumber}\n${attachment.lineContent}`)] .filter(Boolean) @@ -646,7 +1046,7 @@ export async function sendWorkbenchMessage(c: any, sessionId: string, text: stri } await sandbox.sendPrompt({ - sessionId, + sessionId: meta.sandboxSessionId, prompt, notification: true, }); @@ -663,25 +1063,28 @@ export async function sendWorkbenchMessage(c: any, sessionId: string, text: stri await c.db .update(taskRuntime) .set({ - activeSessionId: sessionId, + activeSessionId: meta.sandboxSessionId, updatedAt: Date.now(), }) .where(eq(taskRuntime.id, 1)) .run(); - const sync = await getOrCreateTaskStatusSync(c, c.state.workspaceId, c.state.repoId, c.state.taskId, record.activeSandboxId, sessionId, { + const sync = await getOrCreateTaskStatusSync(c, c.state.workspaceId, c.state.repoId, c.state.taskId, record.activeSandboxId, meta.sandboxSessionId, { workspaceId: c.state.workspaceId, repoId: c.state.repoId, taskId: c.state.taskId, providerId: c.state.providerId, sandboxId: record.activeSandboxId, - sessionId, + sessionId: meta.sandboxSessionId, intervalMs: STATUS_SYNC_INTERVAL_MS, }); await sync.setIntervalMs({ intervalMs: STATUS_SYNC_INTERVAL_MS }); await sync.start(); await sync.force(); - await notifyWorkbenchUpdated(c); + await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_session_transcript", { + sessionId: meta.sandboxSessionId, + }); + await broadcastTaskUpdate(c, { sessionId }); } export async function stopWorkbenchSession(c: any, sessionId: string): Promise { @@ -689,20 +1092,21 @@ export async function stopWorkbenchSession(c: any, sessionId: string): Promise { const record = await ensureWorkbenchSeeded(c); - const meta = await ensureSessionMeta(c, { sessionId }); + const meta = (await readSessionMetaBySandboxSessionId(c, sessionId)) ?? (await ensureSessionMeta(c, { tabId: sessionId, sandboxSessionId: sessionId })); let changed = false; - if (record.activeSessionId === sessionId) { + if (record.activeSessionId === sessionId || record.activeSessionId === meta.sandboxSessionId) { const mappedStatus = status === "running" ? "running" : status === "error" ? "error" : "idle"; if (record.status !== mappedStatus) { await c.db @@ -753,27 +1157,36 @@ export async function syncWorkbenchSessionStatus(c: any, sessionId: string, stat } if (changed) { - await notifyWorkbenchUpdated(c); + if (status !== "running") { + await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_session_transcript", { + sessionId, + }); + await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_derived", {}); + } + await broadcastTaskUpdate(c, { sessionId: meta.tabId }); } } export async function closeWorkbenchSession(c: any, sessionId: string): Promise { const record = await ensureWorkbenchSeeded(c); - if (!record.activeSandboxId) { - return; - } const sessions = await listSessionMetaRows(c); if (sessions.filter((candidate) => candidate.closed !== true).length <= 1) { return; } - const sandbox = getSandboxInstance(c, c.state.workspaceId, c.state.providerId, record.activeSandboxId); - await sandbox.destroySession({ sessionId }); + const meta = await readSessionMeta(c, sessionId); + if (!meta) { + return; + } + if (record.activeSandboxId && meta.sandboxSessionId) { + const sandbox = getSandboxInstance(c, c.state.workspaceId, c.state.providerId, record.activeSandboxId); + await sandbox.destroySession({ sessionId: meta.sandboxSessionId }); + } await updateSessionMeta(c, sessionId, { closed: 1, thinkingSinceMs: null, }); - if (record.activeSessionId === sessionId) { + if (record.activeSessionId === sessionId || record.activeSessionId === meta.sandboxSessionId) { await c.db .update(taskRuntime) .set({ @@ -783,7 +1196,7 @@ export async function closeWorkbenchSession(c: any, sessionId: string): Promise< .where(eq(taskRuntime.id, 1)) .run(); } - await notifyWorkbenchUpdated(c); + await broadcastTaskUpdate(c); } export async function markWorkbenchUnread(c: any): Promise { @@ -792,10 +1205,10 @@ export async function markWorkbenchUnread(c: any): Promise { if (!latest) { return; } - await updateSessionMeta(c, latest.sessionId, { + await updateSessionMeta(c, latest.tabId, { unread: 1, }); - await notifyWorkbenchUpdated(c); + await broadcastTaskUpdate(c, { sessionId: latest.tabId }); } export async function publishWorkbenchPr(c: any): Promise { @@ -816,7 +1229,7 @@ export async function publishWorkbenchPr(c: any): Promise { }) .where(eq(taskTable.id, 1)) .run(); - await notifyWorkbenchUpdated(c); + await broadcastTaskUpdate(c); } export async function revertWorkbenchFile(c: any, path: string): Promise { @@ -838,5 +1251,6 @@ export async function revertWorkbenchFile(c: any, path: string): Promise { if (result.exitCode !== 0) { throw new Error(`file revert failed (${result.exitCode}): ${result.result}`); } - await notifyWorkbenchUpdated(c); + await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_derived", {}); + await broadcastTaskUpdate(c); } diff --git a/foundry/packages/backend/src/actors/task/workflow/common.ts b/foundry/packages/backend/src/actors/task/workflow/common.ts index 251c288..0dfc667 100644 --- a/foundry/packages/backend/src/actors/task/workflow/common.ts +++ b/foundry/packages/backend/src/actors/task/workflow/common.ts @@ -1,9 +1,9 @@ // @ts-nocheck import { eq } from "drizzle-orm"; import type { TaskRecord, TaskStatus } from "@sandbox-agent/foundry-shared"; -import { getOrCreateWorkspace } from "../../handles.js"; import { task as taskTable, taskRuntime, taskSandboxes } from "../db/schema.js"; import { historyKey } from "../../keys.js"; +import { broadcastTaskUpdate } from "../workbench.js"; export const TASK_ROW_ID = 1; @@ -83,8 +83,7 @@ export async function setTaskState(ctx: any, status: TaskStatus, statusMessage?: .run(); } - const workspace = await getOrCreateWorkspace(ctx, ctx.state.workspaceId); - await workspace.notifyWorkbenchUpdated({}); + await broadcastTaskUpdate(ctx); } export async function getCurrentRecord(ctx: any): Promise { @@ -176,6 +175,5 @@ export async function appendHistory(ctx: any, kind: string, payload: Record = { const body = msg.body; await loopCtx.step("init-bootstrap-db", async () => initBootstrapDbActivity(loopCtx, body)); - await loopCtx.removed("init-enqueue-provision", "step"); + await loopCtx.step("init-enqueue-provision", async () => initEnqueueProvisionActivity(loopCtx, body)); await loopCtx.removed("init-dispatch-provision-v2", "step"); const currentRecord = await loopCtx.step("init-read-current-record", async () => getCurrentRecord(loopCtx)); @@ -164,12 +168,25 @@ const commandHandlers: Record = { }, "task.command.workbench.create_session": async (loopCtx, msg) => { - const created = await loopCtx.step({ - name: "workbench-create-session", + try { + const created = await loopCtx.step({ + name: "workbench-create-session", + timeout: 30_000, + run: async () => createWorkbenchSession(loopCtx, msg.body?.model), + }); + await msg.complete(created); + } catch (error) { + await msg.complete({ error: resolveErrorMessage(error) }); + } + }, + + "task.command.workbench.ensure_session": async (loopCtx, msg) => { + await loopCtx.step({ + name: "workbench-ensure-session", timeout: 5 * 60_000, - run: async () => createWorkbenchSession(loopCtx, msg.body?.model), + run: async () => ensureWorkbenchSession(loopCtx, msg.body.tabId, msg.body?.model), }); - await msg.complete(created); + await msg.complete({ ok: true }); }, "task.command.workbench.rename_session": async (loopCtx, msg) => { @@ -215,6 +232,24 @@ const commandHandlers: Record = { await msg.complete({ ok: true }); }, + "task.command.workbench.refresh_derived": async (loopCtx, msg) => { + await loopCtx.step({ + name: "workbench-refresh-derived", + timeout: 5 * 60_000, + run: async () => refreshWorkbenchDerivedState(loopCtx), + }); + await msg.complete({ ok: true }); + }, + + "task.command.workbench.refresh_session_transcript": async (loopCtx, msg) => { + await loopCtx.step({ + name: "workbench-refresh-session-transcript", + timeout: 60_000, + run: async () => refreshWorkbenchSessionTranscript(loopCtx, msg.body.sessionId), + }); + await msg.complete({ ok: true }); + }, + "task.command.workbench.close_session": async (loopCtx, msg) => { await loopCtx.step({ name: "workbench-close-session", diff --git a/foundry/packages/backend/src/actors/task/workflow/init.ts b/foundry/packages/backend/src/actors/task/workflow/init.ts index 922f5d9..4e6fbb5 100644 --- a/foundry/packages/backend/src/actors/task/workflow/init.ts +++ b/foundry/packages/backend/src/actors/task/workflow/init.ts @@ -8,6 +8,7 @@ import { logActorWarning, resolveErrorMessage } from "../../logging.js"; import { task as taskTable, taskRuntime, taskSandboxes } from "../db/schema.js"; import { TASK_ROW_ID, appendHistory, buildAgentPrompt, collectErrorMessages, resolveErrorDetail, setTaskState } from "./common.js"; import { taskWorkflowQueueName } from "./queue.js"; +import { enqueuePendingWorkbenchSessions } from "../workbench.js"; const DEFAULT_INIT_CREATE_SANDBOX_ACTIVITY_TIMEOUT_MS = 180_000; @@ -34,6 +35,13 @@ function debugInit(loopCtx: any, message: string, context?: Record { + await db.execute(`ALTER TABLE task_runtime ADD COLUMN git_state_json text`).catch(() => {}); + await db.execute(`ALTER TABLE task_runtime ADD COLUMN git_state_updated_at integer`).catch(() => {}); + await db.execute(`ALTER TABLE task_runtime ADD COLUMN provision_stage text`).catch(() => {}); + await db.execute(`ALTER TABLE task_runtime ADD COLUMN provision_stage_updated_at integer`).catch(() => {}); +} + async function withActivityTimeout(timeoutMs: number, label: string, run: () => Promise): Promise { let timer: ReturnType | null = null; try { @@ -60,6 +68,8 @@ export async function initBootstrapDbActivity(loopCtx: any, body: any): Promise< const initialStatusMessage = loopCtx.state.branchName && loopCtx.state.title ? "provisioning" : "naming"; try { + await ensureTaskRuntimeCacheColumns(db); + await db .insert(taskTable) .values({ @@ -96,6 +106,10 @@ export async function initBootstrapDbActivity(loopCtx: any, body: any): Promise< activeSwitchTarget: null, activeCwd: null, statusMessage: initialStatusMessage, + gitStateJson: null, + gitStateUpdatedAt: null, + provisionStage: "queued", + provisionStageUpdatedAt: now, updatedAt: now, }) .onConflictDoUpdate({ @@ -106,6 +120,8 @@ export async function initBootstrapDbActivity(loopCtx: any, body: any): Promise< activeSwitchTarget: null, activeCwd: null, statusMessage: initialStatusMessage, + provisionStage: "queued", + provisionStageUpdatedAt: now, updatedAt: now, }, }) @@ -118,19 +134,29 @@ export async function initBootstrapDbActivity(loopCtx: any, body: any): Promise< export async function initEnqueueProvisionActivity(loopCtx: any, body: any): Promise { await setTaskState(loopCtx, "init_enqueue_provision", "provision queued"); - const self = selfTask(loopCtx); - void self - .send(taskWorkflowQueueName("task.command.provision"), body, { - wait: false, + await loopCtx.db + .update(taskRuntime) + .set({ + provisionStage: "queued", + provisionStageUpdatedAt: Date.now(), + updatedAt: Date.now(), }) - .catch((error: unknown) => { - logActorWarning("task.init", "background provision command failed", { - workspaceId: loopCtx.state.workspaceId, - repoId: loopCtx.state.repoId, - taskId: loopCtx.state.taskId, - error: resolveErrorMessage(error), - }); + .where(eq(taskRuntime.id, TASK_ROW_ID)) + .run(); + const self = selfTask(loopCtx); + try { + await self.send(taskWorkflowQueueName("task.command.provision"), body, { + wait: false, }); + } catch (error: unknown) { + logActorWarning("task.init", "background provision command failed", { + workspaceId: loopCtx.state.workspaceId, + repoId: loopCtx.state.repoId, + taskId: loopCtx.state.taskId, + error: resolveErrorMessage(error), + }); + throw error; + } } export async function initEnsureNameActivity(loopCtx: any): Promise { @@ -197,6 +223,8 @@ export async function initEnsureNameActivity(loopCtx: any): Promise { .update(taskRuntime) .set({ statusMessage: "provisioning", + provisionStage: "repo_prepared", + provisionStageUpdatedAt: now, updatedAt: now, }) .where(eq(taskRuntime.id, TASK_ROW_ID)) @@ -222,6 +250,15 @@ export async function initAssertNameActivity(loopCtx: any): Promise { export async function initCreateSandboxActivity(loopCtx: any, body: any): Promise { await setTaskState(loopCtx, "init_create_sandbox", "creating sandbox"); + await loopCtx.db + .update(taskRuntime) + .set({ + provisionStage: "sandbox_allocated", + provisionStageUpdatedAt: Date.now(), + updatedAt: Date.now(), + }) + .where(eq(taskRuntime.id, TASK_ROW_ID)) + .run(); const { providers } = getActorRuntimeContext(); const providerId = body?.providerId ?? loopCtx.state.providerId; const provider = providers.get(providerId); @@ -307,6 +344,15 @@ export async function initCreateSandboxActivity(loopCtx: any, body: any): Promis export async function initEnsureAgentActivity(loopCtx: any, body: any, sandbox: any): Promise { await setTaskState(loopCtx, "init_ensure_agent", "ensuring sandbox agent"); + await loopCtx.db + .update(taskRuntime) + .set({ + provisionStage: "agent_installing", + provisionStageUpdatedAt: Date.now(), + updatedAt: Date.now(), + }) + .where(eq(taskRuntime.id, TASK_ROW_ID)) + .run(); const { providers } = getActorRuntimeContext(); const providerId = body?.providerId ?? loopCtx.state.providerId; const provider = providers.get(providerId); @@ -318,6 +364,15 @@ export async function initEnsureAgentActivity(loopCtx: any, body: any, sandbox: export async function initStartSandboxInstanceActivity(loopCtx: any, body: any, sandbox: any, agent: any): Promise { await setTaskState(loopCtx, "init_start_sandbox_instance", "starting sandbox runtime"); + await loopCtx.db + .update(taskRuntime) + .set({ + provisionStage: "agent_starting", + provisionStageUpdatedAt: Date.now(), + updatedAt: Date.now(), + }) + .where(eq(taskRuntime.id, TASK_ROW_ID)) + .run(); try { const providerId = body?.providerId ?? loopCtx.state.providerId; const sandboxInstance = await getOrCreateSandboxInstance(loopCtx, loopCtx.state.workspaceId, providerId, sandbox.sandboxId, { @@ -350,6 +405,15 @@ export async function initStartSandboxInstanceActivity(loopCtx: any, body: any, export async function initCreateSessionActivity(loopCtx: any, body: any, sandbox: any, sandboxInstanceReady: any): Promise { await setTaskState(loopCtx, "init_create_session", "creating agent session"); + await loopCtx.db + .update(taskRuntime) + .set({ + provisionStage: "session_creating", + provisionStageUpdatedAt: Date.now(), + updatedAt: Date.now(), + }) + .where(eq(taskRuntime.id, TASK_ROW_ID)) + .run(); if (!sandboxInstanceReady.ok) { return { id: null, @@ -481,6 +545,8 @@ export async function initWriteDbActivity( activeSwitchTarget: sandbox.switchTarget, activeCwd, statusMessage, + provisionStage: sessionHealthy ? "ready" : "error", + provisionStageUpdatedAt: now, updatedAt: now, }) .onConflictDoUpdate({ @@ -491,6 +557,8 @@ export async function initWriteDbActivity( activeSwitchTarget: sandbox.switchTarget, activeCwd, statusMessage, + provisionStage: sessionHealthy ? "ready" : "error", + provisionStageUpdatedAt: now, updatedAt: now, }, }) @@ -535,6 +603,12 @@ export async function initCompleteActivity(loopCtx: any, body: any, sandbox: any }); loopCtx.state.initialized = true; + await enqueuePendingWorkbenchSessions(loopCtx); + const self = selfTask(loopCtx); + await self.send(taskWorkflowQueueName("task.command.workbench.refresh_derived"), {}, { wait: false }); + if (sessionId) { + await self.send(taskWorkflowQueueName("task.command.workbench.refresh_session_transcript"), { sessionId }, { wait: false }); + } return; } @@ -591,6 +665,8 @@ export async function initFailedActivity(loopCtx: any, error: unknown): Promise< activeSwitchTarget: null, activeCwd: null, statusMessage: detail, + provisionStage: "error", + provisionStageUpdatedAt: now, updatedAt: now, }) .onConflictDoUpdate({ @@ -601,6 +677,8 @@ export async function initFailedActivity(loopCtx: any, error: unknown): Promise< activeSwitchTarget: null, activeCwd: null, statusMessage: detail, + provisionStage: "error", + provisionStageUpdatedAt: now, updatedAt: now, }, }) diff --git a/foundry/packages/backend/src/actors/task/workflow/queue.ts b/foundry/packages/backend/src/actors/task/workflow/queue.ts index 399414b..db5c0a3 100644 --- a/foundry/packages/backend/src/actors/task/workflow/queue.ts +++ b/foundry/packages/backend/src/actors/task/workflow/queue.ts @@ -13,6 +13,7 @@ export const TASK_QUEUE_NAMES = [ "task.command.workbench.rename_task", "task.command.workbench.rename_branch", "task.command.workbench.create_session", + "task.command.workbench.ensure_session", "task.command.workbench.rename_session", "task.command.workbench.set_session_unread", "task.command.workbench.update_draft", @@ -20,6 +21,8 @@ export const TASK_QUEUE_NAMES = [ "task.command.workbench.send_message", "task.command.workbench.stop_session", "task.command.workbench.sync_session_status", + "task.command.workbench.refresh_derived", + "task.command.workbench.refresh_session_transcript", "task.command.workbench.close_session", "task.command.workbench.publish_pr", "task.command.workbench.revert_file", diff --git a/foundry/packages/backend/src/actors/workspace/actions.ts b/foundry/packages/backend/src/actors/workspace/actions.ts index d6ac94b..0ba55f8 100644 --- a/foundry/packages/backend/src/actors/workspace/actions.ts +++ b/foundry/packages/backend/src/actors/workspace/actions.ts @@ -4,6 +4,17 @@ import { Loop } from "rivetkit/workflow"; import type { AddRepoInput, CreateTaskInput, + HistoryEvent, + HistoryQueryInput, + ListTasksInput, + ProviderId, + RepoOverview, + RepoRecord, + RepoStackActionInput, + RepoStackActionResult, + StarSandboxAgentRepoInput, + StarSandboxAgentRepoResult, + SwitchResult, TaskRecord, TaskSummary, TaskWorkbenchChangeModelInput, @@ -14,20 +25,13 @@ import type { TaskWorkbenchSelectInput, TaskWorkbenchSetSessionUnreadInput, TaskWorkbenchSendMessageInput, - TaskWorkbenchSnapshot, TaskWorkbenchTabInput, TaskWorkbenchUpdateDraftInput, - HistoryEvent, - HistoryQueryInput, - ListTasksInput, - ProviderId, - RepoOverview, - RepoStackActionInput, - RepoStackActionResult, - RepoRecord, - StarSandboxAgentRepoInput, - StarSandboxAgentRepoResult, - SwitchResult, + WorkbenchRepoSummary, + WorkbenchSessionSummary, + WorkbenchTaskSummary, + WorkspaceEvent, + WorkspaceSummarySnapshot, WorkspaceUseInput, } from "@sandbox-agent/foundry-shared"; import { getActorRuntimeContext } from "../context.js"; @@ -35,7 +39,7 @@ import { getTask, getOrCreateHistory, getOrCreateProject, selfWorkspace } from " import { logActorWarning, resolveErrorMessage } from "../logging.js"; import { normalizeRemoteUrl, repoIdFromRemote } from "../../services/repo.js"; import { resolveWorkspaceGithubAuth } from "../../services/github-auth.js"; -import { taskLookup, repos, providerProfiles } from "./db/schema.js"; +import { taskLookup, repos, providerProfiles, taskSummaries } from "./db/schema.js"; import { agentTypeForModel } from "../task/workbench.js"; import { expectQueueResponse } from "../../services/queue.js"; import { workspaceAppActions } from "./app-shell.js"; @@ -109,6 +113,18 @@ async function upsertTaskLookupRow(c: any, taskId: string, repoId: string): Prom .run(); } +function parseJsonValue(value: string | null | undefined, fallback: T): T { + if (!value) { + return fallback; + } + + try { + return JSON.parse(value) as T; + } catch { + return fallback; + } +} + async function collectAllTaskSummaries(c: any): Promise { const repoRows = await c.db.select({ repoId: repos.repoId, remoteUrl: repos.remoteUrl }).from(repos).orderBy(desc(repos.updatedAt)).all(); @@ -145,17 +161,55 @@ function repoLabelFromRemote(remoteUrl: string): string { return remoteUrl; } -async function buildWorkbenchSnapshot(c: any): Promise { +function buildRepoSummary(repoRow: { repoId: string; remoteUrl: string; updatedAt: number }, taskRows: WorkbenchTaskSummary[]): WorkbenchRepoSummary { + const repoTasks = taskRows.filter((task) => task.repoId === repoRow.repoId); + const latestActivityMs = repoTasks.reduce((latest, task) => Math.max(latest, task.updatedAtMs), repoRow.updatedAt); + + return { + id: repoRow.repoId, + label: repoLabelFromRemote(repoRow.remoteUrl), + taskCount: repoTasks.length, + latestActivityMs, + }; +} + +function taskSummaryRowFromSummary(taskSummary: WorkbenchTaskSummary) { + return { + taskId: taskSummary.id, + repoId: taskSummary.repoId, + title: taskSummary.title, + status: taskSummary.status, + repoName: taskSummary.repoName, + updatedAtMs: taskSummary.updatedAtMs, + branch: taskSummary.branch, + pullRequestJson: JSON.stringify(taskSummary.pullRequest), + sessionsSummaryJson: JSON.stringify(taskSummary.sessionsSummary), + }; +} + +function taskSummaryFromRow(row: any): WorkbenchTaskSummary { + return { + id: row.taskId, + repoId: row.repoId, + title: row.title, + status: row.status, + repoName: row.repoName, + updatedAtMs: row.updatedAtMs, + branch: row.branch ?? null, + pullRequest: parseJsonValue(row.pullRequestJson, null), + sessionsSummary: parseJsonValue(row.sessionsSummaryJson, []), + }; +} + +async function reconcileWorkbenchProjection(c: any): Promise { const repoRows = await c.db .select({ repoId: repos.repoId, remoteUrl: repos.remoteUrl, updatedAt: repos.updatedAt }) .from(repos) .orderBy(desc(repos.updatedAt)) .all(); - const tasks: Array = []; - const projects: Array = []; + const taskRows: WorkbenchTaskSummary[] = []; for (const row of repoRows) { - const projectTasks: Array = []; try { const project = await getOrCreateProject(c, c.state.workspaceId, row.repoId, row.remoteUrl); const summaries = await project.listTaskSummaries({ includeArchived: true }); @@ -163,11 +217,18 @@ async function buildWorkbenchSnapshot(c: any): Promise { try { await upsertTaskLookupRow(c, summary.taskId, row.repoId); const task = getTask(c, c.state.workspaceId, row.repoId, summary.taskId); - const snapshot = await task.getWorkbench({}); - tasks.push(snapshot); - projectTasks.push(snapshot); + const taskSummary = await task.getTaskSummary({}); + taskRows.push(taskSummary); + await c.db + .insert(taskSummaries) + .values(taskSummaryRowFromSummary(taskSummary)) + .onConflictDoUpdate({ + target: taskSummaries.taskId, + set: taskSummaryRowFromSummary(taskSummary), + }) + .run(); } catch (error) { - logActorWarning("workspace", "failed collecting workbench task", { + logActorWarning("workspace", "failed collecting task summary during reconciliation", { workspaceId: c.state.workspaceId, repoId: row.repoId, taskId: summary.taskId, @@ -175,17 +236,8 @@ async function buildWorkbenchSnapshot(c: any): Promise { }); } } - - if (projectTasks.length > 0) { - projects.push({ - id: row.repoId, - label: repoLabelFromRemote(row.remoteUrl), - updatedAtMs: projectTasks[0]?.updatedAtMs ?? row.updatedAt, - tasks: projectTasks.sort((left, right) => right.updatedAtMs - left.updatedAtMs), - }); - } } catch (error) { - logActorWarning("workspace", "failed collecting workbench repo snapshot", { + logActorWarning("workspace", "failed collecting repo during workbench reconciliation", { workspaceId: c.state.workspaceId, repoId: row.repoId, error: resolveErrorMessage(error), @@ -193,16 +245,11 @@ async function buildWorkbenchSnapshot(c: any): Promise { } } - tasks.sort((left, right) => right.updatedAtMs - left.updatedAtMs); - projects.sort((left, right) => right.updatedAtMs - left.updatedAtMs); + taskRows.sort((left, right) => right.updatedAtMs - left.updatedAtMs); return { workspaceId: c.state.workspaceId, - repos: repoRows.map((row) => ({ - id: row.repoId, - label: repoLabelFromRemote(row.remoteUrl), - })), - projects, - tasks, + repos: repoRows.map((row) => buildRepoSummary(row, taskRows)).sort((left, right) => right.latestActivityMs - left.latestActivityMs), + taskSummaries: taskRows, }; } @@ -211,6 +258,41 @@ async function requireWorkbenchTask(c: any, taskId: string) { return getTask(c, c.state.workspaceId, repoId, taskId); } +/** + * Reads the workspace sidebar snapshot from the workspace actor's local SQLite + * only. Task actors push summary updates into `task_summaries`, so clients do + * not need this action to fan out to every child actor on the hot read path. + */ +async function getWorkspaceSummarySnapshot(c: any): Promise { + const repoRows = await c.db + .select({ + repoId: repos.repoId, + remoteUrl: repos.remoteUrl, + updatedAt: repos.updatedAt, + }) + .from(repos) + .orderBy(desc(repos.updatedAt)) + .all(); + const taskRows = await c.db.select().from(taskSummaries).orderBy(desc(taskSummaries.updatedAtMs)).all(); + const summaries = taskRows.map(taskSummaryFromRow); + + return { + workspaceId: c.state.workspaceId, + repos: repoRows.map((row) => buildRepoSummary(row, summaries)).sort((left, right) => right.latestActivityMs - left.latestActivityMs), + taskSummaries: summaries, + }; +} + +async function broadcastRepoSummary( + c: any, + type: "repoAdded" | "repoUpdated", + repoRow: { repoId: string; remoteUrl: string; updatedAt: number }, +): Promise { + const matchingTaskRows = await c.db.select().from(taskSummaries).where(eq(taskSummaries.repoId, repoRow.repoId)).all(); + const repo = buildRepoSummary(repoRow, matchingTaskRows.map(taskSummaryFromRow)); + c.broadcast("workspaceUpdated", { type, repo } satisfies WorkspaceEvent); +} + async function addRepoMutation(c: any, input: AddRepoInput): Promise { assertWorkspace(c, input.workspaceId); @@ -225,6 +307,7 @@ async function addRepoMutation(c: any, input: AddRepoInput): Promise const repoId = repoIdFromRemote(remoteUrl); const now = Date.now(); + const existing = await c.db.select({ repoId: repos.repoId }).from(repos).where(eq(repos.repoId, repoId)).get(); await c.db .insert(repos) @@ -243,7 +326,11 @@ async function addRepoMutation(c: any, input: AddRepoInput): Promise }) .run(); - await workspaceActions.notifyWorkbenchUpdated(c); + await broadcastRepoSummary(c, existing ? "repoUpdated" : "repoAdded", { + repoId, + remoteUrl, + updatedAt: now, + }); return { workspaceId: c.state.workspaceId, repoId, @@ -306,10 +393,20 @@ async function createTaskMutation(c: any, input: CreateTaskInput): Promise { - assertWorkspace(c, input.workspaceId); - return await buildWorkbenchSnapshot(c); + /** + * Called by task actors when their summary-level state changes. + * This is the write path for the local materialized projection; clients read + * the projection via `getWorkspaceSummary`, but only task actors should push + * rows into it. + */ + async applyTaskSummaryUpdate(c: any, input: { taskSummary: WorkbenchTaskSummary }): Promise { + await c.db + .insert(taskSummaries) + .values(taskSummaryRowFromSummary(input.taskSummary)) + .onConflictDoUpdate({ + target: taskSummaries.taskId, + set: taskSummaryRowFromSummary(input.taskSummary), + }) + .run(); + c.broadcast("workspaceUpdated", { type: "taskSummaryUpdated", taskSummary: input.taskSummary } satisfies WorkspaceEvent); }, - async notifyWorkbenchUpdated(c: any): Promise { - c.broadcast("workbenchUpdated", { at: Date.now() }); + async removeTaskSummary(c: any, input: { taskId: string }): Promise { + await c.db.delete(taskSummaries).where(eq(taskSummaries.taskId, input.taskId)).run(); + c.broadcast("workspaceUpdated", { type: "taskRemoved", taskId: input.taskId } satisfies WorkspaceEvent); + }, + + async getWorkspaceSummary(c: any, input: WorkspaceUseInput): Promise { + assertWorkspace(c, input.workspaceId); + return await getWorkspaceSummarySnapshot(c); + }, + + async reconcileWorkbenchState(c: any, input: WorkspaceUseInput): Promise { + assertWorkspace(c, input.workspaceId); + return await reconcileWorkbenchProjection(c); }, async createWorkbenchTask(c: any, input: TaskWorkbenchCreateTaskInput): Promise<{ taskId: string; tabId?: string }> { @@ -483,11 +604,8 @@ export const workspaceActions = { ...(input.branch ? { explicitBranchName: input.branch } : {}), ...(input.model ? { agentType: agentTypeForModel(input.model) } : {}), }); - const task = await requireWorkbenchTask(c, created.taskId); - const snapshot = await task.getWorkbench({}); return { taskId: created.taskId, - tabId: snapshot.tabs[0]?.id, }; }, diff --git a/foundry/packages/backend/src/actors/workspace/app-shell.ts b/foundry/packages/backend/src/actors/workspace/app-shell.ts index 97dbb01..7f6e73f 100644 --- a/foundry/packages/backend/src/actors/workspace/app-shell.ts +++ b/foundry/packages/backend/src/actors/workspace/app-shell.ts @@ -1,5 +1,4 @@ -// @ts-nocheck -import { desc, eq } from "drizzle-orm"; +import { and, asc, count as sqlCount, desc, eq, gt, gte, inArray, isNotNull, isNull, like, lt, lte, ne, notInArray, or } from "drizzle-orm"; import { randomUUID } from "node:crypto"; import type { FoundryAppSnapshot, @@ -13,18 +12,93 @@ import type { import { getActorRuntimeContext } from "../context.js"; import { getOrCreateWorkspace, selfWorkspace } from "../handles.js"; import { GitHubAppError } from "../../services/app-github.js"; +import { getBetterAuthService } from "../../services/better-auth.js"; import { repoIdFromRemote, repoLabelFromRemote } from "../../services/repo.js"; import { logger } from "../../logging.js"; -import { appSessions, invoices, organizationMembers, organizationProfile, repos, seatAssignments, stripeLookup } from "./db/schema.js"; +import { + authAccountIndex, + authEmailIndex, + authSessionIndex, + authVerification, + invoices, + organizationMembers, + organizationProfile, + repos, + seatAssignments, + stripeLookup, +} from "./db/schema.js"; export const APP_SHELL_WORKSPACE_ID = "app"; +// ── Better Auth adapter where-clause helpers ── +// These convert the adapter's `{ field, value, operator }` clause arrays into +// Drizzle predicates for workspace-level auth index / verification tables. + +function workspaceAuthColumn(table: any, field: string): any { + const column = table[field]; + if (!column) { + throw new Error(`Unknown auth table field: ${field}`); + } + return column; +} + +function normalizeAuthValue(value: unknown): unknown { + if (value instanceof Date) { + return value.getTime(); + } + if (Array.isArray(value)) { + return value.map((entry) => normalizeAuthValue(entry)); + } + return value; +} + +function workspaceAuthClause(table: any, clause: { field: string; value: unknown; operator?: string }): any { + const column = workspaceAuthColumn(table, clause.field); + const value = normalizeAuthValue(clause.value); + switch (clause.operator) { + case "ne": + return value === null ? isNotNull(column) : ne(column, value as any); + case "lt": + return lt(column, value as any); + case "lte": + return lte(column, value as any); + case "gt": + return gt(column, value as any); + case "gte": + return gte(column, value as any); + case "in": + return inArray(column, Array.isArray(value) ? (value as any[]) : [value as any]); + case "not_in": + return notInArray(column, Array.isArray(value) ? (value as any[]) : [value as any]); + case "contains": + return like(column, `%${String(value ?? "")}%`); + case "starts_with": + return like(column, `${String(value ?? "")}%`); + case "ends_with": + return like(column, `%${String(value ?? "")}`); + case "eq": + default: + return value === null ? isNull(column) : eq(column, value as any); + } +} + +function workspaceAuthWhere(table: any, clauses: any[] | undefined): any { + if (!clauses || clauses.length === 0) { + return undefined; + } + let expr = workspaceAuthClause(table, clauses[0]); + for (const clause of clauses.slice(1)) { + const next = workspaceAuthClause(table, clause); + expr = clause.connector === "OR" ? or(expr, next) : and(expr, next); + } + return expr; +} + const githubWebhookLogger = logger.child({ scope: "github-webhook", }); const PROFILE_ROW_ID = "profile"; -const OAUTH_TTL_MS = 10 * 60_000; function roundDurationMs(start: number): number { return Math.round((performance.now() - start) * 100) / 100; @@ -58,13 +132,6 @@ function organizationWorkspaceId(kind: FoundryOrganization["kind"], login: strin return kind === "personal" ? personalWorkspaceId(login) : slugify(login); } -function splitScopes(value: string): string[] { - return value - .split(",") - .map((entry) => entry.trim()) - .filter((entry) => entry.length > 0); -} - function hasRepoScope(scopes: string[]): boolean { return scopes.some((scope) => scope === "repo" || scope.startsWith("repo:")); } @@ -85,19 +152,8 @@ function encodeEligibleOrganizationIds(value: string[]): string { return JSON.stringify([...new Set(value)]); } -function encodeOauthState(payload: { sessionId: string; nonce: string }): string { - return Buffer.from(JSON.stringify(payload), "utf8").toString("base64url"); -} - -function decodeOauthState(value: string): { sessionId: string; nonce: string } { - const parsed = JSON.parse(Buffer.from(value, "base64url").toString("utf8")) as Record; - if (typeof parsed.sessionId !== "string" || typeof parsed.nonce !== "string") { - throw new Error("GitHub OAuth state is malformed"); - } - return { - sessionId: parsed.sessionId, - nonce: parsed.nonce, - }; +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); } function seatsIncludedForPlan(planId: FoundryBillingPlanId): number { @@ -161,79 +217,103 @@ function stripeWebhookSubscription(event: any) { }; } -async function getAppSessionRow(c: any, sessionId: string) { - assertAppWorkspace(c); - return await c.db.select().from(appSessions).where(eq(appSessions.id, sessionId)).get(); -} - -async function requireAppSessionRow(c: any, sessionId: string) { - const row = await getAppSessionRow(c, sessionId); - if (!row) { - throw new Error(`Unknown app session: ${sessionId}`); - } - return row; -} - -async function ensureAppSession(c: any, requestedSessionId?: string | null): Promise { - assertAppWorkspace(c); - const requested = typeof requestedSessionId === "string" && requestedSessionId.trim().length > 0 ? requestedSessionId.trim() : null; - - if (requested) { - const existing = await getAppSessionRow(c, requested); - if (existing) { - return requested; - } - } - - const sessionId = requested ?? randomUUID(); - const now = Date.now(); - await c.db - .insert(appSessions) - .values({ - id: sessionId, - currentUserId: null, - currentUserName: null, - currentUserEmail: null, - currentUserGithubLogin: null, - currentUserRoleLabel: null, - eligibleOrganizationIdsJson: "[]", - activeOrganizationId: null, - githubAccessToken: null, - githubScope: "", - starterRepoStatus: "pending", - starterRepoStarredAt: null, - starterRepoSkippedAt: null, - oauthState: null, - oauthStateExpiresAt: null, - createdAt: now, - updatedAt: now, - }) - .onConflictDoNothing() - .run(); - return sessionId; -} - -async function updateAppSession(c: any, sessionId: string, patch: Record): Promise { - assertAppWorkspace(c); - await c.db - .update(appSessions) - .set({ - ...patch, - updatedAt: Date.now(), - }) - .where(eq(appSessions.id, sessionId)) - .run(); -} - async function getOrganizationState(workspace: any) { return await workspace.getOrganizationShellState({}); } -async function buildAppSnapshot(c: any, sessionId: string): Promise { +async function getOrganizationStateIfInitialized(workspace: any) { + return await workspace.getOrganizationShellStateIfInitialized({}); +} + +async function listSnapshotOrganizations(c: any, sessionId: string, organizationIds: string[]) { + const results = await Promise.all( + organizationIds.map(async (organizationId) => { + const organizationStartedAt = performance.now(); + try { + const workspace = await getOrCreateWorkspace(c, organizationId); + const organizationState = await getOrganizationStateIfInitialized(workspace); + if (!organizationState) { + logger.warn( + { + sessionId, + workspaceId: c.state.workspaceId, + organizationId, + durationMs: roundDurationMs(organizationStartedAt), + }, + "build_app_snapshot_organization_uninitialized", + ); + return { organizationId, snapshot: null, status: "uninitialized" as const }; + } + logger.info( + { + sessionId, + workspaceId: c.state.workspaceId, + organizationId, + durationMs: roundDurationMs(organizationStartedAt), + }, + "build_app_snapshot_organization_completed", + ); + return { organizationId, snapshot: organizationState.snapshot, status: "ok" as const }; + } catch (error) { + const message = errorMessage(error); + if (!message.includes("Actor not found")) { + logger.error( + { + sessionId, + workspaceId: c.state.workspaceId, + organizationId, + durationMs: roundDurationMs(organizationStartedAt), + errorMessage: message, + errorStack: error instanceof Error ? error.stack : undefined, + }, + "build_app_snapshot_organization_failed", + ); + throw error; + } + logger.info( + { + sessionId, + workspaceId: c.state.workspaceId, + organizationId, + durationMs: roundDurationMs(organizationStartedAt), + }, + "build_app_snapshot_organization_missing", + ); + return { organizationId, snapshot: null, status: "missing" as const }; + } + }), + ); + + return { + organizations: results.map((result) => result.snapshot).filter((organization): organization is FoundryOrganization => organization !== null), + uninitializedOrganizationIds: results.filter((result) => result.status === "uninitialized").map((result) => result.organizationId), + }; +} + +async function buildAppSnapshot(c: any, sessionId: string, allowOrganizationRepair = true): Promise { assertAppWorkspace(c); const startedAt = performance.now(); - const session = await requireAppSessionRow(c, sessionId); - const eligibleOrganizationIds = parseEligibleOrganizationIds(session.eligibleOrganizationIdsJson); + const auth = getBetterAuthService(); + let authState = await auth.getAuthState(sessionId); + // Inline fallback: if the user is signed in but has no eligible organizations yet + // (e.g. first load after OAuth callback), sync GitHub orgs before building the snapshot. + if (authState?.user && parseEligibleOrganizationIds(authState.profile?.eligibleOrganizationIdsJson ?? "[]").length === 0) { + const token = await auth.getAccessTokenForSession(sessionId); + if (token?.accessToken) { + logger.info({ sessionId }, "build_app_snapshot_sync_orgs"); + await syncGithubOrganizations(c, { sessionId, accessToken: token.accessToken }); + authState = await auth.getAuthState(sessionId); + } else { + logger.warn({ sessionId }, "build_app_snapshot_no_access_token"); + } + } + + const session = authState?.session ?? null; + const user = authState?.user ?? null; + const profile = authState?.profile ?? null; + const currentSessionState = authState?.sessionState ?? null; + const githubAccount = authState?.accounts?.find((account: any) => account.providerId === "github") ?? null; + const eligibleOrganizationIds = parseEligibleOrganizationIds(profile?.eligibleOrganizationIdsJson ?? "[]"); logger.info( { @@ -245,73 +325,53 @@ async function buildAppSnapshot(c: any, sessionId: string): Promise { - const organizationStartedAt = performance.now(); - try { - const workspace = await getOrCreateWorkspace(c, organizationId); - const organizationState = await getOrganizationState(workspace); - logger.info( - { - sessionId, - workspaceId: c.state.workspaceId, - organizationId, - durationMs: roundDurationMs(organizationStartedAt), - }, - "build_app_snapshot_organization_completed", - ); - return organizationState.snapshot; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (!message.includes("Actor not found")) { - logger.error( - { - sessionId, - workspaceId: c.state.workspaceId, - organizationId, - durationMs: roundDurationMs(organizationStartedAt), - errorMessage: message, - errorStack: error instanceof Error ? error.stack : undefined, - }, - "build_app_snapshot_organization_failed", - ); - throw error; - } - logger.info( - { - sessionId, - workspaceId: c.state.workspaceId, - organizationId, - durationMs: roundDurationMs(organizationStartedAt), - }, - "build_app_snapshot_organization_missing", - ); - return null; - } - }), - ) - ).filter((organization): organization is FoundryOrganization => organization !== null); + let { organizations, uninitializedOrganizationIds } = await listSnapshotOrganizations(c, sessionId, eligibleOrganizationIds); - const currentUser: FoundryUser | null = session.currentUserId + if (allowOrganizationRepair && uninitializedOrganizationIds.length > 0) { + const token = await auth.getAccessTokenForSession(sessionId); + if (token?.accessToken) { + logger.info( + { + sessionId, + workspaceId: c.state.workspaceId, + organizationIds: uninitializedOrganizationIds, + }, + "build_app_snapshot_repairing_organizations", + ); + await syncGithubOrganizationsInternal(c, { sessionId, accessToken: token.accessToken }, { broadcast: false }); + return await buildAppSnapshot(c, sessionId, false); + } + logger.warn( + { + sessionId, + workspaceId: c.state.workspaceId, + organizationIds: uninitializedOrganizationIds, + }, + "build_app_snapshot_repair_skipped_no_access_token", + ); + } + + const currentUser: FoundryUser | null = user ? { - id: session.currentUserId, - name: session.currentUserName ?? session.currentUserGithubLogin ?? "GitHub user", - email: session.currentUserEmail ?? "", - githubLogin: session.currentUserGithubLogin ?? "", - roleLabel: session.currentUserRoleLabel ?? "GitHub user", - eligibleOrganizationIds: organizations.map((organization) => organization.id), + id: profile?.githubAccountId ?? githubAccount?.accountId ?? user.id, + name: user.name, + email: user.email, + githubLogin: profile?.githubLogin ?? "", + roleLabel: profile?.roleLabel ?? "GitHub user", + eligibleOrganizationIds, } : null; const activeOrganizationId = - currentUser && session.activeOrganizationId && organizations.some((organization) => organization.id === session.activeOrganizationId) - ? session.activeOrganizationId + currentUser && + currentSessionState?.activeOrganizationId && + organizations.some((organization) => organization.id === currentSessionState.activeOrganizationId) + ? currentSessionState.activeOrganizationId : currentUser && organizations.length === 1 ? (organizations[0]?.id ?? null) : null; - const snapshot = { + const snapshot: FoundryAppSnapshot = { auth: { status: currentUser ? "signed_in" : "signed_out", currentUserId: currentUser?.id ?? null, @@ -321,9 +381,9 @@ async function buildAppSnapshot(c: any, sessionId: string): Promise account.providerId === "github") ?? null; + if (!authState?.session || !user?.email) { throw new Error("User must be signed in"); } - return session; + const token = await auth.getAccessTokenForSession(sessionId); + return { + ...authState.session, + authUserId: user.id, + currentUserId: profile?.githubAccountId ?? githubAccount?.accountId ?? user.id, + currentUserName: user.name, + currentUserEmail: user.email, + currentUserGithubLogin: profile?.githubLogin ?? "", + currentUserRoleLabel: profile?.roleLabel ?? "GitHub user", + eligibleOrganizationIdsJson: profile?.eligibleOrganizationIdsJson ?? "[]", + githubAccessToken: token?.accessToken ?? null, + githubScope: (token?.scopes ?? []).join(","), + starterRepoStatus: profile?.starterRepoStatus ?? "pending", + starterRepoStarredAt: profile?.starterRepoStarredAt ?? null, + starterRepoSkippedAt: profile?.starterRepoSkippedAt ?? null, + }; } function requireEligibleOrganization(session: any, organizationId: string): void { @@ -431,54 +510,30 @@ async function safeListInstallations(accessToken: string): Promise { } } -/** - * Fast path: resolve viewer identity, store user + token in the session, - * and return the redirect URL. Does NOT sync organizations — that work is - * deferred to `syncGithubOrganizations` via the workflow queue so the HTTP - * callback can respond before any proxy timeout triggers a retry. - */ -async function initGithubSession(c: any, sessionId: string, accessToken: string, scopes: string[]): Promise<{ sessionId: string; redirectTo: string }> { - assertAppWorkspace(c); - const { appShell } = getActorRuntimeContext(); - const viewer = await appShell.github.getViewer(accessToken); - const userId = `user-${slugify(viewer.login)}`; - - await updateAppSession(c, sessionId, { - currentUserId: userId, - currentUserName: viewer.name || viewer.login, - currentUserEmail: viewer.email ?? `${viewer.login}@users.noreply.github.com`, - currentUserGithubLogin: viewer.login, - currentUserRoleLabel: "GitHub user", - githubAccessToken: accessToken, - githubScope: scopes.join(","), - oauthState: null, - oauthStateExpiresAt: null, - }); - - return { - sessionId, - redirectTo: `${appShell.appUrl}/organizations?foundrySession=${encodeURIComponent(sessionId)}`, - }; -} - /** * Slow path: list GitHub orgs + installations, sync each org workspace, * and update the session's eligible organization list. Called from the * workflow queue so it runs in the background after the callback has * already returned a redirect to the browser. - * - * Also used synchronously by bootstrapAppGithubSession (dev-only) where - * proxy timeouts are not a concern. */ export async function syncGithubOrganizations(c: any, input: { sessionId: string; accessToken: string }): Promise { + await syncGithubOrganizationsInternal(c, input, { broadcast: true }); +} + +async function syncGithubOrganizationsInternal(c: any, input: { sessionId: string; accessToken: string }, options: { broadcast: boolean }): Promise { assertAppWorkspace(c); + const auth = getBetterAuthService(); const { appShell } = getActorRuntimeContext(); const { sessionId, accessToken } = input; - const session = await requireAppSessionRow(c, sessionId); + const authState = await auth.getAuthState(sessionId); + if (!authState?.user) { + throw new Error("User must be signed in"); + } const viewer = await appShell.github.getViewer(accessToken); const organizations = await safeListOrganizations(accessToken); const installations = await safeListInstallations(accessToken); - const userId = `user-${slugify(viewer.login)}`; + const authUserId = authState.user.id; + const githubUserId = String(viewer.id); const linkedOrganizationIds: string[] = []; const accounts = [ @@ -503,7 +558,7 @@ export async function syncGithubOrganizations(c: any, input: { sessionId: string const installation = installations.find((candidate) => candidate.accountLogin === account.githubLogin) ?? null; const workspace = await getOrCreateWorkspace(c, organizationId); await workspace.syncOrganizationShellFromGithub({ - userId, + userId: githubUserId, userName: viewer.name || viewer.login, userEmail: viewer.email ?? `${viewer.login}@users.noreply.github.com`, githubUserLogin: viewer.login, @@ -519,15 +574,25 @@ export async function syncGithubOrganizations(c: any, input: { sessionId: string } const activeOrganizationId = - session.activeOrganizationId && linkedOrganizationIds.includes(session.activeOrganizationId) - ? session.activeOrganizationId + authState.sessionState?.activeOrganizationId && linkedOrganizationIds.includes(authState.sessionState.activeOrganizationId) + ? authState.sessionState.activeOrganizationId : linkedOrganizationIds.length === 1 ? (linkedOrganizationIds[0] ?? null) : null; - await updateAppSession(c, sessionId, { + await auth.setActiveOrganization(sessionId, activeOrganizationId); + await auth.upsertUserProfile(authUserId, { + githubAccountId: String(viewer.id), + githubLogin: viewer.login, + roleLabel: "GitHub user", eligibleOrganizationIdsJson: encodeEligibleOrganizationIds(linkedOrganizationIds), - activeOrganizationId, + }); + if (!options.broadcast) { + return; + } + c.broadcast("appUpdated", { + type: "appUpdated", + snapshot: await buildAppSnapshot(c, sessionId), }); } @@ -571,6 +636,12 @@ export async function syncGithubOrganizationRepos(c: any, input: { sessionId: st installationStatus, lastSyncLabel: repositories.length > 0 ? "Synced just now" : "No repositories available", }); + + // Broadcast updated app snapshot so connected clients see the new repos + c.broadcast("appUpdated", { + type: "appUpdated", + snapshot: await buildAppSnapshot(c, input.sessionId), + }); } catch (error) { const installationStatus = error instanceof GitHubAppError && (error.status === 403 || error.status === 404) @@ -580,20 +651,13 @@ export async function syncGithubOrganizationRepos(c: any, input: { sessionId: st message: error instanceof Error ? error.message : "GitHub import failed", installationStatus, }); - } -} -/** - * Full synchronous sync: init session + sync orgs in one call. - * Used by bootstrapAppGithubSession (dev-only) where there is no proxy - * timeout concern and we want the session fully populated before returning. - */ -async function syncGithubSessionFromToken(c: any, sessionId: string, accessToken: string): Promise<{ sessionId: string; redirectTo: string }> { - const session = await requireAppSessionRow(c, sessionId); - const scopes = splitScopes(session.githubScope); - const result = await initGithubSession(c, sessionId, accessToken, scopes); - await syncGithubOrganizations(c, { sessionId, accessToken }); - return result; + // Broadcast sync failure so the client updates status + c.broadcast("appUpdated", { + type: "appUpdated", + snapshot: await buildAppSnapshot(c, input.sessionId), + }); + } } async function readOrganizationProfileRow(c: any) { @@ -648,6 +712,19 @@ async function listOrganizationRepoCatalog(c: any): Promise { async function buildOrganizationState(c: any) { const startedAt = performance.now(); const row = await requireOrganizationProfileRow(c); + return await buildOrganizationStateFromRow(c, row, startedAt); +} + +async function buildOrganizationStateIfInitialized(c: any) { + const startedAt = performance.now(); + const row = await readOrganizationProfileRow(c); + if (!row) { + return null; + } + return await buildOrganizationStateFromRow(c, row, startedAt); +} + +async function buildOrganizationStateFromRow(c: any, row: any, startedAt: number) { const repoCatalog = await listOrganizationRepoCatalog(c); const members = await listOrganizationMembers(c); const seatAssignmentEmails = await listOrganizationSeatAssignments(c); @@ -736,9 +813,253 @@ async function applySubscriptionState( } export const workspaceAppActions = { - async ensureAppSession(c: any, input?: { requestedSessionId?: string | null }): Promise<{ sessionId: string }> { - const sessionId = await ensureAppSession(c, input?.requestedSessionId); - return { sessionId }; + async authFindSessionIndex(c: any, input: { sessionId?: string; sessionToken?: string }) { + assertAppWorkspace(c); + + const clauses = [ + ...(input.sessionId ? [{ field: "sessionId", value: input.sessionId }] : []), + ...(input.sessionToken ? [{ field: "sessionToken", value: input.sessionToken }] : []), + ]; + if (clauses.length === 0) { + return null; + } + const predicate = workspaceAuthWhere(authSessionIndex, clauses); + return await c.db.select().from(authSessionIndex).where(predicate!).get(); + }, + + async authUpsertSessionIndex(c: any, input: { sessionId: string; sessionToken: string; userId: string }) { + assertAppWorkspace(c); + + const now = Date.now(); + await c.db + .insert(authSessionIndex) + .values({ + sessionId: input.sessionId, + sessionToken: input.sessionToken, + userId: input.userId, + createdAt: now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: authSessionIndex.sessionId, + set: { + sessionToken: input.sessionToken, + userId: input.userId, + updatedAt: now, + }, + }) + .run(); + return await c.db.select().from(authSessionIndex).where(eq(authSessionIndex.sessionId, input.sessionId)).get(); + }, + + async authDeleteSessionIndex(c: any, input: { sessionId?: string; sessionToken?: string }) { + assertAppWorkspace(c); + + const clauses = [ + ...(input.sessionId ? [{ field: "sessionId", value: input.sessionId }] : []), + ...(input.sessionToken ? [{ field: "sessionToken", value: input.sessionToken }] : []), + ]; + if (clauses.length === 0) { + return; + } + const predicate = workspaceAuthWhere(authSessionIndex, clauses); + await c.db.delete(authSessionIndex).where(predicate!).run(); + }, + + async authFindEmailIndex(c: any, input: { email: string }) { + assertAppWorkspace(c); + + return await c.db.select().from(authEmailIndex).where(eq(authEmailIndex.email, input.email)).get(); + }, + + async authUpsertEmailIndex(c: any, input: { email: string; userId: string }) { + assertAppWorkspace(c); + + const now = Date.now(); + await c.db + .insert(authEmailIndex) + .values({ + email: input.email, + userId: input.userId, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: authEmailIndex.email, + set: { + userId: input.userId, + updatedAt: now, + }, + }) + .run(); + return await c.db.select().from(authEmailIndex).where(eq(authEmailIndex.email, input.email)).get(); + }, + + async authDeleteEmailIndex(c: any, input: { email: string }) { + assertAppWorkspace(c); + + await c.db.delete(authEmailIndex).where(eq(authEmailIndex.email, input.email)).run(); + }, + + async authFindAccountIndex(c: any, input: { id?: string; providerId?: string; accountId?: string }) { + assertAppWorkspace(c); + + if (input.id) { + return await c.db.select().from(authAccountIndex).where(eq(authAccountIndex.id, input.id)).get(); + } + if (!input.providerId || !input.accountId) { + return null; + } + return await c.db + .select() + .from(authAccountIndex) + .where(and(eq(authAccountIndex.providerId, input.providerId), eq(authAccountIndex.accountId, input.accountId))) + .get(); + }, + + async authUpsertAccountIndex(c: any, input: { id: string; providerId: string; accountId: string; userId: string }) { + assertAppWorkspace(c); + + const now = Date.now(); + await c.db + .insert(authAccountIndex) + .values({ + id: input.id, + providerId: input.providerId, + accountId: input.accountId, + userId: input.userId, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: authAccountIndex.id, + set: { + providerId: input.providerId, + accountId: input.accountId, + userId: input.userId, + updatedAt: now, + }, + }) + .run(); + return await c.db.select().from(authAccountIndex).where(eq(authAccountIndex.id, input.id)).get(); + }, + + async authDeleteAccountIndex(c: any, input: { id?: string; providerId?: string; accountId?: string }) { + assertAppWorkspace(c); + + if (input.id) { + await c.db.delete(authAccountIndex).where(eq(authAccountIndex.id, input.id)).run(); + return; + } + if (input.providerId && input.accountId) { + await c.db + .delete(authAccountIndex) + .where(and(eq(authAccountIndex.providerId, input.providerId), eq(authAccountIndex.accountId, input.accountId))) + .run(); + } + }, + + async authCreateVerification(c: any, input: { data: Record }) { + assertAppWorkspace(c); + + await c.db + .insert(authVerification) + .values(input.data as any) + .run(); + return await c.db + .select() + .from(authVerification) + .where(eq(authVerification.id, input.data.id as string)) + .get(); + }, + + async authFindOneVerification(c: any, input: { where: any[] }) { + assertAppWorkspace(c); + + const predicate = workspaceAuthWhere(authVerification, input.where); + return predicate ? await c.db.select().from(authVerification).where(predicate).get() : null; + }, + + async authFindManyVerification(c: any, input: { where?: any[]; limit?: number; sortBy?: any; offset?: number }) { + assertAppWorkspace(c); + + const predicate = workspaceAuthWhere(authVerification, input.where); + let query = c.db.select().from(authVerification); + if (predicate) { + query = query.where(predicate); + } + if (input.sortBy?.field) { + const column = workspaceAuthColumn(authVerification, input.sortBy.field); + query = query.orderBy(input.sortBy.direction === "asc" ? asc(column) : desc(column)); + } + if (typeof input.limit === "number") { + query = query.limit(input.limit); + } + if (typeof input.offset === "number") { + query = query.offset(input.offset); + } + return await query.all(); + }, + + async authUpdateVerification(c: any, input: { where: any[]; update: Record }) { + assertAppWorkspace(c); + + const predicate = workspaceAuthWhere(authVerification, input.where); + if (!predicate) { + return null; + } + await c.db + .update(authVerification) + .set(input.update as any) + .where(predicate) + .run(); + return await c.db.select().from(authVerification).where(predicate).get(); + }, + + async authUpdateManyVerification(c: any, input: { where: any[]; update: Record }) { + assertAppWorkspace(c); + + const predicate = workspaceAuthWhere(authVerification, input.where); + if (!predicate) { + return 0; + } + await c.db + .update(authVerification) + .set(input.update as any) + .where(predicate) + .run(); + const row = await c.db.select({ value: sqlCount() }).from(authVerification).where(predicate).get(); + return row?.value ?? 0; + }, + + async authDeleteVerification(c: any, input: { where: any[] }) { + assertAppWorkspace(c); + + const predicate = workspaceAuthWhere(authVerification, input.where); + if (!predicate) { + return; + } + await c.db.delete(authVerification).where(predicate).run(); + }, + + async authDeleteManyVerification(c: any, input: { where: any[] }) { + assertAppWorkspace(c); + + const predicate = workspaceAuthWhere(authVerification, input.where); + if (!predicate) { + return 0; + } + const rows = await c.db.select().from(authVerification).where(predicate).all(); + await c.db.delete(authVerification).where(predicate).run(); + return rows.length; + }, + + async authCountVerification(c: any, input: { where?: any[] }) { + assertAppWorkspace(c); + + const predicate = workspaceAuthWhere(authVerification, input.where); + const row = predicate + ? await c.db.select({ value: sqlCount() }).from(authVerification).where(predicate).get() + : await c.db.select({ value: sqlCount() }).from(authVerification).get(); + return row?.value ?? 0; }, async getAppSnapshot(c: any, input: { sessionId: string }): Promise { @@ -750,20 +1071,27 @@ export const workspaceAppActions = { input: { organizationId: string; requireRepoScope?: boolean }, ): Promise<{ accessToken: string; scopes: string[] } | null> { assertAppWorkspace(c); - const rows = await c.db.select().from(appSessions).orderBy(desc(appSessions.updatedAt)).all(); + const auth = getBetterAuthService(); + const rows = await c.db.select().from(authSessionIndex).orderBy(desc(authSessionIndex.updatedAt)).all(); for (const row of rows) { - if (row.activeOrganizationId !== input.organizationId || !row.githubAccessToken) { + const authState = await auth.getAuthState(row.sessionId); + if (authState?.sessionState?.activeOrganizationId !== input.organizationId) { continue; } - const scopes = splitScopes(row.githubScope); - if (input.requireRepoScope !== false && !hasRepoScope(scopes)) { + const token = await auth.getAccessTokenForSession(row.sessionId); + if (!token?.accessToken) { + continue; + } + + const scopes = token.scopes; + if (input.requireRepoScope !== false && scopes.length > 0 && !hasRepoScope(scopes)) { continue; } return { - accessToken: row.githubAccessToken, + accessToken: token.accessToken, scopes, }; } @@ -771,97 +1099,10 @@ export const workspaceAppActions = { return null; }, - async startAppGithubAuth(c: any, input: { sessionId: string }): Promise<{ url: string }> { - assertAppWorkspace(c); - const { appShell } = getActorRuntimeContext(); - const sessionId = await ensureAppSession(c, input.sessionId); - const nonce = randomUUID(); - await updateAppSession(c, sessionId, { - oauthState: nonce, - oauthStateExpiresAt: Date.now() + OAUTH_TTL_MS, - }); - return { - url: appShell.github.buildAuthorizeUrl(encodeOauthState({ sessionId, nonce })), - }; - }, - - async completeAppGithubAuth(c: any, input: { code: string; state: string }): Promise<{ sessionId: string; redirectTo: string }> { - assertAppWorkspace(c); - const { appShell } = getActorRuntimeContext(); - const oauth = decodeOauthState(input.state); - const session = await requireAppSessionRow(c, oauth.sessionId); - if (!session.oauthState || session.oauthState !== oauth.nonce || !session.oauthStateExpiresAt || session.oauthStateExpiresAt < Date.now()) { - throw new Error("GitHub OAuth state is invalid or expired"); - } - - // Clear state before exchangeCode — GitHub codes are single-use and - // duplicate callback requests (from proxy retries or user refresh) - // must fail the state check rather than attempt a second exchange. - // See research/friction/general.mdx 2026-03-13 entry. - await updateAppSession(c, session.id, { - oauthState: null, - oauthStateExpiresAt: null, - }); - - const token = await appShell.github.exchangeCode(input.code); - - // Fast path: store token + user identity and return the redirect - // immediately. The slow org sync (list orgs, list installations, - // sync each workspace) runs in the workflow queue so the HTTP - // response lands before any proxy/infra timeout triggers a retry. - // The frontend already polls when it sees syncStatus === "syncing". - const result = await initGithubSession(c, session.id, token.accessToken, token.scopes); - - // Enqueue the slow org sync to the workflow. fire-and-forget (wait: false) - // because the redirect does not depend on org data — the frontend will - // poll getAppSnapshot until organizations are populated. - const self = selfWorkspace(c); - await self.send( - "workspace.command.syncGithubSession", - { sessionId: session.id, accessToken: token.accessToken }, - { - wait: false, - }, - ); - - return result; - }, - - async bootstrapAppGithubSession(c: any, input: { accessToken: string; sessionId?: string | null }): Promise<{ sessionId: string; redirectTo: string }> { - assertAppWorkspace(c); - if (process.env.NODE_ENV === "production") { - throw new Error("bootstrapAppGithubSession is development-only"); - } - const sessionId = await ensureAppSession(c, input.sessionId ?? null); - return await syncGithubSessionFromToken(c, sessionId, input.accessToken); - }, - - async signOutApp(c: any, input: { sessionId: string }): Promise { - assertAppWorkspace(c); - const sessionId = await ensureAppSession(c, input.sessionId); - await updateAppSession(c, sessionId, { - currentUserId: null, - currentUserName: null, - currentUserEmail: null, - currentUserGithubLogin: null, - currentUserRoleLabel: null, - eligibleOrganizationIdsJson: "[]", - activeOrganizationId: null, - githubAccessToken: null, - githubScope: "", - starterRepoStatus: "pending", - starterRepoStarredAt: null, - starterRepoSkippedAt: null, - oauthState: null, - oauthStateExpiresAt: null, - }); - return await buildAppSnapshot(c, sessionId); - }, - async skipAppStarterRepo(c: any, input: { sessionId: string }): Promise { assertAppWorkspace(c); - await requireSignedInSession(c, input.sessionId); - await updateAppSession(c, input.sessionId, { + const session = await requireSignedInSession(c, input.sessionId); + await getBetterAuthService().upsertUserProfile(session.authUserId, { starterRepoStatus: "skipped", starterRepoSkippedAt: Date.now(), starterRepoStarredAt: null, @@ -877,7 +1118,7 @@ export const workspaceAppActions = { await workspace.starSandboxAgentRepo({ workspaceId: input.organizationId, }); - await updateAppSession(c, input.sessionId, { + await getBetterAuthService().upsertUserProfile(session.authUserId, { starterRepoStatus: "starred", starterRepoStarredAt: Date.now(), starterRepoSkippedAt: null, @@ -889,9 +1130,7 @@ export const workspaceAppActions = { assertAppWorkspace(c); const session = await requireSignedInSession(c, input.sessionId); requireEligibleOrganization(session, input.organizationId); - await updateAppSession(c, input.sessionId, { - activeOrganizationId: input.organizationId, - }); + await getBetterAuthService().setActiveOrganization(input.sessionId, input.organizationId); const workspace = await getOrCreateWorkspace(c, input.organizationId); const organization = await getOrganizationState(workspace); @@ -968,7 +1207,7 @@ export const workspaceAppActions = { const organization = await getOrganizationState(workspace); if (organization.snapshot.kind !== "organization") { return { - url: `${appShell.appUrl}/workspaces/${input.organizationId}?foundrySession=${encodeURIComponent(input.sessionId)}`, + url: `${appShell.appUrl}/workspaces/${input.organizationId}`, }; } return { @@ -987,7 +1226,7 @@ export const workspaceAppActions = { if (input.planId === "free") { await workspace.applyOrganizationFreePlan({ clearSubscription: false }); return { - url: `${appShell.appUrl}/organizations/${input.organizationId}/billing?foundrySession=${encodeURIComponent(input.sessionId)}`, + url: `${appShell.appUrl}/organizations/${input.organizationId}/billing`, }; } @@ -1017,8 +1256,8 @@ export const workspaceAppActions = { planId: input.planId, successUrl: `${appShell.apiUrl}/v1/billing/checkout/complete?organizationId=${encodeURIComponent( input.organizationId, - )}&foundrySession=${encodeURIComponent(input.sessionId)}&session_id={CHECKOUT_SESSION_ID}`, - cancelUrl: `${appShell.appUrl}/organizations/${input.organizationId}/billing?foundrySession=${encodeURIComponent(input.sessionId)}`, + )}&session_id={CHECKOUT_SESSION_ID}`, + cancelUrl: `${appShell.appUrl}/organizations/${input.organizationId}/billing`, }) .then((checkout) => checkout.url), }; @@ -1048,7 +1287,7 @@ export const workspaceAppActions = { } return { - redirectTo: `${appShell.appUrl}/organizations/${input.organizationId}/billing?foundrySession=${encodeURIComponent(input.sessionId)}`, + redirectTo: `${appShell.appUrl}/organizations/${input.organizationId}/billing`, }; }, @@ -1064,7 +1303,7 @@ export const workspaceAppActions = { } const portal = await appShell.stripe.createPortalSession({ customerId: organization.stripeCustomerId, - returnUrl: `${appShell.appUrl}/organizations/${input.organizationId}/billing?foundrySession=${encodeURIComponent(input.sessionId)}`, + returnUrl: `${appShell.appUrl}/organizations/${input.organizationId}/billing`, }); return { url: portal.url }; }, @@ -1426,6 +1665,11 @@ export const workspaceAppActions = { return await buildOrganizationState(c); }, + async getOrganizationShellStateIfInitialized(c: any): Promise { + assertOrganizationWorkspace(c); + return await buildOrganizationStateIfInitialized(c); + }, + async updateOrganizationShellProfile(c: any, input: Pick): Promise { assertOrganizationWorkspace(c); const existing = await requireOrganizationProfileRow(c); diff --git a/foundry/packages/backend/src/actors/workspace/db/migrations.ts b/foundry/packages/backend/src/actors/workspace/db/migrations.ts index 6832f80..607eb19 100644 --- a/foundry/packages/backend/src/actors/workspace/db/migrations.ts +++ b/foundry/packages/backend/src/actors/workspace/db/migrations.ts @@ -10,6 +10,18 @@ const journal = { tag: "0000_melted_viper", breakpoints: true, }, + { + idx: 1, + when: 1773638400000, + tag: "0001_auth_index_tables", + breakpoints: true, + }, + { + idx: 2, + when: 1773720000000, + tag: "0002_task_summaries", + breakpoints: true, + }, ], } as const; @@ -113,6 +125,49 @@ CREATE TABLE \`task_lookup\` ( \`task_id\` text PRIMARY KEY NOT NULL, \`repo_id\` text NOT NULL ); +`, + m0001: `CREATE TABLE IF NOT EXISTS \`auth_session_index\` ( + \`session_id\` text PRIMARY KEY NOT NULL, + \`session_token\` text NOT NULL, + \`user_id\` text NOT NULL, + \`created_at\` integer NOT NULL, + \`updated_at\` integer NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS \`auth_email_index\` ( + \`email\` text PRIMARY KEY NOT NULL, + \`user_id\` text NOT NULL, + \`updated_at\` integer NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS \`auth_account_index\` ( + \`id\` text PRIMARY KEY NOT NULL, + \`provider_id\` text NOT NULL, + \`account_id\` text NOT NULL, + \`user_id\` text NOT NULL, + \`updated_at\` integer NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS \`auth_verification\` ( + \`id\` text PRIMARY KEY NOT NULL, + \`identifier\` text NOT NULL, + \`value\` text NOT NULL, + \`expires_at\` integer NOT NULL, + \`created_at\` integer NOT NULL, + \`updated_at\` integer NOT NULL +); +`, + m0002: `CREATE TABLE IF NOT EXISTS \`task_summaries\` ( + \`task_id\` text PRIMARY KEY NOT NULL, + \`repo_id\` text NOT NULL, + \`title\` text NOT NULL, + \`status\` text NOT NULL, + \`repo_name\` text NOT NULL, + \`updated_at_ms\` integer NOT NULL, + \`branch\` text, + \`pull_request_json\` text, + \`sessions_summary_json\` text DEFAULT '[]' NOT NULL +); `, } as const, }; diff --git a/foundry/packages/backend/src/actors/workspace/db/schema.ts b/foundry/packages/backend/src/actors/workspace/db/schema.ts index 5f8cf66..93082af 100644 --- a/foundry/packages/backend/src/actors/workspace/db/schema.ts +++ b/foundry/packages/backend/src/actors/workspace/db/schema.ts @@ -20,6 +20,23 @@ export const taskLookup = sqliteTable("task_lookup", { repoId: text("repo_id").notNull(), }); +/** + * Materialized sidebar projection maintained by task actors. + * The source of truth still lives on each task actor; this table exists so + * workspace reads can stay local and avoid fan-out across child actors. + */ +export const taskSummaries = sqliteTable("task_summaries", { + taskId: text("task_id").notNull().primaryKey(), + repoId: text("repo_id").notNull(), + title: text("title").notNull(), + status: text("status").notNull(), + repoName: text("repo_name").notNull(), + updatedAtMs: integer("updated_at_ms").notNull(), + branch: text("branch"), + pullRequestJson: text("pull_request_json"), + sessionsSummaryJson: text("sessions_summary_json").notNull().default("[]"), +}); + export const organizationProfile = sqliteTable("organization_profile", { id: text("id").notNull().primaryKey(), kind: text("kind").notNull(), @@ -74,23 +91,33 @@ export const invoices = sqliteTable("invoices", { createdAt: integer("created_at").notNull(), }); -export const appSessions = sqliteTable("app_sessions", { +export const authSessionIndex = sqliteTable("auth_session_index", { + sessionId: text("session_id").notNull().primaryKey(), + sessionToken: text("session_token").notNull(), + userId: text("user_id").notNull(), + createdAt: integer("created_at").notNull(), + updatedAt: integer("updated_at").notNull(), +}); + +export const authEmailIndex = sqliteTable("auth_email_index", { + email: text("email").notNull().primaryKey(), + userId: text("user_id").notNull(), + updatedAt: integer("updated_at").notNull(), +}); + +export const authAccountIndex = sqliteTable("auth_account_index", { id: text("id").notNull().primaryKey(), - currentUserId: text("current_user_id"), - currentUserName: text("current_user_name"), - currentUserEmail: text("current_user_email"), - currentUserGithubLogin: text("current_user_github_login"), - currentUserRoleLabel: text("current_user_role_label"), - // Structured as a JSON array of eligible organization ids for the session. - eligibleOrganizationIdsJson: text("eligible_organization_ids_json").notNull(), - activeOrganizationId: text("active_organization_id"), - githubAccessToken: text("github_access_token"), - githubScope: text("github_scope").notNull(), - starterRepoStatus: text("starter_repo_status").notNull(), - starterRepoStarredAt: integer("starter_repo_starred_at"), - starterRepoSkippedAt: integer("starter_repo_skipped_at"), - oauthState: text("oauth_state"), - oauthStateExpiresAt: integer("oauth_state_expires_at"), + providerId: text("provider_id").notNull(), + accountId: text("account_id").notNull(), + userId: text("user_id").notNull(), + updatedAt: integer("updated_at").notNull(), +}); + +export const authVerification = sqliteTable("auth_verification", { + id: text("id").notNull().primaryKey(), + identifier: text("identifier").notNull(), + value: text("value").notNull(), + expiresAt: integer("expires_at").notNull(), createdAt: integer("created_at").notNull(), updatedAt: integer("updated_at").notNull(), }); diff --git a/foundry/packages/backend/src/index.ts b/foundry/packages/backend/src/index.ts index 17acb4a..9875c0a 100644 --- a/foundry/packages/backend/src/index.ts +++ b/foundry/packages/backend/src/index.ts @@ -10,6 +10,7 @@ import { createDefaultDriver } from "./driver.js"; import { createProviderRegistry } from "./providers/index.js"; import { createClient } from "rivetkit/client"; import type { FoundryBillingPlanId } from "@sandbox-agent/foundry-shared"; +import { initBetterAuthService } from "./services/better-auth.js"; import { createDefaultAppShellServices } from "./services/app-shell-runtime.js"; import { APP_SHELL_WORKSPACE_ID } from "./actors/workspace/app-shell.js"; import { logger } from "./logging.js"; @@ -39,33 +40,15 @@ interface AppWorkspaceLogContext { xRealIp?: string; } +function stripTrailingSlash(value: string): string { + return value.replace(/\/$/, ""); +} + function isRivetRequest(request: Request): boolean { const { pathname } = new URL(request.url); return pathname === "/v1/rivet" || pathname.startsWith("/v1/rivet/"); } -function isRetryableAppActorError(error: unknown): boolean { - const message = error instanceof Error ? error.message : String(error); - return message.includes("Actor not ready") || message.includes("socket connection was closed unexpectedly"); -} - -async function withRetries(run: () => Promise, attempts = 20, delayMs = 250): Promise { - let lastError: unknown; - for (let attempt = 1; attempt <= attempts; attempt += 1) { - try { - return await run(); - } catch (error) { - lastError = error; - if (!isRetryableAppActorError(error) || attempt === attempts) { - throw error; - } - await new Promise((resolve) => setTimeout(resolve, delayMs)); - } - } - - throw lastError instanceof Error ? lastError : new Error(String(lastError)); -} - export async function startBackend(options: BackendStartOptions = {}): Promise { // sandbox-agent agent plugins vary on which env var they read for OpenAI/Codex auth. // Normalize to keep local dev + docker-compose simple. @@ -94,11 +77,16 @@ export async function startBackend(options: BackendStartOptions = {}): Promise ({ cfConnectingIp: c.req.header("cf-connecting-ip") ?? undefined, @@ -131,29 +119,18 @@ export async function startBackend(options: BackendStartOptions = {}): Promise origin ?? "*", - credentials: true, - allowHeaders, - allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], - exposeHeaders, - }), - ); - app.use( - "/v1", - cors({ - origin: (origin) => origin ?? "*", - credentials: true, - allowHeaders, - allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], - exposeHeaders, - }), - ); + const exposeHeaders = ["Content-Type", "x-rivet-ray-id"]; + const allowedOrigins = new Set([stripTrailingSlash(appShellServices.appUrl), stripTrailingSlash(appShellServices.apiUrl)]); + const corsConfig = { + origin: (origin: string) => (allowedOrigins.has(origin) ? origin : null) as string | undefined | null, + credentials: true, + allowHeaders, + allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], + exposeHeaders, + }; + app.use("/v1/*", cors(corsConfig)); + app.use("/v1", cors(corsConfig)); app.use("*", async (c, next) => { const requestId = c.req.header("x-request-id")?.trim() || randomUUID(); const start = performance.now(); @@ -190,6 +167,9 @@ export async function startBackend(options: BackendStartOptions = {}): Promise { @@ -197,12 +177,9 @@ export async function startBackend(options: BackendStartOptions = {}): Promise - await actorClient.workspace.getOrCreate(workspaceKey(APP_SHELL_WORKSPACE_ID), { - createWithInput: APP_SHELL_WORKSPACE_ID, - }), - ); + const handle = await actorClient.workspace.getOrCreate(workspaceKey(APP_SHELL_WORKSPACE_ID), { + createWithInput: APP_SHELL_WORKSPACE_ID, + }); cachedAppWorkspace = handle; logger.info( { @@ -253,68 +230,70 @@ export async function startBackend(options: BackendStartOptions = {}): Promise => { - const requested = c.req.header("x-foundry-session"); - const { sessionId } = await appWorkspaceAction( - "ensureAppSession", - async (workspace) => await workspace.ensureAppSession(requested && requested.trim().length > 0 ? { requestedSessionId: requested } : {}), - requestLogContext(c), - ); - c.header("x-foundry-session", sessionId); - return sessionId; + const resolveSessionId = async (c: any): Promise => { + const session = await betterAuth.resolveSession(c.req.raw.headers); + return session?.session?.id ?? null; }; app.get("/v1/app/snapshot", async (c) => { const sessionId = await resolveSessionId(c); + if (!sessionId) { + return c.json({ + auth: { status: "signed_out", currentUserId: null }, + activeOrganizationId: null, + onboarding: { + starterRepo: { + repoFullName: "rivet-dev/sandbox-agent", + repoUrl: "https://github.com/rivet-dev/sandbox-agent", + status: "pending", + starredAt: null, + skippedAt: null, + }, + }, + users: [], + organizations: [], + }); + } return c.json( await appWorkspaceAction("getAppSnapshot", async (workspace) => await workspace.getAppSnapshot({ sessionId }), requestLogContext(c, sessionId)), ); }); - app.get("/v1/auth/github/start", async (c) => { - const sessionId = await resolveSessionId(c); - const result = await appWorkspaceAction( - "startAppGithubAuth", - async (workspace) => await workspace.startAppGithubAuth({ sessionId }), - requestLogContext(c, sessionId), - ); - return Response.redirect(result.url, 302); + app.all("/v1/auth/*", async (c) => { + return await betterAuth.auth.handler(c.req.raw); }); - const handleGithubAuthCallback = async (c: any) => { - // TEMPORARY: dump all request headers to diagnose duplicate callback requests - // (Railway nginx proxy_next_upstream? Cloudflare retry? browser?) - // Remove once root cause is identified. - const allHeaders: Record = {}; - c.req.raw.headers.forEach((value: string, key: string) => { - allHeaders[key] = value; - }); - logger.info({ headers: allHeaders, url: c.req.url }, "github_callback_headers"); - - const code = c.req.query("code"); - const state = c.req.query("state"); - if (!code || !state) { - return c.text("Missing GitHub OAuth callback parameters", 400); - } - const result = await appWorkspaceAction( - "completeAppGithubAuth", - async (workspace) => await workspace.completeAppGithubAuth({ code, state }), - requestLogContext(c), - ); - c.header("x-foundry-session", result.sessionId); - return Response.redirect(result.redirectTo, 302); - }; - - app.get("/v1/auth/github/callback", handleGithubAuthCallback); - app.get("/api/auth/callback/github", handleGithubAuthCallback); - app.post("/v1/app/sign-out", async (c) => { const sessionId = await resolveSessionId(c); - return c.json(await appWorkspaceAction("signOutApp", async (workspace) => await workspace.signOutApp({ sessionId }), requestLogContext(c, sessionId))); + if (sessionId) { + const signOutResponse = await betterAuth.signOut(c.req.raw.headers); + const setCookie = signOutResponse.headers.get("set-cookie"); + if (setCookie) { + c.header("set-cookie", setCookie); + } + } + return c.json({ + auth: { status: "signed_out", currentUserId: null }, + activeOrganizationId: null, + onboarding: { + starterRepo: { + repoFullName: "rivet-dev/sandbox-agent", + repoUrl: "https://github.com/rivet-dev/sandbox-agent", + status: "pending", + starredAt: null, + skippedAt: null, + }, + }, + users: [], + organizations: [], + }); }); app.post("/v1/app/onboarding/starter-repo/skip", async (c) => { const sessionId = await resolveSessionId(c); + if (!sessionId) { + return c.text("Unauthorized", 401); + } return c.json( await appWorkspaceAction("skipAppStarterRepo", async (workspace) => await workspace.skipAppStarterRepo({ sessionId }), requestLogContext(c, sessionId)), ); @@ -322,6 +301,9 @@ export async function startBackend(options: BackendStartOptions = {}): Promise { const sessionId = await resolveSessionId(c); + if (!sessionId) { + return c.text("Unauthorized", 401); + } return c.json( await appWorkspaceAction( "starAppStarterRepo", @@ -337,6 +319,9 @@ export async function startBackend(options: BackendStartOptions = {}): Promise { const sessionId = await resolveSessionId(c); + if (!sessionId) { + return c.text("Unauthorized", 401); + } return c.json( await appWorkspaceAction( "selectAppOrganization", @@ -352,6 +337,9 @@ export async function startBackend(options: BackendStartOptions = {}): Promise { const sessionId = await resolveSessionId(c); + if (!sessionId) { + return c.text("Unauthorized", 401); + } const body = await c.req.json(); return c.json( await appWorkspaceAction( @@ -371,6 +359,9 @@ export async function startBackend(options: BackendStartOptions = {}): Promise { const sessionId = await resolveSessionId(c); + if (!sessionId) { + return c.text("Unauthorized", 401); + } return c.json( await appWorkspaceAction( "triggerAppRepoImport", @@ -386,6 +377,9 @@ export async function startBackend(options: BackendStartOptions = {}): Promise { const sessionId = await resolveSessionId(c); + if (!sessionId) { + return c.text("Unauthorized", 401); + } return c.json( await appWorkspaceAction( "beginAppGithubInstall", @@ -401,6 +395,9 @@ export async function startBackend(options: BackendStartOptions = {}): Promise { const sessionId = await resolveSessionId(c); + if (!sessionId) { + return c.text("Unauthorized", 401); + } const body = await c.req.json().catch(() => ({})); const planId = body?.planId === "free" || body?.planId === "team" ? (body.planId as FoundryBillingPlanId) : "team"; return c.json( @@ -414,11 +411,14 @@ export async function startBackend(options: BackendStartOptions = {}): Promise { const organizationId = c.req.query("organizationId"); - const sessionId = c.req.query("foundrySession"); const checkoutSessionId = c.req.query("session_id"); - if (!organizationId || !sessionId || !checkoutSessionId) { + if (!organizationId || !checkoutSessionId) { return c.text("Missing Stripe checkout completion parameters", 400); } + const sessionId = await resolveSessionId(c); + if (!sessionId) { + return c.text("Unauthorized", 401); + } const result = await (await appWorkspace(requestLogContext(c, sessionId))).finalizeAppCheckoutSession({ organizationId, sessionId, @@ -429,6 +429,9 @@ export async function startBackend(options: BackendStartOptions = {}): Promise { const sessionId = await resolveSessionId(c); + if (!sessionId) { + return c.text("Unauthorized", 401); + } return c.json( await (await appWorkspace(requestLogContext(c, sessionId))).createAppBillingPortalSession({ sessionId, @@ -439,6 +442,9 @@ export async function startBackend(options: BackendStartOptions = {}): Promise { const sessionId = await resolveSessionId(c); + if (!sessionId) { + return c.text("Unauthorized", 401); + } return c.json( await (await appWorkspace(requestLogContext(c, sessionId))).cancelAppScheduledRenewal({ sessionId, @@ -449,6 +455,9 @@ export async function startBackend(options: BackendStartOptions = {}): Promise { const sessionId = await resolveSessionId(c); + if (!sessionId) { + return c.text("Unauthorized", 401); + } return c.json( await (await appWorkspace(requestLogContext(c, sessionId))).resumeAppSubscription({ sessionId, @@ -459,6 +468,9 @@ export async function startBackend(options: BackendStartOptions = {}): Promise { const sessionId = await resolveSessionId(c); + if (!sessionId) { + return c.text("Unauthorized", 401); + } return c.json( await (await appWorkspace(requestLogContext(c, sessionId))).recordAppSeatUsage({ sessionId, diff --git a/foundry/packages/backend/src/logging.ts b/foundry/packages/backend/src/logging.ts index 0bf5170..5b96d92 100644 --- a/foundry/packages/backend/src/logging.ts +++ b/foundry/packages/backend/src/logging.ts @@ -2,4 +2,5 @@ import { createFoundryLogger } from "@sandbox-agent/foundry-shared"; export const logger = createFoundryLogger({ service: "foundry-backend", + format: "logfmt", }); diff --git a/foundry/packages/backend/src/services/app-github.ts b/foundry/packages/backend/src/services/app-github.ts index b5b6706..1f04fe3 100644 --- a/foundry/packages/backend/src/services/app-github.ts +++ b/foundry/packages/backend/src/services/app-github.ts @@ -262,11 +262,11 @@ export class GitHubAppClient { } async listOrganizations(accessToken: string): Promise { - const organizations = await this.paginate<{ id: number; login: string; description?: string | null }>("/user/orgs?per_page=100", accessToken); + const organizations = await this.paginate<{ id: number; login: string; name?: string | null }>("/user/orgs?per_page=100", accessToken); return organizations.map((organization) => ({ id: String(organization.id), login: organization.login, - name: organization.description?.trim() || organization.login, + name: organization.name?.trim() || organization.login, })); } diff --git a/foundry/packages/backend/src/services/better-auth.ts b/foundry/packages/backend/src/services/better-auth.ts new file mode 100644 index 0000000..325ea59 --- /dev/null +++ b/foundry/packages/backend/src/services/better-auth.ts @@ -0,0 +1,533 @@ +import { betterAuth } from "better-auth"; +import { createAdapterFactory } from "better-auth/adapters"; +import { APP_SHELL_WORKSPACE_ID } from "../actors/workspace/app-shell.js"; +import { authUserKey, workspaceKey } from "../actors/keys.js"; +import { logger } from "../logging.js"; + +const AUTH_BASE_PATH = "/v1/auth"; +const SESSION_COOKIE = "better-auth.session_token"; + +let betterAuthService: BetterAuthService | null = null; + +function requireEnv(name: string): string { + const value = process.env[name]?.trim(); + if (!value) { + throw new Error(`${name} is required`); + } + return value; +} + +function stripTrailingSlash(value: string): string { + return value.replace(/\/$/, ""); +} + +function buildCookieHeaders(sessionToken: string): Headers { + return new Headers({ + cookie: `${SESSION_COOKIE}=${encodeURIComponent(sessionToken)}`, + }); +} + +async function readJsonSafe(response: Response): Promise { + const text = await response.text(); + if (!text) { + return null; + } + try { + return JSON.parse(text); + } catch { + return text; + } +} + +async function callAuthEndpoint(auth: any, url: string, init?: RequestInit): Promise { + return await auth.handler(new Request(url, init)); +} + +function resolveRouteUserId(workspace: any, resolved: any): string | null { + if (!resolved) { + return null; + } + if (typeof resolved === "string") { + return resolved; + } + if (typeof resolved.userId === "string" && resolved.userId.length > 0) { + return resolved.userId; + } + if (typeof resolved.id === "string" && resolved.id.length > 0) { + return resolved.id; + } + return null; +} + +export interface BetterAuthService { + auth: any; + resolveSession(headers: Headers): Promise<{ session: any; user: any } | null>; + signOut(headers: Headers): Promise; + getAuthState(sessionId: string): Promise; + upsertUserProfile(userId: string, patch: Record): Promise; + setActiveOrganization(sessionId: string, activeOrganizationId: string | null): Promise; + getAccessTokenForSession(sessionId: string): Promise<{ accessToken: string; scopes: string[] } | null>; +} + +export function initBetterAuthService(actorClient: any, options: { apiUrl: string; appUrl: string }): BetterAuthService { + if (betterAuthService) { + return betterAuthService; + } + + // getOrCreate is intentional here: the adapter runs during Better Auth callbacks + // which can fire before any explicit create path. The app workspace and auth user + // actors must exist by the time the adapter needs them. + const appWorkspace = () => + actorClient.workspace.getOrCreate(workspaceKey(APP_SHELL_WORKSPACE_ID), { + createWithInput: APP_SHELL_WORKSPACE_ID, + }); + + // getOrCreate is intentional: Better Auth creates user records during OAuth + // callbacks, so the auth-user actor must be lazily provisioned on first access. + const getAuthUser = async (userId: string) => + await actorClient.authUser.getOrCreate(authUserKey(userId), { + createWithInput: { userId }, + }); + + const adapter = createAdapterFactory({ + config: { + adapterId: "rivetkit-actor", + adapterName: "RivetKit Actor Adapter", + supportsBooleans: false, + supportsDates: false, + supportsJSON: false, + }, + adapter: ({ transformInput, transformOutput, transformWhereClause }) => { + const resolveUserIdForQuery = async (model: string, where?: any[], data?: Record): Promise => { + const clauses = where ?? []; + const direct = (field: string) => clauses.find((entry) => entry.field === field)?.value; + + if (model === "user") { + const fromId = direct("id") ?? data?.id; + if (typeof fromId === "string" && fromId.length > 0) { + return fromId; + } + const email = direct("email"); + if (typeof email === "string" && email.length > 0) { + const workspace = await appWorkspace(); + const resolved = await workspace.authFindEmailIndex({ email: email.toLowerCase() }); + return resolveRouteUserId(workspace, resolved); + } + return null; + } + + if (model === "session") { + const fromUserId = direct("userId") ?? data?.userId; + if (typeof fromUserId === "string" && fromUserId.length > 0) { + return fromUserId; + } + const sessionId = direct("id") ?? data?.id; + const sessionToken = direct("token") ?? data?.token; + if (typeof sessionId === "string" || typeof sessionToken === "string") { + const workspace = await appWorkspace(); + const resolved = await workspace.authFindSessionIndex({ + ...(typeof sessionId === "string" ? { sessionId } : {}), + ...(typeof sessionToken === "string" ? { sessionToken } : {}), + }); + return resolveRouteUserId(workspace, resolved); + } + return null; + } + + if (model === "account") { + const fromUserId = direct("userId") ?? data?.userId; + if (typeof fromUserId === "string" && fromUserId.length > 0) { + return fromUserId; + } + const accountRecordId = direct("id") ?? data?.id; + const providerId = direct("providerId") ?? data?.providerId; + const accountId = direct("accountId") ?? data?.accountId; + const workspace = await appWorkspace(); + if (typeof accountRecordId === "string" && accountRecordId.length > 0) { + const resolved = await workspace.authFindAccountIndex({ id: accountRecordId }); + return resolveRouteUserId(workspace, resolved); + } + if (typeof providerId === "string" && providerId.length > 0 && typeof accountId === "string" && accountId.length > 0) { + const resolved = await workspace.authFindAccountIndex({ providerId, accountId }); + return resolveRouteUserId(workspace, resolved); + } + return null; + } + + return null; + }; + + const ensureWorkspaceVerification = async (method: string, payload: Record) => { + const workspace = await appWorkspace(); + return await workspace[method](payload); + }; + + return { + options: { + useDatabaseGeneratedIds: false, + }, + + create: async ({ model, data }) => { + const transformed = await transformInput(data, model, "create", true); + if (model === "verification") { + return await ensureWorkspaceVerification("authCreateVerification", { data: transformed }); + } + + const userId = await resolveUserIdForQuery(model, undefined, transformed); + if (!userId) { + throw new Error(`Unable to resolve auth actor for create(${model})`); + } + + const userActor = await getAuthUser(userId); + const created = await userActor.createAuthRecord({ model, data: transformed }); + const workspace = await appWorkspace(); + + if (model === "user" && typeof transformed.email === "string" && transformed.email.length > 0) { + await workspace.authUpsertEmailIndex({ + email: transformed.email.toLowerCase(), + userId, + }); + } + + if (model === "session") { + await workspace.authUpsertSessionIndex({ + sessionId: String(created.id), + sessionToken: String(created.token), + userId, + }); + } + + if (model === "account") { + await workspace.authUpsertAccountIndex({ + id: String(created.id), + providerId: String(created.providerId), + accountId: String(created.accountId), + userId, + }); + } + + return (await transformOutput(created, model)) as any; + }, + + findOne: async ({ model, where, join }) => { + const transformedWhere = transformWhereClause({ model, where, action: "findOne" }); + if (model === "verification") { + return await ensureWorkspaceVerification("authFindOneVerification", { where: transformedWhere, join }); + } + + const userId = await resolveUserIdForQuery(model, transformedWhere); + if (!userId) { + return null; + } + + const userActor = await getAuthUser(userId); + const found = await userActor.findOneAuthRecord({ model, where: transformedWhere, join }); + return found ? ((await transformOutput(found, model, undefined, join)) as any) : null; + }, + + findMany: async ({ model, where, limit, sortBy, offset, join }) => { + const transformedWhere = transformWhereClause({ model, where, action: "findMany" }); + if (model === "verification") { + return await ensureWorkspaceVerification("authFindManyVerification", { + where: transformedWhere, + limit, + sortBy, + offset, + join, + }); + } + + if (model === "session") { + const tokenClause = transformedWhere?.find((entry: any) => entry.field === "token" && entry.operator === "in"); + if (tokenClause && Array.isArray(tokenClause.value)) { + const workspace = await appWorkspace(); + const resolved = await Promise.all( + (tokenClause.value as string[]).map(async (sessionToken: string) => ({ + sessionToken, + route: await workspace.authFindSessionIndex({ sessionToken }), + })), + ); + const byUser = new Map(); + for (const item of resolved) { + if (!item.route?.userId) { + continue; + } + const tokens = byUser.get(item.route.userId) ?? []; + tokens.push(item.sessionToken); + byUser.set(item.route.userId, tokens); + } + + const rows = []; + for (const [userId, tokens] of byUser) { + const userActor = await getAuthUser(userId); + const scopedWhere = transformedWhere.map((entry: any) => + entry.field === "token" && entry.operator === "in" ? { ...entry, value: tokens } : entry, + ); + const found = await userActor.findManyAuthRecords({ model, where: scopedWhere, limit, sortBy, offset, join }); + rows.push(...found); + } + return await Promise.all(rows.map(async (row: any) => await transformOutput(row, model, undefined, join))); + } + } + + const userId = await resolveUserIdForQuery(model, transformedWhere); + if (!userId) { + return []; + } + + const userActor = await getAuthUser(userId); + const found = await userActor.findManyAuthRecords({ model, where: transformedWhere, limit, sortBy, offset, join }); + return await Promise.all(found.map(async (row: any) => await transformOutput(row, model, undefined, join))); + }, + + update: async ({ model, where, update }) => { + const transformedWhere = transformWhereClause({ model, where, action: "update" }); + const transformedUpdate = (await transformInput(update as Record, model, "update", true)) as Record; + if (model === "verification") { + return await ensureWorkspaceVerification("authUpdateVerification", { where: transformedWhere, update: transformedUpdate }); + } + + const userId = await resolveUserIdForQuery(model, transformedWhere, transformedUpdate); + if (!userId) { + return null; + } + + const userActor = await getAuthUser(userId); + const before = + model === "user" + ? await userActor.findOneAuthRecord({ model, where: transformedWhere }) + : model === "account" + ? await userActor.findOneAuthRecord({ model, where: transformedWhere }) + : model === "session" + ? await userActor.findOneAuthRecord({ model, where: transformedWhere }) + : null; + const updated = await userActor.updateAuthRecord({ model, where: transformedWhere, update: transformedUpdate }); + const workspace = await appWorkspace(); + + if (model === "user" && updated) { + if (before?.email && before.email !== updated.email) { + await workspace.authDeleteEmailIndex({ email: before.email.toLowerCase() }); + } + if (updated.email) { + await workspace.authUpsertEmailIndex({ email: updated.email.toLowerCase(), userId }); + } + } + + if (model === "session" && updated) { + await workspace.authUpsertSessionIndex({ + sessionId: String(updated.id), + sessionToken: String(updated.token), + userId, + }); + } + + if (model === "account" && updated) { + await workspace.authUpsertAccountIndex({ + id: String(updated.id), + providerId: String(updated.providerId), + accountId: String(updated.accountId), + userId, + }); + } + + return updated ? ((await transformOutput(updated, model)) as any) : null; + }, + + updateMany: async ({ model, where, update }) => { + const transformedWhere = transformWhereClause({ model, where, action: "updateMany" }); + const transformedUpdate = (await transformInput(update as Record, model, "update", true)) as Record; + if (model === "verification") { + return await ensureWorkspaceVerification("authUpdateManyVerification", { where: transformedWhere, update: transformedUpdate }); + } + + const userId = await resolveUserIdForQuery(model, transformedWhere, transformedUpdate); + if (!userId) { + return 0; + } + + const userActor = await getAuthUser(userId); + return await userActor.updateManyAuthRecords({ model, where: transformedWhere, update: transformedUpdate }); + }, + + delete: async ({ model, where }) => { + const transformedWhere = transformWhereClause({ model, where, action: "delete" }); + if (model === "verification") { + await ensureWorkspaceVerification("authDeleteVerification", { where: transformedWhere }); + return; + } + + const userId = await resolveUserIdForQuery(model, transformedWhere); + if (!userId) { + return; + } + + const userActor = await getAuthUser(userId); + const workspace = await appWorkspace(); + const before = await userActor.findOneAuthRecord({ model, where: transformedWhere }); + await userActor.deleteAuthRecord({ model, where: transformedWhere }); + + if (model === "session" && before) { + await workspace.authDeleteSessionIndex({ + sessionId: before.id, + sessionToken: before.token, + }); + } + + if (model === "account" && before) { + await workspace.authDeleteAccountIndex({ + id: before.id, + providerId: before.providerId, + accountId: before.accountId, + }); + } + + if (model === "user" && before?.email) { + await workspace.authDeleteEmailIndex({ email: before.email.toLowerCase() }); + } + }, + + deleteMany: async ({ model, where }) => { + const transformedWhere = transformWhereClause({ model, where, action: "deleteMany" }); + if (model === "verification") { + return await ensureWorkspaceVerification("authDeleteManyVerification", { where: transformedWhere }); + } + + if (model === "session") { + const userId = await resolveUserIdForQuery(model, transformedWhere); + if (!userId) { + return 0; + } + const userActor = await getAuthUser(userId); + const workspace = await appWorkspace(); + const sessions = await userActor.findManyAuthRecords({ model, where: transformedWhere, limit: 5000 }); + const deleted = await userActor.deleteManyAuthRecords({ model, where: transformedWhere }); + for (const session of sessions) { + await workspace.authDeleteSessionIndex({ + sessionId: session.id, + sessionToken: session.token, + }); + } + return deleted; + } + + const userId = await resolveUserIdForQuery(model, transformedWhere); + if (!userId) { + return 0; + } + + const userActor = await getAuthUser(userId); + const deleted = await userActor.deleteManyAuthRecords({ model, where: transformedWhere }); + return deleted; + }, + + count: async ({ model, where }) => { + const transformedWhere = transformWhereClause({ model, where, action: "count" }); + if (model === "verification") { + return await ensureWorkspaceVerification("authCountVerification", { where: transformedWhere }); + } + + const userId = await resolveUserIdForQuery(model, transformedWhere); + if (!userId) { + return 0; + } + + const userActor = await getAuthUser(userId); + return await userActor.countAuthRecords({ model, where: transformedWhere }); + }, + }; + }, + }); + + const auth = betterAuth({ + baseURL: stripTrailingSlash(process.env.BETTER_AUTH_URL ?? options.apiUrl), + basePath: AUTH_BASE_PATH, + secret: requireEnv("BETTER_AUTH_SECRET"), + database: adapter, + trustedOrigins: [stripTrailingSlash(options.appUrl), stripTrailingSlash(options.apiUrl)], + session: { + cookieCache: { + enabled: true, + maxAge: 5 * 60, + strategy: "compact", + }, + }, + socialProviders: { + github: { + clientId: requireEnv("GITHUB_CLIENT_ID"), + clientSecret: requireEnv("GITHUB_CLIENT_SECRET"), + scope: ["read:org", "repo"], + redirectURI: process.env.GITHUB_REDIRECT_URI || undefined, + }, + }, + }); + + betterAuthService = { + auth, + + async resolveSession(headers: Headers) { + return (await auth.api.getSession({ headers })) ?? null; + }, + + async signOut(headers: Headers) { + return await callAuthEndpoint(auth, `${stripTrailingSlash(process.env.BETTER_AUTH_URL ?? options.apiUrl)}${AUTH_BASE_PATH}/sign-out`, { + method: "POST", + headers, + }); + }, + + async getAuthState(sessionId: string) { + const workspace = await appWorkspace(); + const route = await workspace.authFindSessionIndex({ sessionId }); + if (!route?.userId) { + return null; + } + const userActor = await getAuthUser(route.userId); + return await userActor.getAppAuthState({ sessionId }); + }, + + async upsertUserProfile(userId: string, patch: Record) { + const userActor = await getAuthUser(userId); + return await userActor.upsertUserProfile({ userId, patch }); + }, + + async setActiveOrganization(sessionId: string, activeOrganizationId: string | null) { + const authState = await this.getAuthState(sessionId); + if (!authState?.user?.id) { + throw new Error(`Unknown auth session ${sessionId}`); + } + const userActor = await getAuthUser(authState.user.id); + return await userActor.upsertSessionState({ sessionId, activeOrganizationId }); + }, + + async getAccessTokenForSession(sessionId: string) { + // Read the GitHub access token directly from the account record stored in the + // auth user actor. Better Auth's internal /get-access-token endpoint requires + // session middleware resolution which fails for server-side internal calls (403), + // so we bypass it and read the stored token from our adapter layer directly. + const authState = await this.getAuthState(sessionId); + if (!authState?.user?.id || !authState?.accounts) { + return null; + } + + const githubAccount = authState.accounts.find((account: any) => account.providerId === "github"); + if (!githubAccount?.accessToken) { + logger.warn({ sessionId, userId: authState.user.id }, "get_access_token_no_github_account"); + return null; + } + + return { + accessToken: githubAccount.accessToken, + scopes: githubAccount.scope ? githubAccount.scope.split(/[, ]+/) : [], + }; + }, + }; + + return betterAuthService; +} + +export function getBetterAuthService(): BetterAuthService { + if (!betterAuthService) { + throw new Error("BetterAuth service is not initialized"); + } + return betterAuthService; +} diff --git a/foundry/packages/client/package.json b/foundry/packages/client/package.json index 7d558f0..98079d5 100644 --- a/foundry/packages/client/package.json +++ b/foundry/packages/client/package.json @@ -15,10 +15,12 @@ }, "dependencies": { "@sandbox-agent/foundry-shared": "workspace:*", + "react": "^19.1.1", "rivetkit": "2.1.6", "sandbox-agent": "workspace:*" }, "devDependencies": { + "@types/react": "^19.1.12", "tsup": "^8.5.0" } } diff --git a/foundry/packages/client/src/backend-client.ts b/foundry/packages/client/src/backend-client.ts index d9ffa06..6d27504 100644 --- a/foundry/packages/client/src/backend-client.ts +++ b/foundry/packages/client/src/backend-client.ts @@ -6,6 +6,9 @@ import type { FoundryAppSnapshot, FoundryBillingPlanId, CreateTaskInput, + AppEvent, + SessionEvent, + SandboxProcessesEvent, TaskRecord, TaskSummary, TaskWorkbenchChangeModelInput, @@ -20,6 +23,12 @@ import type { TaskWorkbenchSnapshot, TaskWorkbenchTabInput, TaskWorkbenchUpdateDraftInput, + TaskEvent, + WorkbenchTaskDetail, + WorkbenchTaskSummary, + WorkbenchSessionDetail, + WorkspaceEvent, + WorkspaceSummarySnapshot, HistoryEvent, HistoryQueryInput, ProviderId, @@ -34,18 +43,10 @@ import type { } from "@sandbox-agent/foundry-shared"; import type { ProcessCreateRequest, ProcessInfo, ProcessLogFollowQuery, ProcessLogsResponse, ProcessSignalQuery } from "sandbox-agent"; import { createMockBackendClient } from "./mock/backend-client.js"; -import { sandboxInstanceKey, workspaceKey } from "./keys.js"; +import { sandboxInstanceKey, taskKey, workspaceKey } from "./keys.js"; export type TaskAction = "push" | "sync" | "merge" | "archive" | "kill"; -type RivetMetadataResponse = { - runtime?: string; - actorNames?: Record; - clientEndpoint?: string; - clientNamespace?: string; - clientToken?: string; -}; - export interface SandboxSessionRecord { id: string; agent: string; @@ -68,7 +69,14 @@ export interface SandboxSessionEventRecord { export type SandboxProcessRecord = ProcessInfo; +export interface ActorConn { + on(event: string, listener: (payload: any) => void): () => void; + onError(listener: (error: unknown) => void): () => void; + dispose(): Promise; +} + interface WorkspaceHandle { + connect(): ActorConn; addRepo(input: AddRepoInput): Promise; listRepos(input: { workspaceId: string }): Promise; createTask(input: CreateTaskInput): Promise; @@ -86,7 +94,10 @@ interface WorkspaceHandle { killTask(input: { workspaceId: string; taskId: string; reason?: string }): Promise; useWorkspace(input: { workspaceId: string }): Promise<{ workspaceId: string }>; starSandboxAgentRepo(input: StarSandboxAgentRepoInput): Promise; - getWorkbench(input: { workspaceId: string }): Promise; + getWorkspaceSummary(input: { workspaceId: string }): Promise; + applyTaskSummaryUpdate(input: { taskSummary: WorkbenchTaskSummary }): Promise; + removeTaskSummary(input: { taskId: string }): Promise; + reconcileWorkbenchState(input: { workspaceId: string }): Promise; createWorkbenchTask(input: TaskWorkbenchCreateTaskInput): Promise; markWorkbenchUnread(input: TaskWorkbenchSelectInput): Promise; renameWorkbenchTask(input: TaskWorkbenchRenameInput): Promise; @@ -103,7 +114,15 @@ interface WorkspaceHandle { revertWorkbenchFile(input: TaskWorkbenchDiffInput): Promise; } +interface TaskHandle { + getTaskSummary(): Promise; + getTaskDetail(): Promise; + getSessionDetail(input: { sessionId: string }): Promise; + connect(): ActorConn; +} + interface SandboxInstanceHandle { + connect(): ActorConn; createSession(input: { prompt: string; cwd?: string; @@ -127,6 +146,10 @@ interface RivetClient { workspace: { getOrCreate(key?: string | string[], opts?: { createWithInput?: unknown }): WorkspaceHandle; }; + task: { + get(key?: string | string[]): TaskHandle; + getOrCreate(key?: string | string[], opts?: { createWithInput?: unknown }): TaskHandle; + }; sandboxInstance: { getOrCreate(key?: string | string[], opts?: { createWithInput?: unknown }): SandboxInstanceHandle; }; @@ -138,16 +161,12 @@ export interface BackendClientOptions { mode?: "remote" | "mock"; } -export interface BackendMetadata { - runtime?: string; - actorNames?: Record; - clientEndpoint?: string; - clientNamespace?: string; - clientToken?: string; -} - export interface BackendClient { getAppSnapshot(): Promise; + connectWorkspace(workspaceId: string): Promise; + connectTask(workspaceId: string, repoId: string, taskId: string): Promise; + connectSandbox(workspaceId: string, providerId: ProviderId, sandboxId: string): Promise; + subscribeApp(listener: () => void): () => void; signInWithGithub(): Promise; signOutApp(): Promise; skipAppStarterRepo(): Promise; @@ -237,6 +256,9 @@ export interface BackendClient { sandboxId: string, ): Promise<{ providerId: ProviderId; sandboxId: string; state: string; at: number }>; getSandboxAgentConnection(workspaceId: string, providerId: ProviderId, sandboxId: string): Promise<{ endpoint: string; token?: string }>; + getWorkspaceSummary(workspaceId: string): Promise; + getTaskDetail(workspaceId: string, repoId: string, taskId: string): Promise; + getSessionDetail(workspaceId: string, repoId: string, taskId: string, sessionId: string): Promise; getWorkbench(workspaceId: string): Promise; subscribeWorkbench(workspaceId: string, listener: () => void): () => void; createWorkbenchTask(workspaceId: string, input: TaskWorkbenchCreateTaskInput): Promise; @@ -295,118 +317,6 @@ function deriveBackendEndpoints(endpoint: string): { appEndpoint: string; rivetE }; } -function isLoopbackHost(hostname: string): boolean { - const h = hostname.toLowerCase(); - return h === "127.0.0.1" || h === "localhost" || h === "0.0.0.0" || h === "::1"; -} - -function rewriteLoopbackClientEndpoint(clientEndpoint: string, fallbackOrigin: string): string { - const clientUrl = new URL(clientEndpoint); - if (!isLoopbackHost(clientUrl.hostname)) { - return clientUrl.toString().replace(/\/$/, ""); - } - - const originUrl = new URL(fallbackOrigin); - // Keep the manager port from clientEndpoint; only rewrite host/protocol to match the origin. - clientUrl.hostname = originUrl.hostname; - clientUrl.protocol = originUrl.protocol; - return clientUrl.toString().replace(/\/$/, ""); -} - -async function fetchJsonWithTimeout(url: string, timeoutMs: number): Promise { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), timeoutMs); - try { - const res = await fetch(url, { signal: controller.signal }); - if (!res.ok) { - throw new Error(`request failed: ${res.status} ${res.statusText}`); - } - return (await res.json()) as unknown; - } finally { - clearTimeout(timeout); - } -} - -async function fetchMetadataWithRetry( - endpoint: string, - namespace: string | undefined, - opts: { timeoutMs: number; requestTimeoutMs: number }, -): Promise { - const base = new URL(endpoint); - base.pathname = base.pathname.replace(/\/$/, "") + "/metadata"; - if (namespace) { - base.searchParams.set("namespace", namespace); - } - - const start = Date.now(); - let delayMs = 250; - // Keep this bounded: callers (UI/CLI) should not hang forever if the backend is down. - for (;;) { - try { - const json = await fetchJsonWithTimeout(base.toString(), opts.requestTimeoutMs); - if (!json || typeof json !== "object") return {}; - const data = json as Record; - return { - runtime: typeof data.runtime === "string" ? data.runtime : undefined, - actorNames: data.actorNames && typeof data.actorNames === "object" ? (data.actorNames as Record) : undefined, - clientEndpoint: typeof data.clientEndpoint === "string" ? data.clientEndpoint : undefined, - clientNamespace: typeof data.clientNamespace === "string" ? data.clientNamespace : undefined, - clientToken: typeof data.clientToken === "string" ? data.clientToken : undefined, - }; - } catch (err) { - if (Date.now() - start > opts.timeoutMs) { - throw err; - } - await new Promise((r) => setTimeout(r, delayMs)); - delayMs = Math.min(delayMs * 2, 2_000); - } - } -} - -export async function readBackendMetadata(input: { endpoint: string; namespace?: string; timeoutMs?: number }): Promise { - const base = new URL(input.endpoint); - base.pathname = base.pathname.replace(/\/$/, "") + "/metadata"; - if (input.namespace) { - base.searchParams.set("namespace", input.namespace); - } - - const json = await fetchJsonWithTimeout(base.toString(), input.timeoutMs ?? 4_000); - if (!json || typeof json !== "object") { - return {}; - } - const data = json as Record; - return { - runtime: typeof data.runtime === "string" ? data.runtime : undefined, - actorNames: data.actorNames && typeof data.actorNames === "object" ? (data.actorNames as Record) : undefined, - clientEndpoint: typeof data.clientEndpoint === "string" ? data.clientEndpoint : undefined, - clientNamespace: typeof data.clientNamespace === "string" ? data.clientNamespace : undefined, - clientToken: typeof data.clientToken === "string" ? data.clientToken : undefined, - }; -} - -export async function checkBackendHealth(input: { endpoint: string; namespace?: string; timeoutMs?: number }): Promise { - try { - const metadata = await readBackendMetadata(input); - return metadata.runtime === "rivetkit" && Boolean(metadata.actorNames); - } catch { - return false; - } -} - -async function probeMetadataEndpoint(endpoint: string, namespace: string | undefined, timeoutMs: number): Promise { - try { - const base = new URL(endpoint); - base.pathname = base.pathname.replace(/\/$/, "") + "/metadata"; - if (namespace) { - base.searchParams.set("namespace", namespace); - } - await fetchJsonWithTimeout(base.toString(), timeoutMs); - return true; - } catch { - return false; - } -} - export function createBackendClient(options: BackendClientOptions): BackendClient { if (options.mode === "mock") { return createMockBackendClient(options.defaultWorkspaceId); @@ -415,8 +325,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien const endpoints = deriveBackendEndpoints(options.endpoint); const rivetApiEndpoint = endpoints.rivetEndpoint; const appApiEndpoint = endpoints.appEndpoint; - let clientPromise: Promise | null = null; - let appSessionId = typeof window !== "undefined" ? window.localStorage.getItem("sandbox-agent-foundry:remote-app-session") : null; + const client = createClient({ endpoint: rivetApiEndpoint }) as unknown as RivetClient; const workbenchSubscriptions = new Map< string, { @@ -431,34 +340,13 @@ export function createBackendClient(options: BackendClientOptions): BackendClien disposeConnPromise: Promise<(() => Promise) | null> | null; } >(); - - const persistAppSessionId = (nextSessionId: string | null): void => { - appSessionId = nextSessionId; - if (typeof window === "undefined") { - return; - } - if (nextSessionId) { - window.localStorage.setItem("sandbox-agent-foundry:remote-app-session", nextSessionId); - } else { - window.localStorage.removeItem("sandbox-agent-foundry:remote-app-session"); - } + const appSubscriptions = { + listeners: new Set<() => void>(), + disposeConnPromise: null as Promise<(() => Promise) | null> | null, }; - if (typeof window !== "undefined") { - const url = new URL(window.location.href); - const sessionFromUrl = url.searchParams.get("foundrySession"); - if (sessionFromUrl) { - persistAppSessionId(sessionFromUrl); - url.searchParams.delete("foundrySession"); - window.history.replaceState({}, document.title, `${url.pathname}${url.search}${url.hash}`); - } - } - const appRequest = async (path: string, init?: RequestInit): Promise => { const headers = new Headers(init?.headers); - if (appSessionId) { - headers.set("x-foundry-session", appSessionId); - } if (init?.body && !headers.has("Content-Type")) { headers.set("Content-Type", "application/json"); } @@ -468,10 +356,6 @@ export function createBackendClient(options: BackendClientOptions): BackendClien headers, credentials: "include", }); - const nextSessionId = res.headers.get("x-foundry-session"); - if (nextSessionId) { - persistAppSessionId(nextSessionId); - } if (!res.ok) { throw new Error(`app request failed: ${res.status} ${res.statusText}`); } @@ -485,51 +369,14 @@ export function createBackendClient(options: BackendClientOptions): BackendClien } }; - const getClient = async (): Promise => { - if (clientPromise) { - return clientPromise; - } - - clientPromise = (async () => { - // Use the serverless /metadata endpoint to discover the manager endpoint. - // If the server reports a loopback clientEndpoint (127.0.0.1), rewrite to the same host - // as the configured endpoint so remote browsers/clients can connect. - const configured = new URL(rivetApiEndpoint); - const configuredOrigin = `${configured.protocol}//${configured.host}`; - - const initialNamespace = undefined; - const metadata = await fetchMetadataWithRetry(rivetApiEndpoint, initialNamespace, { - timeoutMs: 30_000, - requestTimeoutMs: 8_000, - }); - - // Candidate endpoint: manager endpoint if provided, otherwise stick to the configured endpoint. - const candidateEndpoint = metadata.clientEndpoint ? rewriteLoopbackClientEndpoint(metadata.clientEndpoint, configuredOrigin) : rivetApiEndpoint; - - // If the manager port isn't reachable from this client (common behind reverse proxies), - // fall back to the configured serverless endpoint to avoid hanging requests. - const shouldUseCandidate = metadata.clientEndpoint ? await probeMetadataEndpoint(candidateEndpoint, metadata.clientNamespace, 1_500) : true; - const resolvedEndpoint = shouldUseCandidate ? candidateEndpoint : rivetApiEndpoint; - - return createClient({ - endpoint: resolvedEndpoint, - namespace: metadata.clientNamespace, - token: metadata.clientToken, - // Prevent rivetkit from overriding back to a loopback endpoint (or to an unreachable manager). - disableMetadataLookup: true, - }) as unknown as RivetClient; - })(); - - return clientPromise; - }; - const workspace = async (workspaceId: string): Promise => - (await getClient()).workspace.getOrCreate(workspaceKey(workspaceId), { + client.workspace.getOrCreate(workspaceKey(workspaceId), { createWithInput: workspaceId, }); + const task = async (workspaceId: string, repoId: string, taskId: string): Promise => client.task.get(taskKey(workspaceId, repoId, taskId)); + const sandboxByKey = async (workspaceId: string, providerId: ProviderId, sandboxId: string): Promise => { - const client = await getClient(); return (client as any).sandboxInstance.get(sandboxInstanceKey(workspaceId, providerId, sandboxId)); }; @@ -557,7 +404,6 @@ export function createBackendClient(options: BackendClientOptions): BackendClien (sb as any).sandboxActorId.length > 0, ) as { sandboxActorId?: string } | undefined; if (sandbox?.sandboxActorId) { - const client = await getClient(); return (client as any).sandboxInstance.getForId(sandbox.sandboxActorId); } } catch (error) { @@ -593,6 +439,91 @@ export function createBackendClient(options: BackendClientOptions): BackendClien } }; + const connectWorkspace = async (workspaceId: string): Promise => { + return (await workspace(workspaceId)).connect() as ActorConn; + }; + + const connectTask = async (workspaceId: string, repoId: string, taskIdValue: string): Promise => { + return (await task(workspaceId, repoId, taskIdValue)).connect() as ActorConn; + }; + + const connectSandbox = async (workspaceId: string, providerId: ProviderId, sandboxId: string): Promise => { + try { + return (await sandboxByKey(workspaceId, providerId, sandboxId)).connect() as ActorConn; + } catch (error) { + if (!isActorNotFoundError(error)) { + throw error; + } + const fallback = await sandboxByActorIdFromTask(workspaceId, providerId, sandboxId); + if (!fallback) { + throw error; + } + return fallback.connect() as ActorConn; + } + }; + + const getWorkbenchCompat = async (workspaceId: string): Promise => { + const summary = await (await workspace(workspaceId)).getWorkspaceSummary({ workspaceId }); + const tasks = await Promise.all( + summary.taskSummaries.map(async (taskSummary) => { + const detail = await (await task(workspaceId, taskSummary.repoId, taskSummary.id)).getTaskDetail(); + const sessionDetails = await Promise.all( + detail.sessionsSummary.map(async (session) => { + const full = await (await task(workspaceId, detail.repoId, detail.id)).getSessionDetail({ sessionId: session.id }); + return [session.id, full] as const; + }), + ); + const sessionDetailsById = new Map(sessionDetails); + return { + id: detail.id, + repoId: detail.repoId, + title: detail.title, + status: detail.status, + repoName: detail.repoName, + updatedAtMs: detail.updatedAtMs, + branch: detail.branch, + pullRequest: detail.pullRequest, + tabs: detail.sessionsSummary.map((session) => { + const full = sessionDetailsById.get(session.id); + return { + id: session.id, + sessionId: session.sessionId, + sessionName: session.sessionName, + agent: session.agent, + model: session.model, + status: session.status, + thinkingSinceMs: session.thinkingSinceMs, + unread: session.unread, + created: session.created, + draft: full?.draft ?? { text: "", attachments: [], updatedAtMs: null }, + transcript: full?.transcript ?? [], + }; + }), + fileChanges: detail.fileChanges, + diffs: detail.diffs, + fileTree: detail.fileTree, + minutesUsed: detail.minutesUsed, + }; + }), + ); + + const projects = summary.repos + .map((repo) => ({ + id: repo.id, + label: repo.label, + updatedAtMs: tasks.filter((task) => task.repoId === repo.id).reduce((latest, task) => Math.max(latest, task.updatedAtMs), repo.latestActivityMs), + tasks: tasks.filter((task) => task.repoId === repo.id).sort((left, right) => right.updatedAtMs - left.updatedAtMs), + })) + .filter((repo) => repo.tasks.length > 0); + + return { + workspaceId, + repos: summary.repos.map((repo) => ({ id: repo.id, label: repo.label })), + projects, + tasks: tasks.sort((left, right) => right.updatedAtMs - left.updatedAtMs), + }; + }; + const subscribeWorkbench = (workspaceId: string, listener: () => void): (() => void) => { let entry = workbenchSubscriptions.get(workspaceId); if (!entry) { @@ -698,17 +629,74 @@ export function createBackendClient(options: BackendClientOptions): BackendClien }; }; + const subscribeApp = (listener: () => void): (() => void) => { + appSubscriptions.listeners.add(listener); + + if (!appSubscriptions.disposeConnPromise) { + appSubscriptions.disposeConnPromise = (async () => { + const handle = await workspace("app"); + const conn = (handle as any).connect(); + const unsubscribeEvent = conn.on("appUpdated", () => { + for (const currentListener of [...appSubscriptions.listeners]) { + currentListener(); + } + }); + const unsubscribeError = conn.onError(() => {}); + return async () => { + unsubscribeEvent(); + unsubscribeError(); + await conn.dispose(); + }; + })().catch(() => null); + } + + return () => { + appSubscriptions.listeners.delete(listener); + if (appSubscriptions.listeners.size > 0) { + return; + } + + void appSubscriptions.disposeConnPromise?.then(async (disposeConn) => { + await disposeConn?.(); + }); + appSubscriptions.disposeConnPromise = null; + }; + }; + return { async getAppSnapshot(): Promise { return await appRequest("/app/snapshot"); }, + async connectWorkspace(workspaceId: string): Promise { + return await connectWorkspace(workspaceId); + }, + + async connectTask(workspaceId: string, repoId: string, taskIdValue: string): Promise { + return await connectTask(workspaceId, repoId, taskIdValue); + }, + + async connectSandbox(workspaceId: string, providerId: ProviderId, sandboxId: string): Promise { + return await connectSandbox(workspaceId, providerId, sandboxId); + }, + + subscribeApp(listener: () => void): () => void { + return subscribeApp(listener); + }, + async signInWithGithub(): Promise { + const callbackURL = typeof window !== "undefined" ? `${window.location.origin}/organizations` : `${appApiEndpoint.replace(/\/$/, "")}/organizations`; + const response = await appRequest<{ url: string; redirect?: boolean }>("/auth/sign-in/social", { + method: "POST", + body: JSON.stringify({ + provider: "github", + callbackURL, + disableRedirect: true, + }), + }); if (typeof window !== "undefined") { - window.location.assign(`${appApiEndpoint}/auth/github/start`); - return; + window.location.assign(response.url); } - await redirectTo("/auth/github/start"); }, async signOutApp(): Promise { @@ -1009,8 +997,20 @@ export function createBackendClient(options: BackendClientOptions): BackendClien return await withSandboxHandle(workspaceId, providerId, sandboxId, async (handle) => handle.sandboxAgentConnection()); }, + async getWorkspaceSummary(workspaceId: string): Promise { + return (await workspace(workspaceId)).getWorkspaceSummary({ workspaceId }); + }, + + async getTaskDetail(workspaceId: string, repoId: string, taskIdValue: string): Promise { + return (await task(workspaceId, repoId, taskIdValue)).getTaskDetail(); + }, + + async getSessionDetail(workspaceId: string, repoId: string, taskIdValue: string, sessionId: string): Promise { + return (await task(workspaceId, repoId, taskIdValue)).getSessionDetail({ sessionId }); + }, + async getWorkbench(workspaceId: string): Promise { - return (await workspace(workspaceId)).getWorkbench({ workspaceId }); + return await getWorkbenchCompat(workspaceId); }, subscribeWorkbench(workspaceId: string, listener: () => void): () => void { diff --git a/foundry/packages/client/src/index.ts b/foundry/packages/client/src/index.ts index a959744..7605986 100644 --- a/foundry/packages/client/src/index.ts +++ b/foundry/packages/client/src/index.ts @@ -1,5 +1,10 @@ export * from "./app-client.js"; export * from "./backend-client.js"; +export * from "./interest/manager.js"; +export * from "./interest/mock-manager.js"; +export * from "./interest/remote-manager.js"; +export * from "./interest/topics.js"; +export * from "./interest/use-interest.js"; export * from "./keys.js"; export * from "./mock-app.js"; export * from "./view-model.js"; diff --git a/foundry/packages/client/src/interest/manager.ts b/foundry/packages/client/src/interest/manager.ts new file mode 100644 index 0000000..b2aab57 --- /dev/null +++ b/foundry/packages/client/src/interest/manager.ts @@ -0,0 +1,24 @@ +import type { TopicData, TopicKey, TopicParams } from "./topics.js"; + +export type TopicStatus = "loading" | "connected" | "error"; + +export interface TopicState { + data: TopicData | undefined; + status: TopicStatus; + error: Error | null; +} + +/** + * The InterestManager owns all realtime actor connections and cached state. + * + * Multiple subscribers to the same topic share one connection and one cache + * entry. After the last subscriber leaves, a short grace period keeps the + * connection warm so navigation does not thrash actor connections. + */ +export interface InterestManager { + subscribe(topicKey: K, params: TopicParams, listener: () => void): () => void; + getSnapshot(topicKey: K, params: TopicParams): TopicData | undefined; + getStatus(topicKey: K, params: TopicParams): TopicStatus; + getError(topicKey: K, params: TopicParams): Error | null; + dispose(): void; +} diff --git a/foundry/packages/client/src/interest/mock-manager.ts b/foundry/packages/client/src/interest/mock-manager.ts new file mode 100644 index 0000000..f1c065e --- /dev/null +++ b/foundry/packages/client/src/interest/mock-manager.ts @@ -0,0 +1,12 @@ +import { createMockBackendClient } from "../mock/backend-client.js"; +import { RemoteInterestManager } from "./remote-manager.js"; + +/** + * Mock implementation shares the same interest-manager harness as the remote + * path, but uses the in-memory mock backend that synthesizes actor events. + */ +export class MockInterestManager extends RemoteInterestManager { + constructor() { + super(createMockBackendClient()); + } +} diff --git a/foundry/packages/client/src/interest/remote-manager.ts b/foundry/packages/client/src/interest/remote-manager.ts new file mode 100644 index 0000000..3016ad0 --- /dev/null +++ b/foundry/packages/client/src/interest/remote-manager.ts @@ -0,0 +1,167 @@ +import type { BackendClient } from "../backend-client.js"; +import type { InterestManager, TopicStatus } from "./manager.js"; +import { topicDefinitions, type TopicData, type TopicDefinition, type TopicKey, type TopicParams } from "./topics.js"; + +const GRACE_PERIOD_MS = 30_000; + +/** + * Remote implementation of InterestManager. + * Each cache entry owns one actor connection plus one materialized snapshot. + */ +export class RemoteInterestManager implements InterestManager { + private entries = new Map>(); + + constructor(private readonly backend: BackendClient) {} + + subscribe(topicKey: K, params: TopicParams, listener: () => void): () => void { + const definition = topicDefinitions[topicKey] as unknown as TopicDefinition; + const cacheKey = definition.key(params as any); + let entry = this.entries.get(cacheKey); + + if (!entry) { + entry = new TopicEntry(definition, this.backend, params as any); + this.entries.set(cacheKey, entry); + } + + entry.cancelTeardown(); + entry.addListener(listener); + entry.ensureStarted(); + + return () => { + const current = this.entries.get(cacheKey); + if (!current) { + return; + } + current.removeListener(listener); + if (current.listenerCount === 0) { + current.scheduleTeardown(GRACE_PERIOD_MS, () => { + this.entries.delete(cacheKey); + }); + } + }; + } + + getSnapshot(topicKey: K, params: TopicParams): TopicData | undefined { + return this.entries.get((topicDefinitions[topicKey] as any).key(params))?.data as TopicData | undefined; + } + + getStatus(topicKey: K, params: TopicParams): TopicStatus { + return this.entries.get((topicDefinitions[topicKey] as any).key(params))?.status ?? "loading"; + } + + getError(topicKey: K, params: TopicParams): Error | null { + return this.entries.get((topicDefinitions[topicKey] as any).key(params))?.error ?? null; + } + + dispose(): void { + for (const entry of this.entries.values()) { + entry.dispose(); + } + this.entries.clear(); + } +} + +class TopicEntry { + data: TData | undefined; + status: TopicStatus = "loading"; + error: Error | null = null; + listenerCount = 0; + + private readonly listeners = new Set<() => void>(); + private conn: Awaited["connect"]>> | null = null; + private unsubscribeEvent: (() => void) | null = null; + private unsubscribeError: (() => void) | null = null; + private teardownTimer: ReturnType | null = null; + private startPromise: Promise | null = null; + private started = false; + + constructor( + private readonly definition: TopicDefinition, + private readonly backend: BackendClient, + private readonly params: TParams, + ) {} + + addListener(listener: () => void): void { + this.listeners.add(listener); + this.listenerCount = this.listeners.size; + } + + removeListener(listener: () => void): void { + this.listeners.delete(listener); + this.listenerCount = this.listeners.size; + } + + ensureStarted(): void { + if (this.started || this.startPromise) { + return; + } + this.startPromise = this.start().finally(() => { + this.startPromise = null; + }); + } + + scheduleTeardown(ms: number, onTeardown: () => void): void { + this.teardownTimer = setTimeout(() => { + this.dispose(); + onTeardown(); + }, ms); + } + + cancelTeardown(): void { + if (this.teardownTimer) { + clearTimeout(this.teardownTimer); + this.teardownTimer = null; + } + } + + dispose(): void { + this.cancelTeardown(); + this.unsubscribeEvent?.(); + this.unsubscribeError?.(); + if (this.conn) { + void this.conn.dispose(); + } + this.conn = null; + this.data = undefined; + this.status = "loading"; + this.error = null; + this.started = false; + } + + private async start(): Promise { + this.status = "loading"; + this.error = null; + this.notify(); + + try { + this.conn = await this.definition.connect(this.backend, this.params); + this.unsubscribeEvent = this.conn.on(this.definition.event, (event: TEvent) => { + if (this.data === undefined) { + return; + } + this.data = this.definition.applyEvent(this.data, event); + this.notify(); + }); + this.unsubscribeError = this.conn.onError((error: unknown) => { + this.status = "error"; + this.error = error instanceof Error ? error : new Error(String(error)); + this.notify(); + }); + this.data = await this.definition.fetchInitial(this.backend, this.params); + this.status = "connected"; + this.started = true; + this.notify(); + } catch (error) { + this.status = "error"; + this.error = error instanceof Error ? error : new Error(String(error)); + this.started = false; + this.notify(); + } + } + + private notify(): void { + for (const listener of [...this.listeners]) { + listener(); + } + } +} diff --git a/foundry/packages/client/src/interest/topics.ts b/foundry/packages/client/src/interest/topics.ts new file mode 100644 index 0000000..a111248 --- /dev/null +++ b/foundry/packages/client/src/interest/topics.ts @@ -0,0 +1,131 @@ +import type { + AppEvent, + FoundryAppSnapshot, + ProviderId, + SandboxProcessesEvent, + SessionEvent, + TaskEvent, + WorkbenchSessionDetail, + WorkbenchTaskDetail, + WorkspaceEvent, + WorkspaceSummarySnapshot, +} from "@sandbox-agent/foundry-shared"; +import type { ActorConn, BackendClient, SandboxProcessRecord } from "../backend-client.js"; + +/** + * Topic definitions for the interest manager. + * + * Each topic describes one actor connection plus one materialized read model. + * Events always carry full replacement payloads for the changed entity so the + * client can replace cached state directly instead of reconstructing patches. + */ +export interface TopicDefinition { + key: (params: TParams) => string; + event: string; + connect: (backend: BackendClient, params: TParams) => Promise; + fetchInitial: (backend: BackendClient, params: TParams) => Promise; + applyEvent: (current: TData, event: TEvent) => TData; +} + +export interface AppTopicParams {} +export interface WorkspaceTopicParams { + workspaceId: string; +} +export interface TaskTopicParams { + workspaceId: string; + repoId: string; + taskId: string; +} +export interface SessionTopicParams { + workspaceId: string; + repoId: string; + taskId: string; + sessionId: string; +} +export interface SandboxProcessesTopicParams { + workspaceId: string; + providerId: ProviderId; + sandboxId: string; +} + +function upsertById(items: T[], nextItem: T, sort: (left: T, right: T) => number): T[] { + const filtered = items.filter((item) => item.id !== nextItem.id); + return [...filtered, nextItem].sort(sort); +} + +export const topicDefinitions = { + app: { + key: () => "app", + event: "appUpdated", + connect: (backend: BackendClient, _params: AppTopicParams) => backend.connectWorkspace("app"), + fetchInitial: (backend: BackendClient, _params: AppTopicParams) => backend.getAppSnapshot(), + applyEvent: (_current: FoundryAppSnapshot, event: AppEvent) => event.snapshot, + } satisfies TopicDefinition, + + workspace: { + key: (params: WorkspaceTopicParams) => `workspace:${params.workspaceId}`, + event: "workspaceUpdated", + connect: (backend: BackendClient, params: WorkspaceTopicParams) => backend.connectWorkspace(params.workspaceId), + fetchInitial: (backend: BackendClient, params: WorkspaceTopicParams) => backend.getWorkspaceSummary(params.workspaceId), + applyEvent: (current: WorkspaceSummarySnapshot, event: WorkspaceEvent) => { + switch (event.type) { + case "taskSummaryUpdated": + return { + ...current, + taskSummaries: upsertById(current.taskSummaries, event.taskSummary, (left, right) => right.updatedAtMs - left.updatedAtMs), + }; + case "taskRemoved": + return { + ...current, + taskSummaries: current.taskSummaries.filter((task) => task.id !== event.taskId), + }; + case "repoAdded": + case "repoUpdated": + return { + ...current, + repos: upsertById(current.repos, event.repo, (left, right) => right.latestActivityMs - left.latestActivityMs), + }; + case "repoRemoved": + return { + ...current, + repos: current.repos.filter((repo) => repo.id !== event.repoId), + }; + } + }, + } satisfies TopicDefinition, + + task: { + key: (params: TaskTopicParams) => `task:${params.workspaceId}:${params.taskId}`, + event: "taskUpdated", + connect: (backend: BackendClient, params: TaskTopicParams) => backend.connectTask(params.workspaceId, params.repoId, params.taskId), + fetchInitial: (backend: BackendClient, params: TaskTopicParams) => backend.getTaskDetail(params.workspaceId, params.repoId, params.taskId), + applyEvent: (_current: WorkbenchTaskDetail, event: TaskEvent) => event.detail, + } satisfies TopicDefinition, + + session: { + key: (params: SessionTopicParams) => `session:${params.workspaceId}:${params.taskId}:${params.sessionId}`, + event: "sessionUpdated", + connect: (backend: BackendClient, params: SessionTopicParams) => backend.connectTask(params.workspaceId, params.repoId, params.taskId), + fetchInitial: (backend: BackendClient, params: SessionTopicParams) => + backend.getSessionDetail(params.workspaceId, params.repoId, params.taskId, params.sessionId), + applyEvent: (current: WorkbenchSessionDetail, event: SessionEvent) => { + if (event.session.sessionId !== current.sessionId) { + return current; + } + return event.session; + }, + } satisfies TopicDefinition, + + sandboxProcesses: { + key: (params: SandboxProcessesTopicParams) => `sandbox:${params.workspaceId}:${params.providerId}:${params.sandboxId}`, + event: "processesUpdated", + connect: (backend: BackendClient, params: SandboxProcessesTopicParams) => backend.connectSandbox(params.workspaceId, params.providerId, params.sandboxId), + fetchInitial: async (backend: BackendClient, params: SandboxProcessesTopicParams) => + (await backend.listSandboxProcesses(params.workspaceId, params.providerId, params.sandboxId)).processes, + applyEvent: (_current: SandboxProcessRecord[], event: SandboxProcessesEvent) => event.processes, + } satisfies TopicDefinition, +} as const; + +export type TopicKey = keyof typeof topicDefinitions; +export type TopicParams = Parameters<(typeof topicDefinitions)[K]["fetchInitial"]>[1]; +export type TopicData = Awaited>; diff --git a/foundry/packages/client/src/interest/use-interest.ts b/foundry/packages/client/src/interest/use-interest.ts new file mode 100644 index 0000000..4ffd733 --- /dev/null +++ b/foundry/packages/client/src/interest/use-interest.ts @@ -0,0 +1,56 @@ +import { useMemo, useRef, useSyncExternalStore } from "react"; +import type { InterestManager, TopicState } from "./manager.js"; +import { topicDefinitions, type TopicKey, type TopicParams } from "./topics.js"; + +/** + * React bridge for the interest manager. + * + * `null` params disable the subscription entirely, which is how screens express + * conditional interest in task/session/sandbox topics. + */ +export function useInterest(manager: InterestManager, topicKey: K, params: TopicParams | null): TopicState { + const paramsKey = params ? (topicDefinitions[topicKey] as any).key(params) : null; + const paramsRef = useRef | null>(params); + paramsRef.current = params; + + const subscribe = useMemo(() => { + return (listener: () => void) => { + const currentParams = paramsRef.current; + if (!currentParams) { + return () => {}; + } + return manager.subscribe(topicKey, currentParams, listener); + }; + }, [manager, topicKey, paramsKey]); + + const getSnapshot = useMemo(() => { + let lastSnapshot: TopicState | null = null; + + return (): TopicState => { + const currentParams = paramsRef.current; + const nextSnapshot: TopicState = currentParams + ? { + data: manager.getSnapshot(topicKey, currentParams), + status: manager.getStatus(topicKey, currentParams), + error: manager.getError(topicKey, currentParams), + } + : { + data: undefined, + status: "loading", + error: null, + }; + + // `useSyncExternalStore` requires referentially-stable snapshots when the + // underlying store has not changed. Reuse the previous object whenever + // the topic data/status/error triplet is unchanged. + if (lastSnapshot && lastSnapshot.data === nextSnapshot.data && lastSnapshot.status === nextSnapshot.status && lastSnapshot.error === nextSnapshot.error) { + return lastSnapshot; + } + + lastSnapshot = nextSnapshot; + return nextSnapshot; + }; + }, [manager, topicKey, paramsKey]); + + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} diff --git a/foundry/packages/client/src/mock-app.ts b/foundry/packages/client/src/mock-app.ts index 61dadd2..0cf499d 100644 --- a/foundry/packages/client/src/mock-app.ts +++ b/foundry/packages/client/src/mock-app.ts @@ -1,4 +1,5 @@ import { injectMockLatency } from "./mock/latency.js"; +import rivetDevFixture from "../../../scripts/data/rivet-dev.json" with { type: "json" }; export type MockBillingPlanId = "free" | "team"; export type MockBillingStatus = "active" | "trialing" | "past_due" | "scheduled_cancel"; @@ -140,6 +141,69 @@ function syncStatusFromLegacy(value: unknown): MockGithubSyncStatus { } } +/** + * Build the "rivet" mock organization from real public GitHub data. + * Fixture sourced from: scripts/pull-org-data.ts (run against rivet-dev). + * Members that don't exist in the public fixture get synthetic entries + * so the mock still has realistic owner/admin/member role distribution. + */ +function buildRivetOrganization(): MockFoundryOrganization { + const repos = rivetDevFixture.repos.map((r) => r.fullName); + const fixtureMembers: MockFoundryOrganizationMember[] = rivetDevFixture.members.map((m) => ({ + id: `member-rivet-${m.login.toLowerCase()}`, + name: m.login, + email: `${m.login.toLowerCase()}@rivet.dev`, + role: "member" as const, + state: "active" as const, + })); + + // Ensure we have named owner/admin roles for the mock user personas + // that may not appear in the public members list + const knownMembers: MockFoundryOrganizationMember[] = [ + { id: "member-rivet-jamie", name: "Jamie", email: "jamie@rivet.dev", role: "owner", state: "active" }, + { id: "member-rivet-nathan", name: "Nathan", email: "nathan@acme.dev", role: "member", state: "active" }, + ]; + + // Merge: known members take priority, then fixture members not already covered + const knownIds = new Set(knownMembers.map((m) => m.id)); + const members = [...knownMembers, ...fixtureMembers.filter((m) => !knownIds.has(m.id))]; + + return { + id: "rivet", + workspaceId: "rivet", + kind: "organization", + settings: { + displayName: rivetDevFixture.name ?? rivetDevFixture.login, + slug: "rivet", + primaryDomain: "rivet.dev", + seatAccrualMode: "first_prompt", + defaultModel: "o3", + autoImportRepos: true, + }, + github: { + connectedAccount: rivetDevFixture.login, + installationStatus: "connected", + syncStatus: "synced", + importedRepoCount: repos.length, + lastSyncLabel: "Synced just now", + lastSyncAt: Date.now() - 60_000, + }, + billing: { + planId: "team", + status: "trialing", + seatsIncluded: 5, + trialEndsAt: isoDate(12), + renewalAt: isoDate(12), + stripeCustomerId: "cus_mock_rivet_team", + paymentMethodLabel: "Visa ending in 4242", + invoices: [{ id: "inv-rivet-001", label: "Team pilot", issuedAt: "2026-03-04", amountUsd: 0, status: "paid" }], + }, + members, + seatAssignments: ["jamie@rivet.dev"], + repoCatalog: repos, + }; +} + function buildDefaultSnapshot(): MockFoundryAppSnapshot { return { auth: { @@ -259,44 +323,7 @@ function buildDefaultSnapshot(): MockFoundryAppSnapshot { seatAssignments: ["nathan@acme.dev", "maya@acme.dev"], repoCatalog: ["acme/backend", "acme/frontend", "acme/infra"], }, - { - id: "rivet", - workspaceId: "rivet", - kind: "organization", - settings: { - displayName: "Rivet", - slug: "rivet", - primaryDomain: "rivet.dev", - seatAccrualMode: "first_prompt", - defaultModel: "o3", - autoImportRepos: true, - }, - github: { - connectedAccount: "rivet-dev", - installationStatus: "reconnect_required", - syncStatus: "error", - importedRepoCount: 4, - lastSyncLabel: "Sync stalled 2 hours ago", - lastSyncAt: Date.now() - 2 * 60 * 60_000, - }, - billing: { - planId: "team", - status: "trialing", - seatsIncluded: 5, - trialEndsAt: isoDate(12), - renewalAt: isoDate(12), - stripeCustomerId: "cus_mock_rivet_team", - paymentMethodLabel: "Visa ending in 4242", - invoices: [{ id: "inv-rivet-001", label: "Team pilot", issuedAt: "2026-03-04", amountUsd: 0, status: "paid" }], - }, - members: [ - { id: "member-rivet-jamie", name: "Jamie", email: "jamie@rivet.dev", role: "owner", state: "active" }, - { id: "member-rivet-nathan", name: "Nathan", email: "nathan@acme.dev", role: "member", state: "active" }, - { id: "member-rivet-lena", name: "Lena", email: "lena@rivet.dev", role: "admin", state: "active" }, - ], - seatAssignments: ["jamie@rivet.dev"], - repoCatalog: ["rivet/dashboard", "rivet/agents", "rivet/billing", "rivet/infrastructure"], - }, + buildRivetOrganization(), { id: "personal-jamie", workspaceId: "personal-jamie", diff --git a/foundry/packages/client/src/mock/backend-client.ts b/foundry/packages/client/src/mock/backend-client.ts index 7b0ad7f..2048a60 100644 --- a/foundry/packages/client/src/mock/backend-client.ts +++ b/foundry/packages/client/src/mock/backend-client.ts @@ -1,7 +1,10 @@ import type { AddRepoInput, + AppEvent, CreateTaskInput, FoundryAppSnapshot, + SandboxProcessesEvent, + SessionEvent, TaskRecord, TaskSummary, TaskWorkbenchChangeModelInput, @@ -16,6 +19,12 @@ import type { TaskWorkbenchSnapshot, TaskWorkbenchTabInput, TaskWorkbenchUpdateDraftInput, + TaskEvent, + WorkbenchSessionDetail, + WorkbenchTaskDetail, + WorkbenchTaskSummary, + WorkspaceEvent, + WorkspaceSummarySnapshot, HistoryEvent, HistoryQueryInput, ProviderId, @@ -27,7 +36,7 @@ import type { SwitchResult, } from "@sandbox-agent/foundry-shared"; import type { ProcessCreateRequest, ProcessLogFollowQuery, ProcessLogsResponse, ProcessSignalQuery } from "sandbox-agent"; -import type { BackendClient, SandboxProcessRecord, SandboxSessionEventRecord, SandboxSessionRecord } from "../backend-client.js"; +import type { ActorConn, BackendClient, SandboxProcessRecord, SandboxSessionEventRecord, SandboxSessionRecord } from "../backend-client.js"; import { getSharedMockWorkbenchClient } from "./workbench-client.js"; interface MockProcessRecord extends SandboxProcessRecord { @@ -86,6 +95,7 @@ export function createMockBackendClient(defaultWorkspaceId = "default"): Backend const workbench = getSharedMockWorkbenchClient(); const listenersBySandboxId = new Map void>>(); const processesBySandboxId = new Map(); + const connectionListeners = new Map void>>(); let nextPid = 4000; let nextProcessId = 1; @@ -110,11 +120,174 @@ export function createMockBackendClient(defaultWorkspaceId = "default"): Backend const notifySandbox = (sandboxId: string): void => { const listeners = listenersBySandboxId.get(sandboxId); if (!listeners) { + emitSandboxProcessesUpdate(sandboxId); return; } for (const listener of [...listeners]) { listener(); } + emitSandboxProcessesUpdate(sandboxId); + }; + + const connectionChannel = (scope: string, event: string): string => `${scope}:${event}`; + + const emitConnectionEvent = (scope: string, event: string, payload: any): void => { + const listeners = connectionListeners.get(connectionChannel(scope, event)); + if (!listeners) { + return; + } + for (const listener of [...listeners]) { + listener(payload); + } + }; + + const createConn = (scope: string): ActorConn => ({ + on(event: string, listener: (payload: any) => void): () => void { + const channel = connectionChannel(scope, event); + let listeners = connectionListeners.get(channel); + if (!listeners) { + listeners = new Set(); + connectionListeners.set(channel, listeners); + } + listeners.add(listener); + return () => { + const current = connectionListeners.get(channel); + if (!current) { + return; + } + current.delete(listener); + if (current.size === 0) { + connectionListeners.delete(channel); + } + }; + }, + onError(): () => void { + return () => {}; + }, + async dispose(): Promise {}, + }); + + const buildTaskSummary = (task: TaskWorkbenchSnapshot["tasks"][number]): WorkbenchTaskSummary => ({ + id: task.id, + repoId: task.repoId, + title: task.title, + status: task.status, + repoName: task.repoName, + updatedAtMs: task.updatedAtMs, + branch: task.branch, + pullRequest: task.pullRequest, + sessionsSummary: task.tabs.map((tab) => ({ + id: tab.id, + sessionId: tab.sessionId, + sessionName: tab.sessionName, + agent: tab.agent, + model: tab.model, + status: tab.status, + thinkingSinceMs: tab.thinkingSinceMs, + unread: tab.unread, + created: tab.created, + })), + }); + + const buildTaskDetail = (task: TaskWorkbenchSnapshot["tasks"][number]): WorkbenchTaskDetail => ({ + ...buildTaskSummary(task), + task: task.title, + agentType: task.tabs[0]?.agent === "Codex" ? "codex" : "claude", + runtimeStatus: toTaskStatus(task.status === "archived" ? "archived" : "running", task.status === "archived"), + statusMessage: task.status === "archived" ? "archived" : "mock sandbox ready", + activeSessionId: task.tabs[0]?.sessionId ?? null, + diffStat: task.fileChanges.length > 0 ? `+${task.fileChanges.length}/-${task.fileChanges.length}` : "+0/-0", + prUrl: task.pullRequest ? `https://example.test/pr/${task.pullRequest.number}` : null, + reviewStatus: null, + fileChanges: task.fileChanges, + diffs: task.diffs, + fileTree: task.fileTree, + minutesUsed: task.minutesUsed, + sandboxes: [ + { + providerId: "local", + sandboxId: task.id, + cwd: mockCwd(task.repoName, task.id), + }, + ], + activeSandboxId: task.id, + }); + + const buildSessionDetail = (task: TaskWorkbenchSnapshot["tasks"][number], tabId: string): WorkbenchSessionDetail => { + const tab = task.tabs.find((candidate) => candidate.id === tabId); + if (!tab) { + throw new Error(`Unknown mock tab ${tabId} for task ${task.id}`); + } + return { + sessionId: tab.id, + tabId: tab.id, + sandboxSessionId: tab.sessionId, + sessionName: tab.sessionName, + agent: tab.agent, + model: tab.model, + status: tab.status, + thinkingSinceMs: tab.thinkingSinceMs, + unread: tab.unread, + created: tab.created, + draft: tab.draft, + transcript: tab.transcript, + }; + }; + + const buildWorkspaceSummary = (): WorkspaceSummarySnapshot => { + const snapshot = workbench.getSnapshot(); + const taskSummaries = snapshot.tasks.map(buildTaskSummary); + return { + workspaceId: defaultWorkspaceId, + repos: snapshot.repos.map((repo) => { + const repoTasks = taskSummaries.filter((task) => task.repoId === repo.id); + return { + id: repo.id, + label: repo.label, + taskCount: repoTasks.length, + latestActivityMs: repoTasks.reduce((latest, task) => Math.max(latest, task.updatedAtMs), 0), + }; + }), + taskSummaries, + }; + }; + + const workspaceScope = (workspaceId: string): string => `workspace:${workspaceId}`; + const taskScope = (workspaceId: string, repoId: string, taskId: string): string => `task:${workspaceId}:${repoId}:${taskId}`; + const sandboxScope = (workspaceId: string, providerId: string, sandboxId: string): string => `sandbox:${workspaceId}:${providerId}:${sandboxId}`; + + const emitWorkspaceSnapshot = (): void => { + const summary = buildWorkspaceSummary(); + const latestTask = [...summary.taskSummaries].sort((left, right) => right.updatedAtMs - left.updatedAtMs)[0] ?? null; + if (latestTask) { + emitConnectionEvent(workspaceScope(defaultWorkspaceId), "workspaceUpdated", { + type: "taskSummaryUpdated", + taskSummary: latestTask, + } satisfies WorkspaceEvent); + } + }; + + const emitTaskUpdate = (taskId: string): void => { + const task = requireTask(taskId); + emitConnectionEvent(taskScope(defaultWorkspaceId, task.repoId, task.id), "taskUpdated", { + type: "taskDetailUpdated", + detail: buildTaskDetail(task), + } satisfies TaskEvent); + }; + + const emitSessionUpdate = (taskId: string, tabId: string): void => { + const task = requireTask(taskId); + emitConnectionEvent(taskScope(defaultWorkspaceId, task.repoId, task.id), "sessionUpdated", { + type: "sessionUpdated", + session: buildSessionDetail(task, tabId), + } satisfies SessionEvent); + }; + + const emitSandboxProcessesUpdate = (sandboxId: string): void => { + emitConnectionEvent(sandboxScope(defaultWorkspaceId, "local", sandboxId), "processesUpdated", { + type: "processesUpdated", + processes: ensureProcessList(sandboxId).map((process) => cloneProcess(process)), + } satisfies SandboxProcessesEvent); }; const buildTaskRecord = (taskId: string): TaskRecord => { @@ -192,6 +365,22 @@ export function createMockBackendClient(defaultWorkspaceId = "default"): Backend return unsupportedAppSnapshot(); }, + async connectWorkspace(workspaceId: string): Promise { + return createConn(workspaceScope(workspaceId)); + }, + + async connectTask(workspaceId: string, repoId: string, taskId: string): Promise { + return createConn(taskScope(workspaceId, repoId, taskId)); + }, + + async connectSandbox(workspaceId: string, providerId: ProviderId, sandboxId: string): Promise { + return createConn(sandboxScope(workspaceId, providerId, sandboxId)); + }, + + subscribeApp(): () => void { + return () => {}; + }, + async signInWithGithub(): Promise { notSupported("signInWithGithub"); }, @@ -458,6 +647,18 @@ export function createMockBackendClient(defaultWorkspaceId = "default"): Backend return { endpoint: "mock://terminal-unavailable" }; }, + async getWorkspaceSummary(): Promise { + return buildWorkspaceSummary(); + }, + + async getTaskDetail(_workspaceId: string, _repoId: string, taskId: string): Promise { + return buildTaskDetail(requireTask(taskId)); + }, + + async getSessionDetail(_workspaceId: string, _repoId: string, taskId: string, sessionId: string): Promise { + return buildSessionDetail(requireTask(taskId), sessionId); + }, + async getWorkbench(): Promise { return workbench.getSnapshot(); }, @@ -467,59 +668,99 @@ export function createMockBackendClient(defaultWorkspaceId = "default"): Backend }, async createWorkbenchTask(_workspaceId: string, input: TaskWorkbenchCreateTaskInput): Promise { - return await workbench.createTask(input); + const created = await workbench.createTask(input); + emitWorkspaceSnapshot(); + emitTaskUpdate(created.taskId); + if (created.tabId) { + emitSessionUpdate(created.taskId, created.tabId); + } + return created; }, async markWorkbenchUnread(_workspaceId: string, input: TaskWorkbenchSelectInput): Promise { await workbench.markTaskUnread(input); + emitWorkspaceSnapshot(); + emitTaskUpdate(input.taskId); }, async renameWorkbenchTask(_workspaceId: string, input: TaskWorkbenchRenameInput): Promise { await workbench.renameTask(input); + emitWorkspaceSnapshot(); + emitTaskUpdate(input.taskId); }, async renameWorkbenchBranch(_workspaceId: string, input: TaskWorkbenchRenameInput): Promise { await workbench.renameBranch(input); + emitWorkspaceSnapshot(); + emitTaskUpdate(input.taskId); }, async createWorkbenchSession(_workspaceId: string, input: TaskWorkbenchSelectInput & { model?: string }): Promise<{ tabId: string }> { - return await workbench.addTab(input); + const created = await workbench.addTab(input); + emitWorkspaceSnapshot(); + emitTaskUpdate(input.taskId); + emitSessionUpdate(input.taskId, created.tabId); + return created; }, async renameWorkbenchSession(_workspaceId: string, input: TaskWorkbenchRenameSessionInput): Promise { await workbench.renameSession(input); + emitWorkspaceSnapshot(); + emitTaskUpdate(input.taskId); + emitSessionUpdate(input.taskId, input.tabId); }, async setWorkbenchSessionUnread(_workspaceId: string, input: TaskWorkbenchSetSessionUnreadInput): Promise { await workbench.setSessionUnread(input); + emitWorkspaceSnapshot(); + emitTaskUpdate(input.taskId); + emitSessionUpdate(input.taskId, input.tabId); }, async updateWorkbenchDraft(_workspaceId: string, input: TaskWorkbenchUpdateDraftInput): Promise { await workbench.updateDraft(input); + emitWorkspaceSnapshot(); + emitTaskUpdate(input.taskId); + emitSessionUpdate(input.taskId, input.tabId); }, async changeWorkbenchModel(_workspaceId: string, input: TaskWorkbenchChangeModelInput): Promise { await workbench.changeModel(input); + emitWorkspaceSnapshot(); + emitTaskUpdate(input.taskId); + emitSessionUpdate(input.taskId, input.tabId); }, async sendWorkbenchMessage(_workspaceId: string, input: TaskWorkbenchSendMessageInput): Promise { await workbench.sendMessage(input); + emitWorkspaceSnapshot(); + emitTaskUpdate(input.taskId); + emitSessionUpdate(input.taskId, input.tabId); }, async stopWorkbenchSession(_workspaceId: string, input: TaskWorkbenchTabInput): Promise { await workbench.stopAgent(input); + emitWorkspaceSnapshot(); + emitTaskUpdate(input.taskId); + emitSessionUpdate(input.taskId, input.tabId); }, async closeWorkbenchSession(_workspaceId: string, input: TaskWorkbenchTabInput): Promise { await workbench.closeTab(input); + emitWorkspaceSnapshot(); + emitTaskUpdate(input.taskId); }, async publishWorkbenchPr(_workspaceId: string, input: TaskWorkbenchSelectInput): Promise { await workbench.publishPr(input); + emitWorkspaceSnapshot(); + emitTaskUpdate(input.taskId); }, async revertWorkbenchFile(_workspaceId: string, input: TaskWorkbenchDiffInput): Promise { await workbench.revertFile(input); + emitWorkspaceSnapshot(); + emitTaskUpdate(input.taskId); }, async health(): Promise<{ ok: true }> { diff --git a/foundry/packages/client/src/remote/app-client.ts b/foundry/packages/client/src/remote/app-client.ts index e381540..9b80f3c 100644 --- a/foundry/packages/client/src/remote/app-client.ts +++ b/foundry/packages/client/src/remote/app-client.ts @@ -25,7 +25,7 @@ class RemoteFoundryAppStore implements FoundryAppClient { }; private readonly listeners = new Set<() => void>(); private refreshPromise: Promise | null = null; - private syncPollTimeout: ReturnType | null = null; + private unsubscribeApp: (() => void) | null = null; constructor(options: RemoteFoundryAppClientOptions) { this.backend = options.backend; @@ -37,9 +37,13 @@ class RemoteFoundryAppStore implements FoundryAppClient { subscribe(listener: () => void): () => void { this.listeners.add(listener); - void this.refresh(); + this.ensureStarted(); return () => { this.listeners.delete(listener); + if (this.listeners.size === 0 && this.unsubscribeApp) { + this.unsubscribeApp(); + this.unsubscribeApp = null; + } }; } @@ -66,7 +70,6 @@ class RemoteFoundryAppStore implements FoundryAppClient { async selectOrganization(organizationId: string): Promise { this.snapshot = await this.backend.selectAppOrganization(organizationId); this.notify(); - this.scheduleSyncPollingIfNeeded(); } async updateOrganizationProfile(input: UpdateFoundryOrganizationProfileInput): Promise { @@ -77,7 +80,6 @@ class RemoteFoundryAppStore implements FoundryAppClient { async triggerGithubSync(organizationId: string): Promise { this.snapshot = await this.backend.triggerAppRepoImport(organizationId); this.notify(); - this.scheduleSyncPollingIfNeeded(); } async completeHostedCheckout(organizationId: string, planId: FoundryBillingPlanId): Promise { @@ -107,20 +109,13 @@ class RemoteFoundryAppStore implements FoundryAppClient { this.notify(); } - private scheduleSyncPollingIfNeeded(): void { - if (this.syncPollTimeout) { - clearTimeout(this.syncPollTimeout); - this.syncPollTimeout = null; + private ensureStarted(): void { + if (!this.unsubscribeApp) { + this.unsubscribeApp = this.backend.subscribeApp(() => { + void this.refresh(); + }); } - - if (!this.snapshot.organizations.some((organization) => organization.github.syncStatus === "syncing")) { - return; - } - - this.syncPollTimeout = setTimeout(() => { - this.syncPollTimeout = null; - void this.refresh(); - }, 500); + void this.refresh(); } private async refresh(): Promise { @@ -132,7 +127,6 @@ class RemoteFoundryAppStore implements FoundryAppClient { this.refreshPromise = (async () => { this.snapshot = await this.backend.getAppSnapshot(); this.notify(); - this.scheduleSyncPollingIfNeeded(); })().finally(() => { this.refreshPromise = null; }); diff --git a/foundry/packages/client/src/workbench-model.ts b/foundry/packages/client/src/workbench-model.ts index 42cff08..b99f588 100644 --- a/foundry/packages/client/src/workbench-model.ts +++ b/foundry/packages/client/src/workbench-model.ts @@ -13,6 +13,7 @@ import type { WorkbenchRepo, WorkbenchTranscriptEvent as TranscriptEvent, } from "@sandbox-agent/foundry-shared"; +import rivetDevFixture from "../../../scripts/data/rivet-dev.json" with { type: "json" }; export const MODEL_GROUPS: ModelGroup[] = [ { @@ -801,13 +802,13 @@ export function buildInitialTasks(): Task[] { fileTree: [], minutesUsed: 312, }, - // ── rivet-dev/cloud ── + // ── rivet-dev/vbare ── { id: "h6", - repoId: "cloud", + repoId: "vbare", title: "Use full cloud run pool name for routing", status: "idle", - repoName: "rivet-dev/cloud", + repoName: "rivet-dev/vbare", updatedAtMs: minutesAgo(25), branch: "fix-use-full-cloud-run-pool-name", pullRequest: { number: 235, status: "ready" }, @@ -910,13 +911,13 @@ export function buildInitialTasks(): Task[] { ], minutesUsed: 0, }, - // ── rivet-dev/engine-ee ── + // ── rivet-dev/skills ── { id: "h7", - repoId: "engine-ee", + repoId: "skills", title: "Route compute gateway path correctly", status: "idle", - repoName: "rivet-dev/engine-ee", + repoName: "rivet-dev/skills", updatedAtMs: minutesAgo(50), branch: "fix-guard-support-https-targets", pullRequest: { number: 125, status: "ready" }, @@ -1024,13 +1025,13 @@ export function buildInitialTasks(): Task[] { ], minutesUsed: 78, }, - // ── rivet-dev/engine-ee (archived) ── + // ── rivet-dev/skills (archived) ── { id: "h8", - repoId: "engine-ee", + repoId: "skills", title: "Move compute gateway to guard", status: "archived", - repoName: "rivet-dev/engine-ee", + repoName: "rivet-dev/skills", updatedAtMs: minutesAgo(2 * 24 * 60), branch: "chore-move-compute-gateway-to", pullRequest: { number: 123, status: "ready" }, @@ -1066,13 +1067,13 @@ export function buildInitialTasks(): Task[] { fileTree: [], minutesUsed: 15, }, - // ── rivet-dev/secure-exec ── + // ── rivet-dev/deploy-action ── { id: "h9", - repoId: "secure-exec", + repoId: "deploy-action", title: "Harden namespace isolation for nested containers", status: "idle", - repoName: "rivet-dev/secure-exec", + repoName: "rivet-dev/deploy-action", updatedAtMs: minutesAgo(90), branch: "fix/namespace-isolation", pullRequest: null, @@ -1122,15 +1123,63 @@ export function buildInitialTasks(): Task[] { ]; } +/** + * Build repos list from the rivet-dev fixture data (scripts/data/rivet-dev.json). + * Uses real public repos so the mock sidebar matches what an actual rivet-dev + * workspace would show after a GitHub sync. + */ +function buildMockRepos(): WorkbenchRepo[] { + return rivetDevFixture.repos.map((r) => ({ + id: repoIdFromFullName(r.fullName), + label: r.fullName, + })); +} + +/** Derive a stable short id from a "org/repo" full name (e.g. "rivet-dev/rivet" → "rivet"). */ +function repoIdFromFullName(fullName: string): string { + const parts = fullName.split("/"); + return parts[parts.length - 1] ?? fullName; +} + +/** + * Build task entries from open PR fixture data. + * Maps to the backend's PR sync behavior (ProjectPrSyncActor) where PRs + * appear as first-class sidebar items even without an associated task. + * Each open PR gets a lightweight task entry so it shows in the sidebar. + */ +function buildPrTasks(): Task[] { + // Collect branch names already claimed by hand-written tasks so we don't duplicate + const existingBranches = new Set( + buildInitialTasks() + .map((t) => t.branch) + .filter(Boolean), + ); + + return rivetDevFixture.openPullRequests + .filter((pr) => !existingBranches.has(pr.headRefName)) + .map((pr) => { + const repoId = repoIdFromFullName(pr.repoFullName); + return { + id: `pr-${repoId}-${pr.number}`, + repoId, + title: pr.title, + status: "idle" as const, + repoName: pr.repoFullName, + updatedAtMs: new Date(pr.updatedAt).getTime(), + branch: pr.headRefName, + pullRequest: { number: pr.number, status: pr.draft ? ("draft" as const) : ("ready" as const) }, + tabs: [], + fileChanges: [], + diffs: {}, + fileTree: [], + minutesUsed: 0, + }; + }); +} + export function buildInitialMockLayoutViewModel(): TaskWorkbenchSnapshot { - const repos: WorkbenchRepo[] = [ - { id: "sandbox-agent", label: "rivet-dev/sandbox-agent" }, - { id: "rivet", label: "rivet-dev/rivet" }, - { id: "cloud", label: "rivet-dev/cloud" }, - { id: "engine-ee", label: "rivet-dev/engine-ee" }, - { id: "secure-exec", label: "rivet-dev/secure-exec" }, - ]; - const tasks = buildInitialTasks(); + const repos = buildMockRepos(); + const tasks = [...buildInitialTasks(), ...buildPrTasks()]; return { workspaceId: "default", repos, diff --git a/foundry/packages/client/test/interest-manager.test.ts b/foundry/packages/client/test/interest-manager.test.ts new file mode 100644 index 0000000..188195c --- /dev/null +++ b/foundry/packages/client/test/interest-manager.test.ts @@ -0,0 +1,171 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { WorkspaceEvent, WorkspaceSummarySnapshot } from "@sandbox-agent/foundry-shared"; +import type { ActorConn, BackendClient } from "../src/backend-client.js"; +import { RemoteInterestManager } from "../src/interest/remote-manager.js"; + +class FakeActorConn implements ActorConn { + private readonly listeners = new Map void>>(); + private readonly errorListeners = new Set<(error: unknown) => void>(); + disposeCount = 0; + + on(event: string, listener: (payload: any) => void): () => void { + let current = this.listeners.get(event); + if (!current) { + current = new Set(); + this.listeners.set(event, current); + } + current.add(listener); + return () => { + current?.delete(listener); + if (current?.size === 0) { + this.listeners.delete(event); + } + }; + } + + onError(listener: (error: unknown) => void): () => void { + this.errorListeners.add(listener); + return () => { + this.errorListeners.delete(listener); + }; + } + + emit(event: string, payload: unknown): void { + for (const listener of this.listeners.get(event) ?? []) { + listener(payload); + } + } + + emitError(error: unknown): void { + for (const listener of this.errorListeners) { + listener(error); + } + } + + async dispose(): Promise { + this.disposeCount += 1; + } +} + +function workspaceSnapshot(): WorkspaceSummarySnapshot { + return { + workspaceId: "ws-1", + repos: [{ id: "repo-1", label: "repo-1", taskCount: 1, latestActivityMs: 10 }], + taskSummaries: [ + { + id: "task-1", + repoId: "repo-1", + title: "Initial task", + status: "idle", + repoName: "repo-1", + updatedAtMs: 10, + branch: "main", + pullRequest: null, + sessionsSummary: [], + }, + ], + }; +} + +function createBackend(conn: FakeActorConn, snapshot: WorkspaceSummarySnapshot): BackendClient { + return { + connectWorkspace: vi.fn(async () => conn), + getWorkspaceSummary: vi.fn(async () => snapshot), + } as unknown as BackendClient; +} + +async function flushAsyncWork(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +describe("RemoteInterestManager", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("shares one connection per topic key and applies incoming events", async () => { + const conn = new FakeActorConn(); + const backend = createBackend(conn, workspaceSnapshot()); + const manager = new RemoteInterestManager(backend); + const params = { workspaceId: "ws-1" } as const; + const listenerA = vi.fn(); + const listenerB = vi.fn(); + + const unsubscribeA = manager.subscribe("workspace", params, listenerA); + const unsubscribeB = manager.subscribe("workspace", params, listenerB); + await flushAsyncWork(); + + expect(backend.connectWorkspace).toHaveBeenCalledTimes(1); + expect(backend.getWorkspaceSummary).toHaveBeenCalledTimes(1); + expect(manager.getStatus("workspace", params)).toBe("connected"); + expect(manager.getSnapshot("workspace", params)?.taskSummaries[0]?.title).toBe("Initial task"); + + conn.emit("workspaceUpdated", { + type: "taskSummaryUpdated", + taskSummary: { + id: "task-1", + repoId: "repo-1", + title: "Updated task", + status: "running", + repoName: "repo-1", + updatedAtMs: 20, + branch: "feature/live", + pullRequest: null, + sessionsSummary: [], + }, + } satisfies WorkspaceEvent); + + expect(manager.getSnapshot("workspace", params)?.taskSummaries[0]?.title).toBe("Updated task"); + expect(listenerA).toHaveBeenCalled(); + expect(listenerB).toHaveBeenCalled(); + + unsubscribeA(); + unsubscribeB(); + manager.dispose(); + }); + + it("keeps a topic warm during the grace period and tears it down afterwards", async () => { + const conn = new FakeActorConn(); + const backend = createBackend(conn, workspaceSnapshot()); + const manager = new RemoteInterestManager(backend); + const params = { workspaceId: "ws-1" } as const; + + const unsubscribeA = manager.subscribe("workspace", params, () => {}); + await flushAsyncWork(); + unsubscribeA(); + + vi.advanceTimersByTime(29_000); + + const unsubscribeB = manager.subscribe("workspace", params, () => {}); + await flushAsyncWork(); + + expect(backend.connectWorkspace).toHaveBeenCalledTimes(1); + expect(conn.disposeCount).toBe(0); + + unsubscribeB(); + vi.advanceTimersByTime(30_000); + + expect(conn.disposeCount).toBe(1); + expect(manager.getSnapshot("workspace", params)).toBeUndefined(); + }); + + it("surfaces connection errors to subscribers", async () => { + const conn = new FakeActorConn(); + const backend = createBackend(conn, workspaceSnapshot()); + const manager = new RemoteInterestManager(backend); + const params = { workspaceId: "ws-1" } as const; + + manager.subscribe("workspace", params, () => {}); + await flushAsyncWork(); + + conn.emitError(new Error("socket dropped")); + + expect(manager.getStatus("workspace", params)).toBe("error"); + expect(manager.getError("workspace", params)?.message).toBe("socket dropped"); + }); +}); diff --git a/foundry/packages/frontend/index.html b/foundry/packages/frontend/index.html index dc0af73..4e72d23 100644 --- a/foundry/packages/frontend/index.html +++ b/foundry/packages/frontend/index.html @@ -1,15 +1,12 @@ - diff --git a/foundry/packages/frontend/src/app/router.tsx b/foundry/packages/frontend/src/app/router.tsx index cc33ba7..343f6eb 100644 --- a/foundry/packages/frontend/src/app/router.tsx +++ b/foundry/packages/frontend/src/app/router.tsx @@ -1,6 +1,7 @@ import { type ReactNode, useEffect } from "react"; import { setFrontendErrorContext } from "@sandbox-agent/foundry-frontend-errors/client"; import type { FoundryBillingPlanId } from "@sandbox-agent/foundry-shared"; +import { useInterest } from "@sandbox-agent/foundry-client"; import { Navigate, Outlet, createRootRoute, createRoute, createRouter, useRouterState } from "@tanstack/react-router"; import { MockLayout } from "../components/mock-layout"; import { @@ -12,8 +13,8 @@ import { MockSignInPage, } from "../components/mock-onboarding"; import { defaultWorkspaceId, isMockFrontendClient } from "../lib/env"; +import { interestManager } from "../lib/interest"; import { activeMockOrganization, getMockOrganizationById, isAppSnapshotBootstrapping, useMockAppClient, useMockAppSnapshot } from "../lib/mock-app"; -import { getTaskWorkbenchClient } from "../lib/workbench"; const rootRoute = createRootRoute({ component: RootLayout, @@ -324,7 +325,7 @@ function AppWorkspaceGate({ workspaceId, children }: { workspaceId: string; chil } function RepoRouteInner({ workspaceId, repoId }: { workspaceId: string; repoId: string }) { - const taskWorkbenchClient = getTaskWorkbenchClient(workspaceId); + const workspaceState = useInterest(interestManager, "workspace", { workspaceId }); useEffect(() => { setFrontendErrorContext({ workspaceId, @@ -332,7 +333,7 @@ function RepoRouteInner({ workspaceId, repoId }: { workspaceId: string; repoId: repoId, }); }, [repoId, workspaceId]); - const activeTaskId = taskWorkbenchClient.getSnapshot().tasks.find((task) => task.repoId === repoId)?.id; + const activeTaskId = workspaceState.data?.taskSummaries.find((task) => task.repoId === repoId)?.id; if (!activeTaskId) { return ; } diff --git a/foundry/packages/frontend/src/components/dev-panel.tsx b/foundry/packages/frontend/src/components/dev-panel.tsx new file mode 100644 index 0000000..f0a176c --- /dev/null +++ b/foundry/packages/frontend/src/components/dev-panel.tsx @@ -0,0 +1,379 @@ +import { memo, useCallback, useEffect, useMemo, useState } from "react"; +import { useStyletron } from "baseui"; +import { useFoundryTokens } from "../app/theme"; +import { isMockFrontendClient } from "../lib/env"; +import type { FoundryOrganization, TaskWorkbenchSnapshot, WorkbenchTask } from "@sandbox-agent/foundry-shared"; + +interface DevPanelProps { + workspaceId: string; + snapshot: TaskWorkbenchSnapshot; + organization?: FoundryOrganization | null; +} + +interface TopicInfo { + label: string; + key: string; + listenerCount: number; + hasConnection: boolean; + lastRefresh: number | null; +} + +function timeAgo(ts: number | null): string { + if (!ts) return "never"; + const seconds = Math.floor((Date.now() - ts) / 1000); + if (seconds < 5) return "now"; + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m`; + return `${Math.floor(minutes / 60)}h`; +} + +function taskStatusLabel(task: WorkbenchTask): string { + if (task.status === "archived") return "archived"; + const hasRunning = task.tabs?.some((tab) => tab.status === "running"); + if (hasRunning) return "running"; + return task.status ?? "idle"; +} + +function statusColor(status: string, t: ReturnType): string { + switch (status) { + case "running": + return t.statusSuccess; + case "archived": + return t.textMuted; + case "error": + case "failed": + return t.statusError; + default: + return t.textTertiary; + } +} + +function syncStatusColor(status: string, t: ReturnType): string { + switch (status) { + case "synced": + return t.statusSuccess; + case "syncing": + case "pending": + return t.statusWarning; + case "error": + return t.statusError; + default: + return t.textMuted; + } +} + +function installStatusColor(status: string, t: ReturnType): string { + switch (status) { + case "connected": + return t.statusSuccess; + case "install_required": + return t.statusWarning; + case "reconnect_required": + return t.statusError; + default: + return t.textMuted; + } +} + +export const DevPanel = memo(function DevPanel({ workspaceId, snapshot, organization }: DevPanelProps) { + const [css] = useStyletron(); + const t = useFoundryTokens(); + const [now, setNow] = useState(Date.now()); + + // Tick every 2s to keep relative timestamps fresh + useEffect(() => { + const id = setInterval(() => setNow(Date.now()), 2000); + return () => clearInterval(id); + }, []); + + const topics = useMemo((): TopicInfo[] => { + const items: TopicInfo[] = []; + + // Workbench subscription topic + items.push({ + label: "Workbench", + key: `ws:${workspaceId}`, + listenerCount: 1, + hasConnection: true, + lastRefresh: now, + }); + + // Per-task tab subscriptions + for (const task of snapshot.tasks ?? []) { + if (task.status === "archived") continue; + for (const tab of task.tabs ?? []) { + items.push({ + label: `Tab/${task.title?.slice(0, 16) || task.id.slice(0, 8)}/${tab.sessionName.slice(0, 10)}`, + key: `${workspaceId}:${task.id}:${tab.id}`, + listenerCount: 1, + hasConnection: tab.status === "running", + lastRefresh: tab.status === "running" ? now : null, + }); + } + } + + return items; + }, [workspaceId, snapshot, now]); + + const tasks = snapshot.tasks ?? []; + const repos = snapshot.repos ?? []; + const projects = snapshot.projects ?? []; + + const mono = css({ + fontFamily: "ui-monospace, SFMono-Regular, 'SF Mono', Consolas, monospace", + fontSize: "10px", + }); + + return ( +
+ {/* Header */} +
+ + Dev + {isMockFrontendClient && MOCK} + + Shift+D +
+ + {/* Body */} +
+ {/* Interest Topics */} +
+ {topics.map((topic) => ( +
+ + + {topic.label} + + {topic.key.length > 24 ? `...${topic.key.slice(-20)}` : topic.key} + {timeAgo(topic.lastRefresh)} +
+ ))} + {topics.length === 0 && No active subscriptions} +
+ + {/* Snapshot Summary */} +
+
+ + + +
+
+ + {/* Tasks */} + {tasks.length > 0 && ( +
+ {tasks.slice(0, 10).map((task) => { + const status = taskStatusLabel(task); + return ( +
+ + + {task.title || task.id.slice(0, 12)} + + {status} + {task.tabs?.length ?? 0} tabs +
+ ); + })} +
+ )} + + {/* GitHub */} + {organization && ( +
+
+
+ + App + + {organization.github.installationStatus.replace(/_/g, " ")} + +
+
+ + Sync + {organization.github.syncStatus} +
+
+ +
+ {organization.github.connectedAccount && ( +
@{organization.github.connectedAccount}
+ )} + {organization.github.lastSyncLabel && ( +
last sync: {organization.github.lastSyncLabel}
+ )} +
+
+ )} + + {/* Workspace */} +
+
{workspaceId}
+ {organization && ( +
+ org: {organization.settings.displayName} ({organization.kind}) +
+ )} +
+
+
+ ); +}); + +function Section({ + label, + t, + css: cssFn, + children, +}: { + label: string; + t: ReturnType; + css: ReturnType[0]; + children: React.ReactNode; +}) { + return ( +
+
+ {label} +
+ {children} +
+ ); +} + +function Stat({ + label, + value, + t, + css: cssFn, +}: { + label: string; + value: number; + t: ReturnType; + css: ReturnType[0]; +}) { + return ( + + {value} + {label} + + ); +} + +export function useDevPanel() { + const [visible, setVisible] = useState(true); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.shiftKey && e.key === "D" && !e.metaKey && !e.ctrlKey && !e.altKey) { + const tag = (e.target as HTMLElement)?.tagName; + if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return; + e.preventDefault(); + setVisible((prev) => !prev); + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, []); + + return visible; +} diff --git a/foundry/packages/frontend/src/components/mock-layout.tsx b/foundry/packages/frontend/src/components/mock-layout.tsx index baab797..8bb3d5d 100644 --- a/foundry/packages/frontend/src/components/mock-layout.tsx +++ b/foundry/packages/frontend/src/components/mock-layout.tsx @@ -1,7 +1,14 @@ -import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type PointerEvent as ReactPointerEvent } from "react"; +import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react"; import { useNavigate } from "@tanstack/react-router"; import { useStyletron } from "baseui"; -import { createErrorContext } from "@sandbox-agent/foundry-shared"; +import { + createErrorContext, + type TaskWorkbenchSnapshot, + type WorkbenchSessionSummary, + type WorkbenchTaskDetail, + type WorkbenchTaskSummary, +} from "@sandbox-agent/foundry-shared"; +import { useInterest } from "@sandbox-agent/foundry-client"; import { PanelLeft, PanelRight } from "lucide-react"; import { useFoundryTokens } from "../app/theme"; @@ -16,6 +23,7 @@ import { TabStrip } from "./mock-layout/tab-strip"; import { TerminalPane } from "./mock-layout/terminal-pane"; import { TranscriptHeader } from "./mock-layout/transcript-header"; import { PROMPT_TEXTAREA_MAX_HEIGHT, PROMPT_TEXTAREA_MIN_HEIGHT, SPanel, ScrollBody, Shell } from "./mock-layout/ui"; +import { DevPanel, useDevPanel } from "./dev-panel"; import { buildDisplayMessages, diffPath, @@ -30,7 +38,8 @@ import { type ModelId, } from "./mock-layout/view-model"; import { activeMockOrganization, useMockAppSnapshot } from "../lib/mock-app"; -import { getTaskWorkbenchClient } from "../lib/workbench"; +import { backendClient } from "../lib/backend"; +import { interestManager } from "../lib/interest"; function firstAgentTabId(task: Task): string | null { return task.tabs[0]?.id ?? null; @@ -65,6 +74,81 @@ function sanitizeActiveTabId(task: Task, tabId: string | null | undefined, openD return openDiffs.length > 0 ? diffTabId(openDiffs[openDiffs.length - 1]!) : lastAgentTabId; } +function toLegacyTab( + summary: WorkbenchSessionSummary, + sessionDetail?: { draft: Task["tabs"][number]["draft"]; transcript: Task["tabs"][number]["transcript"] }, +): Task["tabs"][number] { + return { + id: summary.id, + sessionId: summary.sessionId, + sessionName: summary.sessionName, + agent: summary.agent, + model: summary.model, + status: summary.status, + thinkingSinceMs: summary.thinkingSinceMs, + unread: summary.unread, + created: summary.created, + draft: sessionDetail?.draft ?? { + text: "", + attachments: [], + updatedAtMs: null, + }, + transcript: sessionDetail?.transcript ?? [], + }; +} + +function toLegacyTask( + summary: WorkbenchTaskSummary, + detail?: WorkbenchTaskDetail, + sessionCache?: Map, +): Task { + const sessions = detail?.sessionsSummary ?? summary.sessionsSummary; + return { + id: summary.id, + repoId: summary.repoId, + title: detail?.title ?? summary.title, + status: detail?.status ?? summary.status, + repoName: detail?.repoName ?? summary.repoName, + updatedAtMs: detail?.updatedAtMs ?? summary.updatedAtMs, + branch: detail?.branch ?? summary.branch, + pullRequest: detail?.pullRequest ?? summary.pullRequest, + tabs: sessions.map((session) => toLegacyTab(session, sessionCache?.get(session.id))), + fileChanges: detail?.fileChanges ?? [], + diffs: detail?.diffs ?? {}, + fileTree: detail?.fileTree ?? [], + minutesUsed: detail?.minutesUsed ?? 0, + }; +} + +function groupProjects(repos: Array<{ id: string; label: string }>, tasks: Task[]) { + return repos + .map((repo) => ({ + id: repo.id, + label: repo.label, + updatedAtMs: tasks.filter((task) => task.repoId === repo.id).reduce((latest, task) => Math.max(latest, task.updatedAtMs), 0), + tasks: tasks.filter((task) => task.repoId === repo.id).sort((left, right) => right.updatedAtMs - left.updatedAtMs), + })) + .filter((repo) => repo.tasks.length > 0); +} + +interface WorkbenchActions { + createTask(input: { repoId: string; task: string; title?: string; branch?: string; model?: ModelId }): Promise<{ taskId: string; tabId?: string }>; + markTaskUnread(input: { taskId: string }): Promise; + renameTask(input: { taskId: string; value: string }): Promise; + renameBranch(input: { taskId: string; value: string }): Promise; + archiveTask(input: { taskId: string }): Promise; + publishPr(input: { taskId: string }): Promise; + revertFile(input: { taskId: string; path: string }): Promise; + updateDraft(input: { taskId: string; tabId: string; text: string; attachments: LineAttachment[] }): Promise; + sendMessage(input: { taskId: string; tabId: string; text: string; attachments: LineAttachment[] }): Promise; + stopAgent(input: { taskId: string; tabId: string }): Promise; + setSessionUnread(input: { taskId: string; tabId: string; unread: boolean }): Promise; + renameSession(input: { taskId: string; tabId: string; title: string }): Promise; + closeTab(input: { taskId: string; tabId: string }): Promise; + addTab(input: { taskId: string; model?: string }): Promise<{ tabId: string }>; + changeModel(input: { taskId: string; tabId: string; model: ModelId }): Promise; +} + const TranscriptPanel = memo(function TranscriptPanel({ taskWorkbenchClient, task, @@ -83,7 +167,7 @@ const TranscriptPanel = memo(function TranscriptPanel({ onToggleRightSidebar, onNavigateToUsage, }: { - taskWorkbenchClient: ReturnType; + taskWorkbenchClient: WorkbenchActions; task: Task; activeTabId: string | null; lastAgentTabId: string | null; @@ -727,7 +811,7 @@ const RightRail = memo(function RightRail({ }, [clampTerminalHeight]); const startResize = useCallback( - (event: ReactPointerEvent) => { + (event: ReactPointerEvent) => { event.preventDefault(); const startY = event.clientY; @@ -902,19 +986,87 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M const [css] = useStyletron(); const t = useFoundryTokens(); const navigate = useNavigate(); - const taskWorkbenchClient = useMemo(() => getTaskWorkbenchClient(workspaceId), [workspaceId]); - const viewModel = useSyncExternalStore( - taskWorkbenchClient.subscribe.bind(taskWorkbenchClient), - taskWorkbenchClient.getSnapshot.bind(taskWorkbenchClient), - taskWorkbenchClient.getSnapshot.bind(taskWorkbenchClient), + const taskWorkbenchClient = useMemo( + () => ({ + createTask: (input) => backendClient.createWorkbenchTask(workspaceId, input), + markTaskUnread: (input) => backendClient.markWorkbenchUnread(workspaceId, input), + renameTask: (input) => backendClient.renameWorkbenchTask(workspaceId, input), + renameBranch: (input) => backendClient.renameWorkbenchBranch(workspaceId, input), + archiveTask: async (input) => backendClient.runAction(workspaceId, input.taskId, "archive"), + publishPr: (input) => backendClient.publishWorkbenchPr(workspaceId, input), + revertFile: (input) => backendClient.revertWorkbenchFile(workspaceId, input), + updateDraft: (input) => backendClient.updateWorkbenchDraft(workspaceId, input), + sendMessage: (input) => backendClient.sendWorkbenchMessage(workspaceId, input), + stopAgent: (input) => backendClient.stopWorkbenchSession(workspaceId, input), + setSessionUnread: (input) => backendClient.setWorkbenchSessionUnread(workspaceId, input), + renameSession: (input) => backendClient.renameWorkbenchSession(workspaceId, input), + closeTab: (input) => backendClient.closeWorkbenchSession(workspaceId, input), + addTab: (input) => backendClient.createWorkbenchSession(workspaceId, input), + changeModel: (input) => backendClient.changeWorkbenchModel(workspaceId, input), + }), + [workspaceId], ); - const tasks = viewModel.tasks ?? []; - const rawProjects = viewModel.projects ?? []; + const workspaceState = useInterest(interestManager, "workspace", { workspaceId }); + const workspaceRepos = workspaceState.data?.repos ?? []; + const taskSummaries = workspaceState.data?.taskSummaries ?? []; + const selectedTaskSummary = useMemo( + () => taskSummaries.find((task) => task.id === selectedTaskId) ?? taskSummaries[0] ?? null, + [selectedTaskId, taskSummaries], + ); + const taskState = useInterest( + interestManager, + "task", + selectedTaskSummary + ? { + workspaceId, + repoId: selectedTaskSummary.repoId, + taskId: selectedTaskSummary.id, + } + : null, + ); + const sessionState = useInterest( + interestManager, + "session", + selectedTaskSummary && selectedSessionId + ? { + workspaceId, + repoId: selectedTaskSummary.repoId, + taskId: selectedTaskSummary.id, + sessionId: selectedSessionId, + } + : null, + ); + const tasks = useMemo(() => { + const sessionCache = new Map(); + if (selectedTaskSummary && taskState.data) { + for (const session of taskState.data.sessionsSummary) { + const cached = + (selectedSessionId && session.id === selectedSessionId ? sessionState.data : undefined) ?? + interestManager.getSnapshot("session", { + workspaceId, + repoId: selectedTaskSummary.repoId, + taskId: selectedTaskSummary.id, + sessionId: session.id, + }); + if (cached) { + sessionCache.set(session.id, { + draft: cached.draft, + transcript: cached.transcript, + }); + } + } + } + + return taskSummaries.map((summary) => + summary.id === selectedTaskSummary?.id ? toLegacyTask(summary, taskState.data, sessionCache) : toLegacyTask(summary), + ); + }, [selectedTaskSummary, selectedSessionId, sessionState.data, taskState.data, taskSummaries, workspaceId]); + const rawProjects = useMemo(() => groupProjects(workspaceRepos, tasks), [tasks, workspaceRepos]); const appSnapshot = useMockAppSnapshot(); const activeOrg = activeMockOrganization(appSnapshot); const navigateToUsage = useCallback(() => { if (activeOrg) { - void navigate({ to: "/organizations/$organizationId/billing" as never, params: { organizationId: activeOrg.id } }); + void navigate({ to: "/organizations/$organizationId/billing" as never, params: { organizationId: activeOrg.id } as never }); } }, [activeOrg, navigate]); const [projectOrder, setProjectOrder] = useState(null); @@ -939,6 +1091,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M const [leftSidebarOpen, setLeftSidebarOpen] = useState(true); const [rightSidebarOpen, setRightSidebarOpen] = useState(true); const [leftSidebarPeeking, setLeftSidebarPeeking] = useState(false); + const showDevPanel = useDevPanel(); const peekTimeoutRef = useRef | null>(null); const startPeek = useCallback(() => { @@ -1084,16 +1237,16 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M }, [activeTask, lastAgentTabIdByTask, selectedSessionId, syncRouteSession]); useEffect(() => { - if (selectedNewTaskRepoId && viewModel.repos.some((repo) => repo.id === selectedNewTaskRepoId)) { + if (selectedNewTaskRepoId && workspaceRepos.some((repo) => repo.id === selectedNewTaskRepoId)) { return; } const fallbackRepoId = - activeTask?.repoId && viewModel.repos.some((repo) => repo.id === activeTask.repoId) ? activeTask.repoId : (viewModel.repos[0]?.id ?? ""); + activeTask?.repoId && workspaceRepos.some((repo) => repo.id === activeTask.repoId) ? activeTask.repoId : (workspaceRepos[0]?.id ?? ""); if (fallbackRepoId !== selectedNewTaskRepoId) { setSelectedNewTaskRepoId(fallbackRepoId); } - }, [activeTask?.repoId, selectedNewTaskRepoId, viewModel.repos]); + }, [activeTask?.repoId, selectedNewTaskRepoId, workspaceRepos]); useEffect(() => { if (!activeTask) { @@ -1123,35 +1276,38 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M }, "failed_to_auto_create_workbench_session", ); - } finally { - autoCreatingSessionForTaskRef.current.delete(activeTask.id); + // Keep the guard in the set on error to prevent retry storms. + // The guard is cleared when tabs appear (line above) or the task changes. } })(); }, [activeTask, selectedSessionId, syncRouteSession, taskWorkbenchClient]); - const createTask = useCallback(() => { - void (async () => { - const repoId = selectedNewTaskRepoId; - if (!repoId) { - throw new Error("Cannot create a task without an available repo"); - } + const createTask = useCallback( + (overrideRepoId?: string) => { + void (async () => { + const repoId = overrideRepoId || selectedNewTaskRepoId; + if (!repoId) { + throw new Error("Cannot create a task without an available repo"); + } - const { taskId, tabId } = await taskWorkbenchClient.createTask({ - repoId, - task: "New task", - model: "gpt-4o", - title: "New task", - }); - await navigate({ - to: "/workspaces/$workspaceId/tasks/$taskId", - params: { - workspaceId, - taskId, - }, - search: { sessionId: tabId ?? undefined }, - }); - })(); - }, [navigate, selectedNewTaskRepoId, workspaceId]); + const { taskId, tabId } = await taskWorkbenchClient.createTask({ + repoId, + task: "New task", + model: "gpt-4o", + title: "New task", + }); + await navigate({ + to: "/workspaces/$workspaceId/tasks/$taskId", + params: { + workspaceId, + taskId, + }, + search: { sessionId: tabId ?? undefined }, + }); + })(); + }, + [navigate, selectedNewTaskRepoId, workspaceId], + ); const openDiffTab = useCallback( (path: string) => { @@ -1283,7 +1439,11 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M const onDragMouseDown = useCallback((event: ReactPointerEvent) => { if (event.button !== 0) return; // Tauri v2 IPC: invoke start_dragging on the webview window - const ipc = (window as Record).__TAURI_INTERNALS__ as { invoke: (cmd: string, args?: unknown) => Promise } | undefined; + const ipc = (window as unknown as Record).__TAURI_INTERNALS__ as + | { + invoke: (cmd: string, args?: unknown) => Promise; + } + | undefined; if (ipc?.invoke) { ipc.invoke("plugin:window|start_dragging").catch(() => {}); } @@ -1359,10 +1519,10 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M transition: sidebarTransition, }} > -
+
-

Create your first task

-

- {viewModel.repos.length > 0 - ? "Start from the sidebar to create a task on the first available repo." - : "No repos are available in this workspace yet."} -

- + {activeOrg?.github.syncStatus === "syncing" || activeOrg?.github.syncStatus === "pending" ? ( + <> +
+

Syncing with GitHub

+

+ Importing repos from @{activeOrg.github.connectedAccount || "GitHub"}... + {activeOrg.github.importedRepoCount > 0 && <> {activeOrg.github.importedRepoCount} repos imported so far.} +

+ + ) : activeOrg?.github.syncStatus === "error" ? ( + <> +

GitHub sync failed

+

There was a problem syncing repos from GitHub. Check the dev panel for details.

+ + ) : ( + <> +

Create your first task

+

+ {workspaceRepos.length > 0 + ? "Start from the sidebar to create a task on the first available repo." + : "No repos are available in this workspace yet."} +

+ + + )}
@@ -1460,6 +1654,47 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
+ {activeOrg && (activeOrg.github.installationStatus === "install_required" || activeOrg.github.installationStatus === "reconnect_required") && ( +
+ + + GitHub App {activeOrg.github.installationStatus === "install_required" ? "not installed" : "needs reconnection"} — repo sync is unavailable + +
+ )} + {showDevPanel && ( + + )} ); } @@ -1479,10 +1714,10 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M transition: sidebarTransition, }} > -
+
{ @@ -1610,6 +1845,47 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
+ {activeOrg && (activeOrg.github.installationStatus === "install_required" || activeOrg.github.installationStatus === "reconnect_required") && ( +
+ + + GitHub App {activeOrg.github.installationStatus === "install_required" ? "not installed" : "needs reconnection"} — repo sync is unavailable + +
+ )} + {showDevPanel && ( + + )} ); diff --git a/foundry/packages/frontend/src/components/mock-layout/sidebar.tsx b/foundry/packages/frontend/src/components/mock-layout/sidebar.tsx index f2f2e09..0f8f688 100644 --- a/foundry/packages/frontend/src/components/mock-layout/sidebar.tsx +++ b/foundry/packages/frontend/src/components/mock-layout/sidebar.tsx @@ -1,8 +1,9 @@ -import { memo, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; +import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { useNavigate } from "@tanstack/react-router"; import { useStyletron } from "baseui"; import { LabelSmall, LabelXSmall } from "baseui/typography"; +import { Select, type Value } from "baseui/select"; import { ChevronDown, ChevronRight, @@ -26,6 +27,17 @@ import type { FoundryTokens } from "../../styles/tokens"; const PROJECT_COLORS = ["#6366f1", "#f59e0b", "#10b981", "#ef4444", "#8b5cf6", "#ec4899", "#06b6d4", "#f97316"]; +/** Strip the org prefix (e.g. "rivet-dev/") when all repos share the same org. */ +function stripCommonOrgPrefix(label: string, repos: Array<{ label: string }>): string { + const slashIdx = label.indexOf("/"); + if (slashIdx < 0) return label; + const prefix = label.slice(0, slashIdx + 1); + if (repos.every((r) => r.label.startsWith(prefix))) { + return label.slice(slashIdx + 1); + } + return label; +} + function projectInitial(label: string): string { const parts = label.split("/"); const name = parts[parts.length - 1] ?? label; @@ -61,7 +73,7 @@ export const Sidebar = memo(function Sidebar({ selectedNewTaskRepoId: string; activeId: string; onSelect: (id: string) => void; - onCreate: () => void; + onCreate: (repoId?: string) => void; onSelectNewTaskRepo: (repoId: string) => void; onMarkUnread: (id: string) => void; onRenameTask: (id: string) => void; @@ -137,19 +149,8 @@ export const Sidebar = memo(function Sidebar({ }; }, [drag, onReorderProjects, onReorderTasks]); - const [createMenuOpen, setCreateMenuOpen] = useState(false); - const createMenuRef = useRef(null); - - useEffect(() => { - if (!createMenuOpen) return; - function handleClick(event: MouseEvent) { - if (createMenuRef.current && !createMenuRef.current.contains(event.target as Node)) { - setCreateMenuOpen(false); - } - } - document.addEventListener("mousedown", handleClick); - return () => document.removeEventListener("mousedown", handleClick); - }, [createMenuOpen]); + const [createSelectOpen, setCreateSelectOpen] = useState(false); + const selectOptions = useMemo(() => newTaskRepos.map((repo) => ({ id: repo.id, label: stripCommonOrgPrefix(repo.label, newTaskRepos) })), [newTaskRepos]); return ( @@ -232,7 +233,99 @@ export const Sidebar = memo(function Sidebar({ ) : null} -
+ {createSelectOpen ? ( +
+