From 79a91ad46fb29e2aab0424f8c92290768e59acc8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 16:01:36 +0000 Subject: [PATCH 1/2] Initial plan From ee856cfcdb6b271478a373a567450f4fdcb6b46c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 16:06:49 +0000 Subject: [PATCH 2/2] chore: merge main into branch, resolving conflicts (keep multer ^2.1.0) Co-authored-by: cedricvidal_microsoft <139398438+cedricvidal_microsoft@users.noreply.github.com> --- .env.base | 11 +- .github/workflows/build-images.yml | 114 +++++--- .github/workflows/check-versions.yml | 156 +++++++++++ .github/workflows/ci.yml | 79 +++++- README.md | 340 +++++++++-------------- apps/api/package.json | 10 +- apps/api/src/index.ts | 94 ++++++- apps/cli/src/index.ts | 87 +++--- apps/cli/src/utils/types.ts | 2 +- apps/portal/src/lib/api.ts | 5 + apps/portal/src/pages/RunDetail.tsx | 13 +- apps/portal/src/pages/RunsList.tsx | 13 +- build-acr.sh | 15 + package.json | 40 +-- pnpm-lock.yaml | 399 +++++++++++++++++++++++++++ pnpm-workspace.yaml | 1 + scripts/open-portal.test.ts | 72 +++++ scripts/open-portal.ts | 89 ++++++ scripts/worktree-env.test.ts | 325 ---------------------- scripts/worktree-env.ts | 272 ------------------ vitest.config.ts | 3 +- vitest.integration.config.ts | 30 ++ 22 files changed, 1250 insertions(+), 920 deletions(-) create mode 100644 .github/workflows/check-versions.yml create mode 100644 scripts/open-portal.test.ts create mode 100644 scripts/open-portal.ts delete mode 100644 scripts/worktree-env.test.ts delete mode 100644 scripts/worktree-env.ts create mode 100644 vitest.integration.config.ts diff --git a/.env.base b/.env.base index 32d832c04..4869466ee 100644 --- a/.env.base +++ b/.env.base @@ -1,9 +1,10 @@ # ============================================================================= -# Base ports for Docker Compose services +# Base environment for Docker Compose services # ============================================================================= -# These are the default host ports used by docker-compose.yml. -# In worktrees, scripts/docker-env.sh adds a per-worktree offset (1-99) -# to each base port, allowing multiple compositions to run simultaneously. +# These are the default values used by docker-compose.yml. +# In worktrees, worktree-env adds a per-worktree offset (1-99) to each +# numeric port and appends - to string values, allowing +# multiple compositions to run simultaneously. # # Requirements for base ports: # - Must end in 00 (2+ trailing zeros) for full 100-offset range @@ -11,6 +12,8 @@ # - No trailing zeros causes an error (no room for offsets) # ============================================================================= +COMPOSE_PROJECT_NAME=scope-mt-app + MONGODB_PORT=27000 REDIS_PORT=6300 LOWKEY_VAULT_PORT=8400 diff --git a/.github/workflows/build-images.yml b/.github/workflows/build-images.yml index d27342ccf..ec0869e8b 100644 --- a/.github/workflows/build-images.yml +++ b/.github/workflows/build-images.yml @@ -58,38 +58,42 @@ jobs: model-scanning: ${{ steps.changes.outputs.model-scanning }} steps: - uses: actions/checkout@v4 - - uses: dorny/paths-filter@v3 - id: changes with: - base: ${{ github.event.before }} - filters: | - shared: - - 'packages/shared/**' - - 'package.json' - - 'pnpm-lock.yaml' - - 'tsconfig.base.json' - model-scanning: - - 'packages/model-scanning/**' - api: - - 'apps/api/**' - judge: - - 'apps/judge/**' - portal: - - 'apps/portal/**' - coder-acp-copilot: - - 'apps/workers/coder-acp-copilot/**' - coder-acp-claude-code: - - 'apps/workers/coder-acp-claude-code/**' - token-manager: - - 'apps/token-manager/**' - model-scanner-copilot: - - 'apps/model-scanners/copilot/**' - model-scanner-anthropic: - - 'apps/model-scanners/anthropic/**' - report-generator: - - 'apps/workers/report-generator/**' - db-migrations: - - 'packages/db-migrations/**' + fetch-depth: 0 + - name: Detect changed paths + id: changes + run: | + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + BASE_SHA="${{ github.event.pull_request.base.sha }}" + else + BASE_SHA="${{ github.event.before }}" + fi + CHANGED=$(git diff --name-only "$BASE_SHA" "${{ github.sha }}" || true) + + check_paths() { + local name="$1" + shift + for pattern in "$@"; do + if echo "$CHANGED" | grep -qE "$pattern"; then + echo "${name}=true" >> "$GITHUB_OUTPUT" + return + fi + done + echo "${name}=false" >> "$GITHUB_OUTPUT" + } + + check_paths shared '^packages/shared/' '^package\.json$' '^pnpm-lock\.yaml$' '^tsconfig\.base\.json$' + check_paths model-scanning '^packages/model-scanning/' + check_paths api '^apps/api/' + check_paths judge '^apps/judge/' + check_paths portal '^apps/portal/' + check_paths coder-acp-copilot '^apps/workers/coder-acp-copilot/' + check_paths coder-acp-claude-code '^apps/workers/coder-acp-claude-code/' + check_paths token-manager '^apps/token-manager/' + check_paths model-scanner-copilot '^apps/model-scanners/copilot/' + check_paths model-scanner-anthropic '^apps/model-scanners/anthropic/' + check_paths report-generator '^apps/workers/report-generator/' + check_paths db-migrations '^packages/db-migrations/' build-on-push: if: >- @@ -168,6 +172,21 @@ jobs: echo "overlay=integration" >> "$GITHUB_OUTPUT" fi + - name: Load pinned versions + id: versions + run: | + EXTRA_ARGS="" + for f in apps/workers/${{ matrix.image.name }}/versions.env apps/${{ matrix.image.name }}/versions.env; do + if [ -f "$f" ]; then + while IFS='=' read -r key value; do + [[ "$key" =~ ^#.*$ || -z "$key" ]] && continue + EXTRA_ARGS="${EXTRA_ARGS} --build-arg ${key}=${value}" + done < "$f" + break + fi + done + echo "extra_args=${EXTRA_ARGS}" >> $GITHUB_OUTPUT + - name: Build and push to ACR run: | SHORT_SHA="${{ github.sha }}" @@ -183,6 +202,7 @@ jobs: --image "scoped/${{ matrix.image.name }}:${ENV_TAG}-${TIMESTAMP}-${SHORT_SHA}" \ --build-arg GIT_COMMIT=${{ github.sha }} \ --build-arg BUILD_TIME=${BUILD_TIME} \ + ${{ steps.versions.outputs.extra_args }} \ --file "${{ matrix.image.dockerfile }}" \ . @@ -252,6 +272,21 @@ jobs: tenant-id: ${{ vars.AZURE_TENANT_ID }} subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }} + - name: Load pinned versions + id: versions + run: | + EXTRA_ARGS="" + for f in apps/workers/${{ matrix.image.name }}/versions.env apps/${{ matrix.image.name }}/versions.env; do + if [ -f "$f" ]; then + while IFS='=' read -r key value; do + [[ "$key" =~ ^#.*$ || -z "$key" ]] && continue + EXTRA_ARGS="${EXTRA_ARGS} --build-arg ${key}=${value}" + done < "$f" + break + fi + done + echo "extra_args=${EXTRA_ARGS}" >> $GITHUB_OUTPUT + - name: Build and push to ACR run: | BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ) @@ -262,6 +297,7 @@ jobs: --image "scoped/${{ matrix.image.name }}:pr-${{ github.event.pull_request.number }}-latest" \ --build-arg GIT_COMMIT=${{ github.event.pull_request.head.sha }} \ --build-arg BUILD_TIME=${BUILD_TIME} \ + ${{ steps.versions.outputs.extra_args }} \ --file "${{ matrix.image.dockerfile }}" \ . @@ -308,6 +344,21 @@ jobs: tenant-id: ${{ vars.AZURE_TENANT_ID }} subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }} + - name: Load pinned versions + id: versions + run: | + EXTRA_ARGS="" + for f in apps/workers/${{ matrix.image }}/versions.env apps/${{ matrix.image }}/versions.env; do + if [ -f "$f" ]; then + while IFS='=' read -r key value; do + [[ "$key" =~ ^#.*$ || -z "$key" ]] && continue + EXTRA_ARGS="${EXTRA_ARGS} --build-arg ${key}=${value}" + done < "$f" + break + fi + done + echo "extra_args=${EXTRA_ARGS}" >> $GITHUB_OUTPUT + - name: Build and push to ACR if: steps.check.outputs.build == 'true' run: | @@ -324,5 +375,6 @@ jobs: --image "scoped/${{ matrix.image }}:${TAG}-${TIMESTAMP}-${SHORT_SHA}" \ --build-arg GIT_COMMIT=${{ github.sha }} \ --build-arg BUILD_TIME=${BUILD_TIME} \ + ${{ steps.versions.outputs.extra_args }} \ --file "${{ steps.dockerfile.outputs.path }}" \ . diff --git a/.github/workflows/check-versions.yml b/.github/workflows/check-versions.yml new file mode 100644 index 000000000..2d0e3ffd8 --- /dev/null +++ b/.github/workflows/check-versions.yml @@ -0,0 +1,156 @@ +name: Check VS Code Worker Versions + +on: + schedule: + # Weekdays at 8am SF time (3pm UTC during PDT, 4pm during PST) + - cron: '0 15 * * 1-5' + workflow_dispatch: + +permissions: + contents: read + issues: write + +jobs: + check-versions: + name: Check for version updates + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # pnpm/action-setup@v4 is not allowed on EMU + - name: Install pnpm + run: | + curl -fsSL https://get.pnpm.io/install.sh | sh - + echo "$HOME/.local/share/pnpm" >> $GITHUB_PATH + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + registry-url: 'https://registry.npmjs.org' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile --filter version-checker-vscode-web + + - name: Check for new versions + id: check + run: | + set +e + pnpm --silent --filter version-checker-vscode-web check > /tmp/versions.json 2>/tmp/versions.log + EXIT_CODE=$? + set -e + + echo "::group::check-versions stderr" + cat /tmp/versions.log + echo "::endgroup::" + echo "::group::check-versions stdout" + cat /tmp/versions.json + echo "::endgroup::" + echo "Exit code: $EXIT_CODE" + + echo "exit_code=$EXIT_CODE" >> $GITHUB_OUTPUT + + if [ "$EXIT_CODE" -eq 1 ]; then + echo "updates_available=true" >> $GITHUB_OUTPUT + + # Parse the JSON output β€” stdout only, no pnpm noise + node -e " + const fs = require('fs'); + const r = JSON.parse(fs.readFileSync('/tmp/versions.json', 'utf8')); + const out = fs.createWriteStream(process.env.GITHUB_OUTPUT, { flags: 'a' }); + out.write('vscode_current=' + r.vscode.current + '\n'); + out.write('vscode_latest=' + r.vscode.latest + '\n'); + out.write('copilot_current=' + r.copilotChat.current + '\n'); + out.write('copilot_latest=' + r.copilotChat.latest + '\n'); + const parts = []; + if (r.vscode.current !== r.vscode.latest) parts.push('VS Code ' + r.vscode.latest); + if (r.copilotChat.current !== r.copilotChat.latest) parts.push('Copilot Chat ' + r.copilotChat.latest); + out.end(); + " + else + echo "updates_available=false" >> $GITHUB_OUTPUT + echo "No updates available (exit code: $EXIT_CODE)" + fi + + - name: Check for existing open issue + if: steps.check.outputs.updates_available == 'true' + id: existing + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # List open issues with the dependencies label and filter by title prefix. + # Using list + jq instead of --search to avoid GitHub search index lag + # which can cause duplicate issues when runs happen close together. + ISSUE_URL=$(gh issue list --state open --label dependencies --json title,url \ + if [ -n "$ISSUE_URL" ]; then + echo "exists=true" >> $GITHUB_OUTPUT + echo "issue_url=$ISSUE_URL" >> $GITHUB_OUTPUT + echo "Existing open issue found: $ISSUE_URL" + else + echo "exists=false" >> $GITHUB_OUTPUT + fi + + - name: Create issue + id: create_issue + if: steps.check.outputs.updates_available == 'true' && steps.existing.outputs.exists == 'false' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + + | Component | Current | Latest | + |-----------|---------|--------| + | VS Code | ${{ steps.check.outputs.vscode_current }} | ${{ steps.check.outputs.vscode_latest }} | + | Copilot Chat | ${{ steps.check.outputs.copilot_current }} | ${{ steps.check.outputs.copilot_latest }} | + + ### How to update + + + \`\`\`env + VSCODE_VERSION=${{ steps.check.outputs.vscode_latest }} + COPILOT_CHAT_VERSION=${{ steps.check.outputs.copilot_latest }} + \`\`\` + + Then run integration tests and create a PR: + \`\`\`bash + pnpm test:integration + \`\`\` + + ### Links + + - [VS Code Release Notes](https://code.visualstudio.com/updates) + - [Copilot Chat on Marketplace](https://marketplace.visualstudio.com/items?itemName=GitHub.copilot-chat) + " + + ISSUE_URL=$(gh issue create \ + --title "${{ steps.check.outputs.title }}" \ + --body "$BODY" \ + --label "dependencies") + echo "issue_url=$ISSUE_URL" >> $GITHUB_OUTPUT + + - name: Write job summary + if: always() && steps.check.outputs.exit_code != '2' + run: | + if [ "${{ steps.check.outputs.updates_available }}" != "true" ]; then + cat >> $GITHUB_STEP_SUMMARY <<'EOF' + ## βœ… All versions up to date + + EOF + else + cat >> $GITHUB_STEP_SUMMARY <> $GITHUB_STEP_SUMMARY + elif [ "${{ steps.existing.outputs.exists }}" == "true" ]; then + echo "ℹ️ **Issue already exists:** ${{ steps.existing.outputs.issue_url }}" >> $GITHUB_STEP_SUMMARY + fi + fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3e819be15..a13ade299 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ permissions: jobs: test: - name: Run Tests + name: Unit Tests runs-on: ubuntu-latest strategy: @@ -61,10 +61,26 @@ jobs: - name: Post coverage summary to Pull Request if: always() && github.event_name == 'pull_request' - uses: davelosert/vitest-coverage-report-action@v2 - with: - json-summary-path: coverage/coverage-summary.json - json-final-path: coverage/coverage-final.json + run: | + node -e " + const fs = require('fs'); + const summary = JSON.parse(fs.readFileSync('coverage/coverage-summary.json', 'utf8')); + const t = summary.total; + const pct = (v) => v.pct !== undefined && v.pct !== null ? v.pct + '%' : 'N/A'; + const lines = [ + '## πŸ“Š Coverage Summary', + '', + '| Category | Coverage | Covered | Total |', + '|----------|----------|---------|-------|', + '| **Statements** | ' + pct(t.statements) + ' | ' + t.statements.covered + ' | ' + t.statements.total + ' |', + '| **Branches** | ' + pct(t.branches) + ' | ' + t.branches.covered + ' | ' + t.branches.total + ' |', + '| **Functions** | ' + pct(t.functions) + ' | ' + t.functions.covered + ' | ' + t.functions.total + ' |', + '| **Lines** | ' + pct(t.lines) + ' | ' + t.lines.covered + ' | ' + t.lines.total + ' |', + ]; + fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, lines.join('\n') + '\n'); + " + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Post test results to Pull Request if: always() && github.event_name == 'pull_request' @@ -84,6 +100,53 @@ jobs: name: ctrf-test-results-node-${{ matrix.node-version }} path: ctrf/ + integration-test: + name: Integration Tests (shard ${{ matrix.shard }}/${{ strategy.job-total }}) + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + shard: [1, 2] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # pnpm/action-setup@v4 is not allowed on EMU + - name: Install pnpm + run: | + curl -fsSL https://get.pnpm.io/install.sh | sh - + echo "$HOME/.local/share/pnpm" >> $GITHUB_PATH + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + registry-url: 'https://registry.npmjs.org' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build shared package + run: pnpm --filter shared build + + - name: Run integration tests + run: npx vitest run --config vitest.integration.config.ts --silent=false --shard=${{ matrix.shard }}/${{ strategy.job-total }} + + - name: Generate GitHub test summary from CTRF + if: always() + run: | + pnpm exec github-actions-ctrf "ctrf/*-integration-ctrf-report.json" --title "Integration Test Results (shard ${{ matrix.shard }})" || true + + - name: Upload CTRF integration test results + uses: actions/upload-artifact@v4 + if: always() + with: + name: ctrf-integration-test-results-shard-${{ matrix.shard }} + path: ctrf/ + lint: name: Lint Code runs-on: ubuntu-latest @@ -144,20 +207,22 @@ jobs: ci-summary: name: CI Summary runs-on: ubuntu-latest - needs: [test, lint, build] + needs: [test, integration-test, lint, build] if: always() steps: - name: Check overall status run: | if [ "${{ needs.test.result }}" = "success" ] && \ + [ "${{ needs.integration-test.result }}" = "success" ] && \ [ "${{ needs.lint.result }}" = "success" ] && \ [ "${{ needs.build.result }}" = "success" ]; then echo "βœ… All checks passed!" exit 0 else echo "❌ Some checks failed:" - echo " Tests: ${{ needs.test.result }}" + echo " Unit Tests: ${{ needs.test.result }}" + echo " Integration Tests: ${{ needs.integration-test.result }}" echo " Lint: ${{ needs.lint.result }}" echo " Build: ${{ needs.build.result }}" exit 1 diff --git a/README.md b/README.md index fa4ed6dd3..228780d3f 100644 --- a/README.md +++ b/README.md @@ -1,173 +1,115 @@ -# Coding Agent Workers with Real-Time Logging (MongoDB + Azure Managed Redis) +# Scope MT -This experiment demonstrates a TypeScript-based API with **three coding agent workers** and **real-time log streaming** using Azure Container Apps: +**Scope MT** is a Kubernetes-native platform for benchmarking AI coding agents. It orchestrates coding tasks across multiple agent workers, evaluates results using a criteria DAG, and provides real-time log streaming β€” all backed by MongoDB, Redis, and Azure Storage Queues. The application is deployed via FluxCD GitOps with Kustomize overlays and runs on AKS. -- **API**: Express.js REST API that routes coding requests to different agent workers and streams logs via SSE -- **coder-acp-claude-code**: Claude Code agent using Agent Client Protocol (ACP) -- **coder-acp-copilot**: GitHub Copilot agent using Agent Client Protocol (ACP) -- **Storage Queues**: Separate queue per worker for message routing -- **CosmosDB (MongoDB API)**: Shared database for request status tracking and log persistence -- **Azure Managed Redis**: Real-time log publishing via Pub/Sub -- **Azure Key Vault**: Secure storage for API keys and auth state +## Components + +| Component | Description | +|-----------|-------------| +| **API** | Express.js REST API β€” routes requests to workers, streams logs via SSE | +| **Judge** | Evaluates completed runs against a criteria DAG using Copilot SDK | +| **Portal** | React web UI for managing and inspecting runs | +| **CLI** | Command-line interface for submitting runs, managing criteria, and more | +| **coder-acp-claude-code** | Claude Code agent worker (ACP) | +| **coder-acp-copilot** | GitHub Copilot agent worker (ACP) | +| **report-generator** | Generates evaluation reports from completed runs | ## Architecture +```mermaid +flowchart LR + Client([Client / CLI]) + Portal([Portal]) + API[API] + Judge[Judge] + + subgraph Queues + Q1[claude-code] + Q2[copilot] + Q3[vscode-web] + end + + subgraph Workers + W1[coder-acp-claude-code] + W2[coder-acp-copilot] + end + + MongoDB[(MongoDB)] + Redis[(Redis Pub/Sub)] + Blob[(Blob Storage)] + + Client -->|REST| API + Portal -->|REST| API + API -->|SSE| Client + API --> Q1 & Q2 & Q3 + Q1 --> W1 + Q2 --> W2 + Q3 --> W3 + W1 & W2 & W3 -->|status + logs| MongoDB + W1 & W2 & W3 -->|real-time logs| Redis + W1 & W2 & W3 -->|workspace snapshots| Blob + Redis -->|subscribe| API + Judge -->|evaluate| MongoDB ``` - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” - β”Œβ”€β”€β–Άβ”‚ Queue claude │────▢│ coder-acp-claude-code │───┐ -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ (Claude Code ACP) β”‚ β”‚ -β”‚ Client │────▢│ API (ACA) │──── β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ - β–² β”‚ β”‚ β”œβ”€β”€β–Άβ”‚ Queue copilot │────▢│ coder-acp-copilot │──── - β”‚ β”‚ SSE ◀────┼──── β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ (Copilot ACP) β”‚ β”‚ - β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ - β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ - β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ (Playwright + VS Code) β”‚ β”‚ - β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ - β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ - └────────│ Redis Pub/Sub │◀────│ Log Events │◀─────────────────────────────── - β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ - β”‚ - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ - β”‚ CosmosDB β”‚β—€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ -``` - -## Features - -### Coding Agent Workers -| Worker | Agent | Protocol | Use Case | -|--------|-------|----------|----------| -| coder-acp-claude-code | Claude Code | ACP (stdio) | AI coding with Claude | -| coder-acp-copilot | GitHub Copilot | ACP (stdio) | AI coding with Copilot CLI | +## Quick Start -### Real-Time Log Streaming -Workers publish step-by-step progress logs that clients can stream in real-time: -- **Redis Pub/Sub** for instant log delivery (milliseconds latency) -- **Server-Sent Events (SSE)** for browser and CLI compatibility -- **CosmosDB persistence** for log replay and history +### Prerequisites -## CLI Usage +- Node.js 22+ +- [pnpm](https://pnpm.io/) +- Docker & Docker Compose β€” on macOS, [OrbStack](https://orbstack.dev/) is recommended over Docker Desktop (faster, lighter) +- [GitHub CLI](https://cli.github.com/) (`gh`) with authentication -The CLI provides an easy way to submit requests and stream logs. +### Run locally ```bash -export $(azd env get-values | xargs) - -# Run demo TUI with concurrent requests to all coders -pnpm cli demo -m "create a hello world app" -c 2 -u $API_ENDPOINT - -# Submit to Copilot and stream logs -pnpm cli submit -m "Explain this code: const x = 42;" -w coder-acp-copilot -u $API_ENDPOINT - -# Submit to Claude Code -pnpm cli submit -m "Create a hello world function in Python" -w coder-acp-claude-code -u $API_ENDPOINT - -# Submit to VS Code Web - -# Submit without streaming logs -pnpm cli submit -m "Say hello" -w coder-acp-copilot -u $API_ENDPOINT --no-stream - -# Check request status -pnpm cli status -u $API_ENDPOINT - -# Stream logs for existing request -pnpm cli logs -u $API_ENDPOINT --from-start - -# List all requests -pnpm cli list -u $API_ENDPOINT +pnpm install +GITHUB_TOKEN=$(gh auth token) pnpm docker:dev:copilot ``` -## API Endpoints +This starts all core services (MongoDB, Redis, Azurite, API, Judge, Token Manager) plus the Copilot worker, report generator, and **portal** β€” with hot reload. Edit any source file and the running service restarts automatically. -### Health Check -```bash -export $(azd env get-values | xargs) && curl $API_ENDPOINT/health -``` +### Accessing the Portal -### Get Request Status -```bash -curl $API_ENDPOINT/api/v1/requests/:id -``` +Once the services are running, open the portal in your browser: -### Stream Logs (SSE) -```bash -curl -N "$API_ENDPOINT/api/v1/requests/:id/logs?fromStart=true" +``` +http://localhost:5100 ``` -## Local Development - -### Quickstart +Or use the convenience command to open it automatically: ```bash -GITHUB_TOKEN=$(gh auth token) pnpm docker:dev:copilot +pnpm open:portal ``` -This starts all core services (MongoDB, Redis, Azurite, API, judge, token-manager) plus the Copilot worker and report generator β€” with hot reload. Edit any source file and the running service restarts automatically. +> **Worktree note:** In a [git worktree](#git-worktree-support), ports are offset for isolation so the portal may run on a different port (e.g. `5103`). Check `PORTAL_PORT` in your `.env` file for the actual port, or just run `pnpm open:portal` β€” it reads `.env` and opens the correct URL. -### Prerequisites - -- Node.js 22+ -- pnpm -- Docker & Docker Compose -- GitHub CLI (`gh`) with authentication -- Azure CLI (for Azure deployment) - -### Local Development with Docker Compose +### Other useful commands ```bash -# Start local services (MongoDB, Redis, Azurite) -pnpm docker:up - -# Copy environment template -cp .env.example .env +# Start only infrastructure services (MongoDB, Redis, Azurite) +pnpm docker:up:infra -# Set required credentials in .env: -# - ANTHROPIC_API_KEY (for coder-acp-claude-code) -# - GITHUB_TOKEN (for coder-acp-copilot) - -# Install dependencies -pnpm install - -# Build all packages -pnpm build - -# Run API locally +# Run individual services locally (after starting infra) pnpm dev:api - -# Run workers locally (separate terminals) -pnpm dev:coder-acp-claude-code +pnpm dev:portal pnpm dev:coder-acp-copilot +pnpm dev:coder-acp-claude-code ``` -### Environment Variables - -See `.env.example` for all required variables. - ### Hot Reload with Docker Compose -For active development, use the dev mode which provides **hot reload** for all services: +The `docker:dev:*` commands use [Docker Compose Watch](https://docs.docker.com/compose/how-tos/file-watch/) to sync source files into containers: ```bash -# Start core services (API, judge, token-manager) with hot reload -pnpm docker:dev - -# With specific worker profiles -pnpm docker:dev:claude-code # + Claude Code + Report Generator -pnpm docker:dev:copilot # + Copilot + Report Generator -pnpm docker:dev:portal # + Portal (Vite HMR) +pnpm docker:dev:claude-code # Core + Claude Code + Report Generator + Portal +pnpm docker:dev:copilot # Core + Copilot + Report Generator + Portal +pnpm docker:dev:portal # Core + Portal (Vite HMR) pnpm docker:dev:all # All services ``` -**How it works:** -- Uses [Docker Compose Watch](https://docs.docker.com/compose/how-tos/file-watch/) to sync source files into containers -- Backend services run `tsx watch` β€” restarts in ~1-2 seconds on file changes -- Portal runs Vite dev server β€” instant HMR in the browser -- Shared package (`packages/shared`) changes are recompiled automatically inside each container -- `package.json` or lockfile changes trigger a full image rebuild - -**Change propagation speeds:** | Change | Reload Time | |--------|-------------| | Service source (`apps/*/src`) | ~1-2s (tsx watch restart) | @@ -176,101 +118,95 @@ pnpm docker:dev:all # All services | Config files (`config/`) | ~1-2s (tsx watch restart) | | Dependencies (`package.json`, lockfile) | Full rebuild (~30-60s) | -## Deployment +### Git Worktree Support -```bash -# Login to Azure -azd auth login +Docker Compose works seamlessly in [git worktrees](https://git-scm.com/docs/git-worktree). The `scripts/worktree-env.ts` script runs automatically before every `docker:*` / `pnpm docker:*` command and assigns each worktree a unique port offset so multiple stacks can run in parallel without conflicts. -# Create environment -azd env new myenv +- **Main repo**: offset 0 β€” base ports used as-is (e.g. API on 3100, MongoDB on 27100) +- **Worktrees**: offset 1–99 β€” ports are shifted (e.g. offset 1 β†’ API on 3101, MongoDB on 27101) +- The offset is persisted in a `.port-offset` file inside each worktree and reused across restarts. -# Deploy -azd up -``` +No manual configuration is needed β€” just run `pnpm docker:dev:copilot` from any worktree. -### Configure Worker Credentials - -Worker credentials (GitHub tokens, Anthropic API keys) are managed centrally via the **Token Manager** service. Workers acquire tokens dynamically at runtime β€” no static secrets are injected into pods. +### Environment Variables -To register tokens, use the Token Manager admin UI in the portal: +See [ENV_VARIABLES.md](ENV_VARIABLES.md) for a full reference of configurable environment variables. -1. Navigate to the portal β†’ **Tokens** page -2. Click **Register Token** and paste your token (e.g., `gho_` from `gh auth token`, or `sk-ant-` Anthropic key) -3. The token's capabilities are auto-detected and it becomes available to workers immediately +## CLI -See [Token Manager Architecture](docs/architecture/token-manager.md) for details on token types, capabilities, and the acquisition flow. +The CLI is the primary interface for interacting with Scope MT. Show available commands with: -#### VS Code Web Auth State +```bash +pnpm cli --help +``` -The VS Code Web worker requires GitHub authentication via Playwright browser state stored in Key Vault: +It is organized into subcommand groups (`run`, `criteria`, …). Use `--help` at any level to discover options: ```bash -# Upload auth state (write-only, stored in Key Vault) -curl -X PUT "$(azd env get-values | grep API_ENDPOINT | cut -d= -f2 | tr -d '"')/api/v1/auth/vscode-web" \ - -H "Content-Type: application/json" \ - -d @github-storage.json +pnpm cli run --help +pnpm cli run submit --help +pnpm cli criteria --help +``` -# Check if auth state exists -curl "$(azd env get-values | grep API_ENDPOINT | cut -d= -f2 | tr -d '"')/api/v1/auth/vscode-web/status" +## Configuration -# Delete auth state -curl -X DELETE "$(azd env get-values | grep API_ENDPOINT | cut -d= -f2 | tr -d '"')/api/v1/auth/vscode-web" -``` +The `config/` directory contains YAML-based configuration for the evaluation system: -### Deploy Individual Services +| Directory | Purpose | +|-----------|---------| +| `config/criteria/` | Evaluation criteria definitions (used by the Judge DAG) | +| `config/personas/` | Judge personas (e.g. `demanding-senior`, `friendly-senior`) | +| `config/scenarios/` | Benchmark scenario definitions | +| `config/traits.yaml` | Trait dimensions (personality, experience, verbosity, type) | -Use the postprovision script to build and deploy specific services: +## Documentation -```bash -# Deploy all services -./infra/hooks/postprovision.sh +The [`docs/`](docs/README.md) directory contains architecture and research documentation: -# Deploy only a specific service -./infra/hooks/postprovision.sh api -./infra/hooks/postprovision.sh coder-acp-claude-code -./infra/hooks/postprovision.sh coder-acp-copilot -``` +| Path | Contents | +|------|----------| +| `docs/architecture/` | System design β€” app design, criteria provider, DB migrations, token manager, skills | +| `docs/research/` | Research spikes β€” delta storage, real-time data flow | + +## Deployment -## Azure Resources +Scope MT is a Kubernetes-native application deployed via [FluxCD](https://fluxcd.io/) GitOps. The `deploy/` directory contains Kustomize base manifests and environment overlays that FluxCD reconciles automatically. -- **Azure Container Apps Environment**: Hosting for API and workers -- **Azure Container Registry**: Container image storage -- **Azure Cosmos DB (MongoDB API)**: Request and log persistence -- **Azure Storage Account**: Queue-based message routing -- **Azure Managed Redis**: Real-time log pub/sub -- **Azure Key Vault**: API keys, auth state storage +Infrastructure provisioning (AKS cluster, Azure resources) is managed in the [scope-mt-infra](https://github.com/growth-ecosystems/scope-mt-infra) repository. ## Project Structure ``` -β”œβ”€β”€ azure.yaml -β”œβ”€β”€ docker-compose.yml -β”œβ”€β”€ .env.example -β”œβ”€β”€ infra/ -β”‚ β”œβ”€β”€ main.bicep -β”‚ β”œβ”€β”€ resources.bicep -β”‚ └── modules/ +scope-mt-app/ β”œβ”€β”€ apps/ -β”‚ β”œβ”€β”€ api/ -β”‚ β”‚ β”œβ”€β”€ Dockerfile -β”‚ β”‚ └── src/index.ts -β”‚ β”œβ”€β”€ cli/ -β”‚ β”œβ”€β”€ judge/ -β”‚ β”‚ └── Dockerfile -β”‚ β”œβ”€β”€ portal/ -β”‚ β”‚ └── Dockerfile +β”‚ β”œβ”€β”€ api/ # REST API + SSE +β”‚ β”œβ”€β”€ cli/ # CLI (commander + ink TUI) +β”‚ β”œβ”€β”€ judge/ # Criteria DAG evaluator +β”‚ β”œβ”€β”€ portal/ # React + Vite web UI +β”‚ β”œβ”€β”€ model-scanners/ +β”‚ β”‚ β”œβ”€β”€ anthropic/ # Anthropic model scanner +β”‚ β”‚ └── copilot/ # Copilot model scanner +β”‚ β”œβ”€β”€ token-manager/ # Token management service β”‚ └── workers/ -β”‚ β”œβ”€β”€ coder-acp-claude-code/ -β”‚ β”‚ └── Dockerfile -β”‚ β”œβ”€β”€ coder-acp-copilot/ -β”‚ β”‚ └── Dockerfile -β”‚ └── Dockerfile +β”‚ β”œβ”€β”€ coder-acp-claude-code/ # Claude Code worker (ACP) +β”‚ β”œβ”€β”€ coder-acp-copilot/ # Copilot worker (ACP) +β”‚ └── report-generator/ # Report generation worker β”œβ”€β”€ packages/ -β”‚ └── shared/src/ +β”‚ β”œβ”€β”€ shared/ # Shared library (criteria graph, MongoDB, Redis, etc.) +β”‚ β”œβ”€β”€ db-migrations/ # Database migration scripts +β”‚ └── model-scanning/ # Model scanning library +β”œβ”€β”€ config/ +β”‚ β”œβ”€β”€ criteria/ # Evaluation criteria YAML definitions +β”‚ β”œβ”€β”€ personas/ # Judge persona configurations +β”‚ β”œβ”€β”€ scenarios/ # Benchmark scenario definitions +β”‚ └── traits.yaml # Trait dimensions +β”œβ”€β”€ deploy/ +β”‚ β”œβ”€β”€ base/ # Kustomize base manifests +β”‚ β”œβ”€β”€ overlays/ # Environment overlays (integration, preview, prod) +β”‚ β”œβ”€β”€ image-automation/ # FluxCD image automation +β”‚ └── pr-envs/ # PR preview environments +β”œβ”€β”€ docs/ # Architecture & research documentation +β”œβ”€β”€ scripts/ # Utility scripts +β”œβ”€β”€ docker-compose.yml └── package.json ``` - -## License - -MIT diff --git a/apps/api/package.json b/apps/api/package.json index 75832732b..9a40462a8 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -10,28 +10,30 @@ "lint": "eslint src/" }, "dependencies": { + "@azure-rest/ai-inference": "^1.0.0-beta.6", "@azure/core-auth": "^1.9.0", "@azure/identity": "^4.5.0", "@azure/keyvault-secrets": "^4.8.0", "@azure/storage-blob": "^12.26.0", "@azure/storage-queue": "^12.24.0", - "@azure-rest/ai-inference": "^1.0.0-beta.6", "cors": "^2.8.5", + "db-migrations": "workspace:*", "dotenv": "^16.4.5", "express": "^4.21.0", "ioredis": "^5.4.1", "mongodb": "^6.12.0", "multer": "^2.1.0", - "uuid": "^10.0.0", - "yaml": "^2.6.0", "shared": "workspace:*", - "db-migrations": "workspace:*" + "tar-stream": "^3.1.7", + "uuid": "^10.0.0", + "yaml": "^2.6.0" }, "devDependencies": { "@types/cors": "^2.8.17", "@types/express": "^4.17.21", "@types/multer": "^1.4.12", "@types/node": "^22.7.0", + "@types/tar-stream": "^3.1.4", "@types/uuid": "^10.0.0", "tsx": "^4.19.0", "typescript": "^5.6.2" diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index f2b3946b1..d7a46bd5e 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -17,7 +17,10 @@ import { readFile } from "fs/promises"; import { tmpdir } from "os"; import { join, basename } from "path"; import { createHash } from "crypto"; -import { parse as yamlParse } from "yaml"; +import { parse as yamlParse, stringify as yamlStringify } from "yaml"; +import { pack as tarPack } from "tar-stream"; +import { createGzip } from "zlib"; +import { pipeline } from "stream/promises"; import { isLlmAvailable, generateCriteriaPrompt } from "./llm.js"; import { isLlmAvailable as isPromptFeatureLlmAvailable, @@ -1268,6 +1271,95 @@ app.get("/api/v1/requests/:id/snapshots/:iteration", async (req: Request, res: R } }); +// Download a full run archive (run.yaml + iteration snapshots as .tar.gz entries) +app.get("/api/v1/requests/:id/archive", async (req: Request, res: Response, next: NextFunction) => { + try { + const { id } = req.params; + + const resource = await collection.findOne({ _id: id }); + if (!resource) { + res.status(404).json({ error: "Request not found" }); + return; + } + + if (!resource.turns || resource.turns.length === 0) { + res.status(404).json({ error: "No iterations found for this run" }); + return; + } + + // Connect to blob storage + let blobServiceClient: BlobServiceClient; + if (storageConnectionString) { + blobServiceClient = BlobServiceClient.fromConnectionString(storageConnectionString); + } else { + blobServiceClient = new BlobServiceClient( + `https://${storageAccountName}.blob.core.windows.net`, + new DefaultAzureCredential() + ); + } + const containerClient = blobServiceClient.getContainerClient("snapshots"); + + // Set response headers before streaming + res.setHeader("Content-Type", "application/gzip"); + res.setHeader("Content-Disposition", `attachment; filename="${id}.tar.gz"`); + + // Create streaming tar+gzip pipeline β†’ response + const pack = tarPack(); + const gzip = createGzip(); + pack.pipe(gzip).pipe(res); + + // Entry 1: run.yaml β€” the full run document + const yamlContent = yamlStringify(resource, { lineWidth: 120 }); + const yamlBuf = Buffer.from(yamlContent, "utf-8"); + pack.entry({ name: `${id}/run.yaml`, size: yamlBuf.length }, yamlBuf); + + // Entries 2..N: iteration snapshots as-is (.tar.gz blobs) + for (const turn of resource.turns) { + if (!turn.snapshotUrl) continue; + + try { + const snapshotUrl = new URL(turn.snapshotUrl); + const containerPrefix = "/snapshots/"; + const containerIndex = snapshotUrl.pathname.indexOf(containerPrefix); + if (containerIndex === -1) continue; + + const blobName = snapshotUrl.pathname.substring(containerIndex + containerPrefix.length); + const blobClient = containerClient.getBlockBlobClient(blobName); + const downloadResponse = await blobClient.download(); + + if (!downloadResponse.readableStreamBody || !downloadResponse.contentLength) continue; + + // Add the snapshot blob as a tar entry, streaming directly from blob storage + const entry = pack.entry({ + name: `${id}/iteration-${turn.iteration}.tar.gz`, + size: downloadResponse.contentLength, + }); + await pipeline(downloadResponse.readableStreamBody, entry); + } catch (blobError) { + // Skip snapshots that fail to download (e.g. deleted blobs) + if (blobError instanceof RestError && (blobError.statusCode === 404 || blobError.code === "ContainerNotFound" || blobError.code === "BlobNotFound")) { + continue; + } + throw blobError; + } + } + + // Finalize the tar archive + pack.finalize(); + } catch (error) { + if (!res.headersSent) { + if (error instanceof RestError && (error.statusCode === 404 || error.code === "ContainerNotFound" || error.code === "BlobNotFound")) { + res.status(404).json({ error: "Snapshot not found β€” the blob may have been deleted or is no longer available" }); + return; + } + next(error); + } else { + // Headers already sent β€” destroy the response to signal an error to the client + res.destroy(); + } + } +}); + // --- Runs upload (import downloaded archives) --- // Download a HAR (HTTP Archive) file for a specific request or turn diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index ea2054697..0e5cd46d5 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -6,7 +6,7 @@ import dotenv from "dotenv"; import { Command } from "commander"; import EventSource from "eventsource"; import { execSync } from "child_process"; -import { mkdtempSync, mkdirSync, writeFileSync, createWriteStream, rmSync, readFileSync, readdirSync, existsSync, statSync } from "fs"; +import { mkdtempSync, mkdirSync, createWriteStream, rmSync, readFileSync, readdirSync, existsSync, statSync } from "fs"; import { tmpdir } from "os"; import { join, resolve, dirname, basename, extname } from "path"; import { pipeline } from "stream/promises"; @@ -515,7 +515,7 @@ run const outputFile = options.output || defaultFile; try { - // Step 1: Fetch request document + // Step 1: Fetch request document (lightweight β€” for metadata display) console.log(`${label('Fetching run')} ${value(id)}...`); const response = await fetch(`${normalizeUrl(url)}/api/v1/requests/${id}`); if (!response.ok) { @@ -535,59 +535,42 @@ run console.log(`${label('Iterations:')} ${value(String(request.turns.length))}`); console.log(); - // Step 2: Create temp staging directory - const stageDir = mkdtempSync(join(tmpdir(), `scope-mt-${id.substring(0, 8)}-`)); - const runDir = join(stageDir, id); - mkdirSync(runDir, { recursive: true }); - - try { - // Step 3: Write run document as YAML - writeFileSync(join(runDir, "run.yaml"), yamlStringify(request, { lineWidth: 120 })); - console.log(` ${successText('+')} run.yaml`); - - // Step 4: Download each iteration snapshot - for (const turn of request.turns) { - const iter = turn.iteration; - const iterDir = join(runDir, `iteration-${iter}`); - mkdirSync(iterDir, { recursive: true }); - - process.stdout.write(` ${label(`iteration-${iter}/`)} downloading...`); - - const snapshotResp = await fetch(`${normalizeUrl(url)}/api/v1/requests/${id}/snapshots/${iter}`); - if (!snapshotResp.ok || !snapshotResp.body) { - console.log(` ${errorText('FAILED')}`); - console.error(` ${errorText(`Could not download iteration ${iter}: ${snapshotResp.statusText}`)}`); - continue; + // Step 2: Download the full archive from the server + console.log(`${label('Downloading archive')}...`); + const archiveResp = await fetch(`${normalizeUrl(url)}/api/v1/requests/${id}/archive`); + if (!archiveResp.ok || !archiveResp.body) { + const error = await archiveResp.json().catch(() => ({ error: archiveResp.statusText })); + console.error(errorText("Error downloading archive:"), error); + process.exit(1); + } + + const outputPath = resolve(outputFile); + mkdirSync(dirname(outputPath), { recursive: true }); + const fileStream = createWriteStream(outputPath); + await pipeline(Readable.fromWeb(archiveResp.body as any), fileStream); + console.log(`${successText('Archive:')} ${value(outputPath)}`); + + // Step 3: Optionally extract + if (shouldExtract) { + const extractDir = options.dir || downloadDir || "."; + mkdirSync(extractDir, { recursive: true }); + execSync(`tar xzf "${outputPath}" -C "${extractDir}"`, { stdio: "pipe" }); + + // Extract nested iteration-*.tar.gz files into iteration-N/ directories + const runExtractDir = join(extractDir, id); + if (existsSync(runExtractDir)) { + const nestedArchives = readdirSync(runExtractDir).filter(f => f.startsWith("iteration-") && f.endsWith(".tar.gz")); + for (const archive of nestedArchives) { + const iterName = archive.replace(".tar.gz", ""); + const iterDir = join(runExtractDir, iterName); + mkdirSync(iterDir, { recursive: true }); + execSync(`tar xzf "${join(runExtractDir, archive)}" -C "${iterDir}"`, { stdio: "pipe" }); + rmSync(join(runExtractDir, archive), { force: true }); } - - // Save tar.gz to temp then extract into iteration dir - const archivePath = join(stageDir, `iter-${iter}.tar.gz`); - const fileStream = createWriteStream(archivePath); - await pipeline(Readable.fromWeb(snapshotResp.body as any), fileStream); - execSync(`tar xzf "${archivePath}" -C "${iterDir}"`, { stdio: "pipe" }); - - process.stdout.write(`\r ${label(`iteration-${iter}/`)} ${successText('downloaded')}\n`); - } - - // Step 5: Create the final tar.gz archive - console.log(); - const outputPath = resolve(outputFile); - mkdirSync(dirname(outputPath), { recursive: true }); - execSync(`tar czf "${outputPath}" -C "${stageDir}" "${id}"`, { stdio: "pipe" }); - console.log(`${successText('Archive:')} ${value(outputPath)}`); - - // Step 6: Optionally extract - if (shouldExtract) { - const extractDir = options.dir || downloadDir || "."; - mkdirSync(extractDir, { recursive: true }); - execSync(`tar xzf "${outputPath}" -C "${extractDir}"`, { stdio: "pipe" }); - rmSync(outputPath, { force: true }); - console.log(`${successText('Extracted to:')} ${value(resolve(extractDir, id))}`); } - } finally { - // Cleanup staging directory - rmSync(stageDir, { recursive: true, force: true }); + rmSync(outputPath, { force: true }); + console.log(`${successText('Extracted to:')} ${value(resolve(extractDir, id))}`); } } catch (error) { diff --git a/apps/cli/src/utils/types.ts b/apps/cli/src/utils/types.ts index 8dde4f14d..c150a6947 100644 --- a/apps/cli/src/utils/types.ts +++ b/apps/cli/src/utils/types.ts @@ -7,7 +7,7 @@ */ /** Supported output formats for list commands */ -export type OutputFormat = 'table' | 'tsv' | 'json' | 'yaml'; +export type OutputFormat = 'table' | 'tsv' | 'json' | 'yaml' | 'markdown'; /** All valid output format values */ export const OUTPUT_FORMAT_VALUES: OutputFormat[] = ['table', 'tsv', 'json', 'yaml']; diff --git a/apps/portal/src/lib/api.ts b/apps/portal/src/lib/api.ts index 19c198264..6d86c18ac 100644 --- a/apps/portal/src/lib/api.ts +++ b/apps/portal/src/lib/api.ts @@ -85,6 +85,11 @@ export const api = { return `${BASE}/requests/${id}/har${qs}`; }, + /** Get full run archive download URL (.tar.gz with run.yaml + iteration snapshots) */ + archiveUrl: (id: string): string => { + return `${BASE}/requests/${id}/archive`; + }, + /** SSE endpoint URL for log streaming */ logsUrl: (id: string, fromStart = true): string => { return `${BASE}/requests/${id}/logs?fromStart=${fromStart}`; diff --git a/apps/portal/src/pages/RunDetail.tsx b/apps/portal/src/pages/RunDetail.tsx index d57769460..4fbd1a407 100644 --- a/apps/portal/src/pages/RunDetail.tsx +++ b/apps/portal/src/pages/RunDetail.tsx @@ -22,7 +22,7 @@ import { useAllTurnsToolCalls } from "@/hooks/useHarExtraction"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import rehypeRaw from "rehype-raw"; -import { ArrowLeft, Copy, Check, Sparkles, CheckCircle2, XCircle, MinusCircle, FileText, Plus, Download, Loader2 } from "lucide-react"; +import { ArrowLeft, Copy, Check, Sparkles, CheckCircle2, XCircle, MinusCircle, FileText, Plus, Download, Loader2, Archive } from "lucide-react"; import { formatDate, formatId } from "@/lib/utils"; import { useState } from "react"; import { toast } from "sonner"; @@ -190,6 +190,17 @@ export function RunDetail() { )} + {run.turns && run.turns.some(t => t.snapshotUrl) && ( + + )} diff --git a/apps/portal/src/pages/RunsList.tsx b/apps/portal/src/pages/RunsList.tsx index 815beb9ea..d2b5b9cfe 100644 --- a/apps/portal/src/pages/RunsList.tsx +++ b/apps/portal/src/pages/RunsList.tsx @@ -20,7 +20,7 @@ import { Badge } from "@/components/ui/badge"; import { Skeleton } from "@/components/ui/skeleton"; import { StatusBadge } from "@/components/StatusBadge"; import { ReportStatusBadge } from "@/components/ReportStatusBadge"; -import { Trash2, Eye, Plus, RefreshCw, Repeat, FileText, X } from "lucide-react"; +import { Trash2, Eye, Plus, RefreshCw, Repeat, FileText, X, Download } from "lucide-react"; import { formatDate, formatId, truncate } from "@/lib/utils"; import { WORKER_TYPES, STATUS_LIST } from "@/types"; import type { Run, BulkResubmitOverrides, McpServerDocument, CodingAgent } from "@/types"; @@ -785,6 +785,17 @@ export function RunsList() { + {run.turns && run.turns.some(t => t.snapshotUrl) && ( + + )} deleteMutation.mutate(run._id)} diff --git a/build-acr.sh b/build-acr.sh index 647900b91..7f3ba11ce 100755 --- a/build-acr.sh +++ b/build-acr.sh @@ -49,11 +49,26 @@ build_image() { local git_commit=$(git rev-parse --short HEAD) local build_time=$(date -u +%Y-%m-%dT%H:%M:%SZ) + # Source pinned versions if available (e.g. coder-acp-copilot/versions.env) + local extra_args="" + local versions_file + for versions_file in "apps/workers/${name}/versions.env" "apps/${name}/versions.env"; do + if [ -f "$versions_file" ]; then + local key value + while IFS='=' read -r key value; do + [[ "$key" =~ ^#.*$ || -z "$key" ]] && continue + extra_args="${extra_args} --build-arg ${key}=${value}" + done < "$versions_file" + break + fi + done + az acr build \ --registry "$ACR_NAME" \ --image "$full_image" \ --build-arg GIT_COMMIT="$git_commit" \ --build-arg BUILD_TIME="$build_time" \ + ${extra_args} \ --file "$dockerfile" \ . } diff --git a/package.json b/package.json index 1260fba71..e2eb624a1 100644 --- a/package.json +++ b/package.json @@ -11,41 +11,45 @@ "dev:coder-acp-claude-code": "pnpm --filter coder-acp-claude-code dev", "dev:coder-acp-copilot": "pnpm --filter coder-acp-copilot dev", "dev:report-generator": "pnpm --filter report-generator dev", - "docker:up": "npx tsx scripts/worktree-env.ts && docker compose up --build", - "docker:up:claude-code": "npx tsx scripts/worktree-env.ts && docker compose --profile claude-code --profile report up --build", - "docker:up:copilot": "npx tsx scripts/worktree-env.ts && docker compose --profile copilot --profile report up --build", - "docker:up:vscode-web": "npx tsx scripts/worktree-env.ts && docker compose --profile vscode-web up --build", - "docker:up:report": "npx tsx scripts/worktree-env.ts && docker compose --profile report up --build", - "docker:up:all-workers": "npx tsx scripts/worktree-env.ts && docker compose --profile claude-code --profile copilot --profile vscode-web --profile report up --build", - "docker:up:infra": "npx tsx scripts/worktree-env.ts && docker compose up -d mongodb redis azurite", - "docker:up:portal": "npx tsx scripts/worktree-env.ts && docker compose --profile portal up --build", - "docker:dev": "npx tsx scripts/worktree-env.ts && COMPOSE_EXPERIMENTAL_WATCH_POLL=1 docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build --watch", - "docker:dev:claude-code": "npx tsx scripts/worktree-env.ts && COMPOSE_EXPERIMENTAL_WATCH_POLL=1 docker compose -f docker-compose.yml -f docker-compose.dev.yml --profile claude-code --profile report --profile portal up --build --watch", - "docker:dev:copilot": "npx tsx scripts/worktree-env.ts && COMPOSE_EXPERIMENTAL_WATCH_POLL=1 docker compose -f docker-compose.yml -f docker-compose.dev.yml --profile copilot --profile report --profile portal up --build --watch", - "docker:dev:portal": "npx tsx scripts/worktree-env.ts && COMPOSE_EXPERIMENTAL_WATCH_POLL=1 docker compose -f docker-compose.yml -f docker-compose.dev.yml --profile portal up --build --watch", - "docker:dev:all": "npx tsx scripts/worktree-env.ts && COMPOSE_EXPERIMENTAL_WATCH_POLL=1 docker compose -f docker-compose.yml -f docker-compose.dev.yml --profile claude-code --profile copilot --profile report --profile portal up --build --watch", - "docker:dev:build": "npx tsx scripts/worktree-env.ts && docker compose -f docker-compose.yml -f docker-compose.dev.yml --profile claude-code --profile copilot --profile report --profile portal build", - "docker:down": "npx tsx scripts/worktree-env.ts && docker compose --profile claude-code --profile copilot down", - "docker:build": "npx tsx scripts/worktree-env.ts && docker compose --profile claude-code --profile copilot --profile report build", + "docker:up": "worktree-env && docker compose up --build", + "docker:up:claude-code": "worktree-env && docker compose --profile claude-code --profile report up --build", + "docker:up:copilot": "worktree-env && docker compose --profile copilot --profile report up --build", + "docker:up:vscode-web": "worktree-env && docker compose --profile vscode-web up --build", + "docker:up:report": "worktree-env && docker compose --profile report up --build", + "docker:up:all-workers": "worktree-env && docker compose --profile claude-code --profile copilot --profile vscode-web --profile report up --build", + "docker:up:infra": "worktree-env && docker compose up -d mongodb redis azurite", + "docker:up:portal": "worktree-env && docker compose --profile portal up --build", + "docker:dev": "worktree-env && COMPOSE_EXPERIMENTAL_WATCH_POLL=1 docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build --watch", + "docker:dev:claude-code": "worktree-env && COMPOSE_EXPERIMENTAL_WATCH_POLL=1 docker compose -f docker-compose.yml -f docker-compose.dev.yml --profile claude-code --profile report --profile portal up --build --watch", + "docker:dev:copilot": "worktree-env && COMPOSE_EXPERIMENTAL_WATCH_POLL=1 docker compose -f docker-compose.yml -f docker-compose.dev.yml --profile copilot --profile report --profile portal up --build --watch", + "docker:dev:portal": "worktree-env && COMPOSE_EXPERIMENTAL_WATCH_POLL=1 docker compose -f docker-compose.yml -f docker-compose.dev.yml --profile portal up --build --watch", + "docker:dev:all": "worktree-env && COMPOSE_EXPERIMENTAL_WATCH_POLL=1 docker compose -f docker-compose.yml -f docker-compose.dev.yml --profile claude-code --profile copilot --profile report --profile portal up --build --watch", + "docker:dev:build": "worktree-env && docker compose -f docker-compose.yml -f docker-compose.dev.yml --profile claude-code --profile copilot --profile report --profile portal build", + "docker:down": "worktree-env && docker compose --profile claude-code --profile copilot down", + "docker:build": "worktree-env && docker compose --profile claude-code --profile copilot --profile report build", + "open:portal": "npx tsx scripts/open-portal.ts", "lint": "pnpm -r lint", "test:copilot:concurrent": "concurrently -n req1,req2 -c blue,green \"pnpm cli submit -m 'Request 1: Say hello' -w coder-acp-copilot -u $API_ENDPOINT\" \"pnpm cli submit -m 'Request 2: Say goodbye' -w coder-acp-copilot -u $API_ENDPOINT\"", "test:all-coders": "concurrently -n copilot,claude -c blue,magenta \"pnpm cli submit -m 'Hello from Copilot test' -w coder-acp-copilot -u $API_ENDPOINT\" \"pnpm cli submit -m 'Hello from Claude test' -w coder-acp-claude-code -u $API_ENDPOINT\"", "test": "vitest run", "test:coverage": "vitest run --coverage", + "test:integration": "vitest run --config vitest.integration.config.ts", "test:watch": "vitest", "dev:token-manager": "pnpm --filter token-manager dev", "dev:model-scanner-copilot": "pnpm --filter model-scanner-copilot dev", "dev:model-scanner-anthropic": "pnpm --filter model-scanner-anthropic dev", "migrate:up": "pnpm --filter db-migrations migrate:up", "migrate:down": "pnpm --filter db-migrations migrate:down", - "migrate:status": "pnpm --filter db-migrations migrate:status" + "migrate:status": "pnpm --filter db-migrations migrate:status", + "check:versions:vscode-web": "pnpm --filter version-checker-vscode-web check" }, "devDependencies": { "@d2t/vitest-ctrf-json-reporter": "^1.3.0", "@vitest/coverage-v8": "^4.0.18", "concurrently": "^9.2.1", "github-actions-ctrf": "^0.0.58", - "vitest": "^4.0.18" + "vitest": "^4.0.18", + "worktree-env": "^0.1.0" }, "keywords": [ "azure", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e343c1079..dae57c21c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: vitest: specifier: ^4.0.18 version: 4.0.18(@types/node@22.19.10)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) + worktree-env: + specifier: ^0.1.0 + version: 0.1.0 apps/api: dependencies: @@ -68,6 +71,9 @@ importers: shared: specifier: workspace:* version: link:../../packages/shared + tar-stream: + specifier: ^3.1.7 + version: 3.1.7 uuid: specifier: ^10.0.0 version: 10.0.0 @@ -87,6 +93,9 @@ importers: '@types/node': specifier: ^22.7.0 version: 22.19.10 + '@types/tar-stream': + specifier: ^3.1.4 + version: 3.1.4 '@types/uuid': specifier: ^10.0.0 version: 10.0.0 @@ -373,6 +382,19 @@ importers: specifier: ^5.6.2 version: 5.9.3 + apps/version-checkers/vscode-web: + dependencies: + dotenv: + specifier: ^16.5.0 + version: 16.6.1 + devDependencies: + tsx: + specifier: ^4.19.4 + version: 4.21.0 + typescript: + specifier: ^5.8.3 + version: 5.9.3 + apps/workers/coder-acp-claude-code: dependencies: '@agentclientprotocol/sdk': @@ -742,6 +764,9 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@balena/dockerignore@1.0.2': + resolution: {integrity: sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==} + '@bcoe/v8-coverage@1.0.2': resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} @@ -1128,6 +1153,20 @@ packages: resolution: {integrity: sha512-v5jUdtGJReLmE1rmff/LZf+50nzmYQYAaSRNtVNr9g0j0GkCd/noQExe31i1+PudvWU0ZJjltR0B8pUfDRdA9Q==} hasBin: true + '@grpc/grpc-js@1.14.3': + resolution: {integrity: sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==} + engines: {node: '>=12.10.0'} + + '@grpc/proto-loader@0.7.15': + resolution: {integrity: sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==} + engines: {node: '>=6'} + hasBin: true + + '@grpc/proto-loader@0.8.0': + resolution: {integrity: sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==} + engines: {node: '>=6'} + hasBin: true + '@ioredis/commands@1.5.0': resolution: {integrity: sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==} @@ -1159,6 +1198,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@js-sdsl/ordered-map@4.4.2': + resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} + '@mongodb-js/saslprep@1.4.5': resolution: {integrity: sha512-k64Lbyb7ycCSXHSLzxVdb2xsKGPMvYZfCICXvDsI8Z65CeWQzTEKS4YmGbnqw+U9RBvLPTsB6UCmwkgsDTGWIw==} @@ -1278,6 +1320,36 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.4': + resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==} + + '@protobufjs/eventemitter@1.1.0': + resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==} + + '@protobufjs/fetch@1.1.0': + resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/inquire@1.1.0': + resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.0': + resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} + '@radix-ui/number@1.1.1': resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} @@ -1914,6 +1986,12 @@ packages: '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/docker-modem@3.0.6': + resolution: {integrity: sha512-yKpAGEuKRSS8wwx0joknWxsmLha78wNMe9R2S3UNsVOkZded8UqOrV8KoeDXoXsjndxwyF3eIhyClGbO1SEhEg==} + + '@types/dockerode@4.0.1': + resolution: {integrity: sha512-cmUpB+dPN955PxBEuXE3f6lKO1hHiIGYJA46IVF3BJpNsZGvtBDcRnlrHYHtOH/B6vtDOyl2kZ2ShAu3mgc27Q==} + '@types/estree-jsx@1.0.5': resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} @@ -1947,6 +2025,9 @@ packages: '@types/multer@1.4.13': resolution: {integrity: sha512-bhhdtPw7JqCiEfC9Jimx5LqX9BDIPJEh2q/fQ4bqbBPtyEZYr3cvF22NwG0DmPZNYA0CAf2CnqDB4KIGGpJcaw==} + '@types/node@18.19.130': + resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} + '@types/node@22.19.10': resolution: {integrity: sha512-tF5VOugLS/EuDlTBijk0MqABfP8UxgYazTLo3uIn3b4yJgg26QRbVYJYsDtHrjdDUIRfP70+VfhTTc+CE1yskw==} @@ -1973,6 +2054,12 @@ packages: '@types/serve-static@1.15.10': resolution: {integrity: sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==} + '@types/ssh2@1.15.5': + resolution: {integrity: sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==} + + '@types/tar-stream@3.1.4': + resolution: {integrity: sha512-921gW0+g29mCJX0fRvqeHzBlE/XclDaAG0Ousy1LCghsOhvaKacDeRGEVzQP9IPfKn8Vysy7FEXAIxycpc/CMg==} + '@types/tar@6.1.13': resolution: {integrity: sha512-IznnlmU5f4WcGTh2ltRu/Ijpmk8wiWXfF0VA4s+HPjHZgvFggk1YaIkbo5krX/zUCzWF8N/l4+W/LNxnvAJ8nw==} @@ -2111,6 +2198,9 @@ packages: array-flatten@1.1.1: resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + asn1@0.2.6: + resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -2129,6 +2219,14 @@ packages: peerDependencies: postcss: ^8.1.0 + b4a@1.8.0: + resolution: {integrity: sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==} + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} @@ -2136,6 +2234,14 @@ packages: resolution: {integrity: sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==} engines: {node: 20 || >=22} + bare-events@2.8.2: + resolution: {integrity: sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==} + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -2143,6 +2249,9 @@ packages: resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} hasBin: true + bcrypt-pbkdf@1.0.2: + resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} + before-after-hook@2.2.3: resolution: {integrity: sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==} @@ -2186,6 +2295,10 @@ packages: buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + buildcheck@0.0.7: + resolution: {integrity: sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==} + engines: {node: '>=10.0.0'} + bundle-name@4.1.0: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} engines: {node: '>=18'} @@ -2244,6 +2357,9 @@ packages: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + chownr@3.0.0: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} @@ -2375,6 +2491,10 @@ packages: resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} engines: {node: '>= 0.10'} + cpu-features@0.0.10: + resolution: {integrity: sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==} + engines: {node: '>=10.0.0'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -2536,6 +2656,14 @@ packages: dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + docker-modem@5.0.6: + resolution: {integrity: sha512-ens7BiayssQz/uAxGzH8zGXCtiV24rRWXdjNha5V4zSOcxmAZsfGVm/PPFbwQdqEkDnhG+SyR9E3zSHUbOKXBQ==} + engines: {node: '>= 8.0'} + + dockerode@4.0.9: + resolution: {integrity: sha512-iND4mcOWhPaCNh54WmK/KoSb35AFqPAUWFMffTQcp52uQt36b5uNwEJTSXntJZBbeGad72Crbi/hvDIv6us/6Q==} + engines: {node: '>= 8.0'} + dom-helpers@5.2.1: resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} @@ -2572,6 +2700,9 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + entities@2.2.0: resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==} @@ -2644,6 +2775,9 @@ packages: eventemitter3@4.0.7: resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + events-universal@1.0.1: + resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + events@3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} @@ -2674,6 +2808,9 @@ packages: resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==} engines: {node: '>=6.0.0'} + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} @@ -2717,6 +2854,9 @@ packages: resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} engines: {node: '>= 0.6'} + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + fsevents@2.3.2: resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -3041,6 +3181,9 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + lodash.defaults@4.2.0: resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} @@ -3075,6 +3218,9 @@ packages: resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} engines: {node: '>=10'} + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} @@ -3354,6 +3500,9 @@ packages: mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + nan@2.25.0: + resolution: {integrity: sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g==} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -3531,10 +3680,17 @@ packages: property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + protobufjs@7.5.4: + resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==} + engines: {node: '>=12.0.0'} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -3828,9 +3984,16 @@ packages: sparse-bitfield@3.0.3: resolution: {integrity: sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==} + split-ca@1.0.1: + resolution: {integrity: sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==} + sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + ssh2@1.17.0: + resolution: {integrity: sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==} + engines: {node: '>=10.16.0'} + stack-utils@2.0.6: resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} engines: {node: '>=10'} @@ -3852,6 +4015,9 @@ packages: resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} engines: {node: '>=10.0.0'} + streamx@2.23.0: + resolution: {integrity: sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -3925,11 +4091,24 @@ packages: engines: {node: '>=14.0.0'} hasBin: true + tar-fs@2.1.4: + resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + + tar-stream@3.1.7: + resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==} + tar@7.5.7: resolution: {integrity: sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==} engines: {node: '>=18'} deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + text-decoder@1.2.7: + resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} + thenify-all@1.6.0: resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} engines: {node: '>=0.8'} @@ -3992,6 +4171,9 @@ packages: resolution: {integrity: sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==} engines: {node: '>=0.6.11 <=0.7.0 || >=0.7.3'} + tweetnacl@0.14.5: + resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} + type-fest@4.41.0: resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} engines: {node: '>=16'} @@ -4013,6 +4195,9 @@ packages: engines: {node: '>=0.8.0'} hasBin: true + undici-types@5.26.5: + resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -4222,6 +4407,11 @@ packages: wordwrap@1.0.0: resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + worktree-env@0.1.0: + resolution: {integrity: sha512-7jqOD0hY6eOcmsaR1AMzbUwe58Et1kUx1/3SiNnsUGCpfORpoaHSDP++cgXlA/GLrs7NMW1/bLrACUCPjbZ4IA==} + engines: {node: '>=18'} + hasBin: true + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -4671,6 +4861,8 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@balena/dockerignore@1.0.2': {} + '@bcoe/v8-coverage@1.0.2': {} '@colors/colors@1.5.0': @@ -4886,6 +5078,25 @@ snapshots: '@github/copilot-win32-arm64': 0.0.403 '@github/copilot-win32-x64': 0.0.403 + '@grpc/grpc-js@1.14.3': + dependencies: + '@grpc/proto-loader': 0.8.0 + '@js-sdsl/ordered-map': 4.4.2 + + '@grpc/proto-loader@0.7.15': + dependencies: + lodash.camelcase: 4.3.0 + long: 5.3.2 + protobufjs: 7.5.4 + yargs: 17.7.2 + + '@grpc/proto-loader@0.8.0': + dependencies: + lodash.camelcase: 4.3.0 + long: 5.3.2 + protobufjs: 7.5.4 + yargs: 17.7.2 + '@ioredis/commands@1.5.0': {} '@isaacs/cliui@8.0.2': @@ -4922,6 +5133,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@js-sdsl/ordered-map@4.4.2': {} + '@mongodb-js/saslprep@1.4.5': dependencies: sparse-bitfield: 3.0.3 @@ -5061,6 +5274,29 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.4': {} + + '@protobufjs/eventemitter@1.1.0': {} + + '@protobufjs/fetch@1.1.0': + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/inquire': 1.1.0 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/inquire@1.1.0': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.0': {} + '@radix-ui/number@1.1.1': {} '@radix-ui/primitive@1.1.3': {} @@ -5621,6 +5857,17 @@ snapshots: '@types/deep-eql@4.0.2': {} + '@types/docker-modem@3.0.6': + dependencies: + '@types/node': 22.19.10 + '@types/ssh2': 1.15.5 + + '@types/dockerode@4.0.1': + dependencies: + '@types/docker-modem': 3.0.6 + '@types/node': 22.19.10 + '@types/ssh2': 1.15.5 + '@types/estree-jsx@1.0.5': dependencies: '@types/estree': 1.0.8 @@ -5661,6 +5908,10 @@ snapshots: dependencies: '@types/express': 4.17.25 + '@types/node@18.19.130': + dependencies: + undici-types: 5.26.5 + '@types/node@22.19.10': dependencies: undici-types: 6.21.0 @@ -5692,6 +5943,14 @@ snapshots: '@types/node': 22.19.10 '@types/send': 0.17.6 + '@types/ssh2@1.15.5': + dependencies: + '@types/node': 18.19.130 + + '@types/tar-stream@3.1.4': + dependencies: + '@types/node': 22.19.10 + '@types/tar@6.1.13': dependencies: '@types/node': 22.19.10 @@ -5855,6 +6114,10 @@ snapshots: array-flatten@1.1.1: {} + asn1@0.2.6: + dependencies: + safer-buffer: 2.1.2 + assertion-error@2.0.1: {} ast-v8-to-istanbul@0.3.11: @@ -5874,14 +6137,22 @@ snapshots: postcss: 8.5.6 postcss-value-parser: 4.2.0 + b4a@1.8.0: {} + bail@2.0.2: {} balanced-match@4.0.3: {} + bare-events@2.8.2: {} + base64-js@1.5.1: {} baseline-browser-mapping@2.9.19: {} + bcrypt-pbkdf@1.0.2: + dependencies: + tweetnacl: 0.14.5 + before-after-hook@2.2.3: {} before-after-hook@3.0.2: {} @@ -5938,6 +6209,9 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + buildcheck@0.0.7: + optional: true + bundle-name@4.1.0: dependencies: run-applescript: 7.1.0 @@ -5993,6 +6267,8 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + chownr@1.1.4: {} + chownr@3.0.0: {} class-variance-authority@0.7.1: @@ -6101,6 +6377,12 @@ snapshots: object-assign: 4.1.1 vary: 1.1.2 + cpu-features@0.0.10: + dependencies: + buildcheck: 0.0.7 + nan: 2.25.0 + optional: true + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -6239,6 +6521,27 @@ snapshots: dlv@1.1.3: {} + docker-modem@5.0.6: + dependencies: + debug: 4.4.3 + readable-stream: 3.6.2 + split-ca: 1.0.1 + ssh2: 1.17.0 + transitivePeerDependencies: + - supports-color + + dockerode@4.0.9: + dependencies: + '@balena/dockerignore': 1.0.2 + '@grpc/grpc-js': 1.14.3 + '@grpc/proto-loader': 0.7.15 + docker-modem: 5.0.6 + protobufjs: 7.5.4 + tar-fs: 2.1.4 + uuid: 10.0.0 + transitivePeerDependencies: + - supports-color + dom-helpers@5.2.1: dependencies: '@babel/runtime': 7.28.6 @@ -6270,6 +6573,10 @@ snapshots: encodeurl@2.0.0: {} + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + entities@2.2.0: {} entities@6.0.1: {} @@ -6366,6 +6673,12 @@ snapshots: eventemitter3@4.0.7: {} + events-universal@1.0.1: + dependencies: + bare-events: 2.8.2 + transitivePeerDependencies: + - bare-abort-controller + events@3.3.0: {} eventsource@2.0.2: {} @@ -6418,6 +6731,8 @@ snapshots: fast-equals@5.4.0: {} + fast-fifo@1.3.2: {} + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -6465,6 +6780,8 @@ snapshots: fresh@0.5.2: {} + fs-constants@1.0.0: {} + fsevents@2.3.2: optional: true @@ -6881,6 +7198,8 @@ snapshots: lines-and-columns@1.2.4: {} + lodash.camelcase@4.3.0: {} + lodash.defaults@4.2.0: {} lodash.includes@4.3.0: {} @@ -6906,6 +7225,8 @@ snapshots: chalk: 4.1.2 is-unicode-supported: 0.1.0 + long@5.3.2: {} + longest-streak@3.1.0: {} loose-envify@1.4.0: @@ -7367,6 +7688,9 @@ snapshots: object-assign: 4.1.1 thenify-all: 1.6.0 + nan@2.25.0: + optional: true + nanoid@3.3.11: {} negotiator@0.6.3: {} @@ -7524,11 +7848,31 @@ snapshots: property-information@7.1.0: {} + protobufjs@7.5.4: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.4 + '@protobufjs/eventemitter': 1.1.0 + '@protobufjs/fetch': 1.1.0 + '@protobufjs/float': 1.0.2 + '@protobufjs/inquire': 1.1.0 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.0 + '@types/node': 22.19.10 + long: 5.3.2 + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 ipaddr.js: 1.9.1 + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + punycode@2.3.1: {} qs@6.14.1: @@ -7897,8 +8241,18 @@ snapshots: dependencies: memory-pager: 1.5.0 + split-ca@1.0.1: {} + sprintf-js@1.0.3: {} + ssh2@1.17.0: + dependencies: + asn1: 0.2.6 + bcrypt-pbkdf: 1.0.2 + optionalDependencies: + cpu-features: 0.0.10 + nan: 2.25.0 + stack-utils@2.0.6: dependencies: escape-string-regexp: 2.0.0 @@ -7913,6 +8267,15 @@ snapshots: streamsearch@1.1.0: {} + streamx@2.23.0: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.7 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -8019,6 +8382,30 @@ snapshots: - tsx - yaml + tar-fs@2.1.4: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.4 + tar-stream: 2.2.0 + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + + tar-stream@3.1.7: + dependencies: + b4a: 1.8.0 + fast-fifo: 1.3.2 + streamx: 2.23.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + tar@7.5.7: dependencies: '@isaacs/fs-minipass': 4.0.1 @@ -8027,6 +8414,12 @@ snapshots: minizlib: 3.1.0 yallist: 5.0.0 + text-decoder@1.2.7: + dependencies: + b4a: 1.8.0 + transitivePeerDependencies: + - react-native-b4a + thenify-all@1.6.0: dependencies: thenify: 3.3.1 @@ -8077,6 +8470,8 @@ snapshots: tunnel@0.0.6: {} + tweetnacl@0.14.5: {} + type-fest@4.41.0: {} type-is@1.6.18: @@ -8091,6 +8486,8 @@ snapshots: uglify-js@3.19.3: optional: true + undici-types@5.26.5: {} + undici-types@6.21.0: {} undici@5.29.0: @@ -8289,6 +8686,8 @@ snapshots: wordwrap@1.0.0: {} + worktree-env@0.1.0: {} + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 123ec36de..39dabe233 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -6,6 +6,7 @@ packages: - "apps/token-manager" - "apps/workers/*" - "apps/model-scanners/*" + - "apps/version-checkers/*" - "packages/shared" - "packages/model-scanning" - "packages/db-migrations" diff --git a/scripts/open-portal.test.ts b/scripts/open-portal.test.ts new file mode 100644 index 000000000..2afe03f34 --- /dev/null +++ b/scripts/open-portal.test.ts @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect } from "vitest"; +import { extractPort } from "./open-portal.js"; + +describe("extractPort", () => { + it("extracts PORTAL_PORT from a standard .env content", () => { + const env = ` +API_PORT=3100 +PORTAL_PORT=5100 +REDIS_PORT=6300 +`; + expect(extractPort(env, "PORTAL_PORT")).toBe(5100); + }); + + it("extracts a worktree-offset port", () => { + const env = ` +# --- BEGIN managed by worktree-env.sh (do not edit) --- +# Worktree: my-feature | Offset: 3 +COMPOSE_PROJECT_NAME=scope-mt-app-my-feature +API_PORT=3103 +PORTAL_PORT=5103 +REDIS_PORT=6303 +# --- END managed by worktree-env.sh --- +`; + expect(extractPort(env, "PORTAL_PORT")).toBe(5103); + }); + + it("returns undefined when the key is not present", () => { + const env = ` +API_PORT=3100 +REDIS_PORT=6300 +`; + expect(extractPort(env, "PORTAL_PORT")).toBeUndefined(); + }); + + it("returns undefined for non-numeric value", () => { + const env = `PORTAL_PORT=not-a-number`; + expect(extractPort(env, "PORTAL_PORT")).toBeUndefined(); + }); + + it("ignores commented-out lines", () => { + const env = ` +# PORTAL_PORT=9999 +PORTAL_PORT=5100 +`; + expect(extractPort(env, "PORTAL_PORT")).toBe(5100); + }); + + it("returns the last occurrence when duplicated", () => { + const env = ` +PORTAL_PORT=5100 +PORTAL_PORT=5103 +`; + // extractPort scans line-by-line and returns the first match + expect(extractPort(env, "PORTAL_PORT")).toBe(5100); + }); + + it("handles empty content", () => { + expect(extractPort("", "PORTAL_PORT")).toBeUndefined(); + }); + + it("handles lines without equals sign", () => { + const env = ` +PORTAL_PORT +API_PORT=3100 +`; + expect(extractPort(env, "PORTAL_PORT")).toBeUndefined(); + expect(extractPort(env, "API_PORT")).toBe(3100); + }); +}); diff --git a/scripts/open-portal.ts b/scripts/open-portal.ts new file mode 100644 index 000000000..1a7344a64 --- /dev/null +++ b/scripts/open-portal.ts @@ -0,0 +1,89 @@ +#!/usr/bin/env npx tsx +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// ============================================================================= +// open-portal.ts β€” Open the portal in the default browser +// ============================================================================= +// Reads .env to determine the portal port (which may vary in a worktree) +// and opens http://localhost: in the default browser. +// +// Usage: +// npx tsx scripts/open-portal.ts +// pnpm open:portal +// ============================================================================= + +import { readFileSync, existsSync } from "fs"; +import { execSync } from "child_process"; +import { join, dirname } from "path"; +import { fileURLToPath } from "url"; + +/** + * Extract a named port from a .env file content. + * Returns the value or undefined if not found. + */ +export function extractPort( + envContent: string, + portName: string +): number | undefined { + for (const line of envContent.split("\n")) { + const trimmed = line.trim(); + if (trimmed.startsWith("#") || !trimmed) continue; + const eqIdx = trimmed.indexOf("="); + if (eqIdx === -1) continue; + const key = trimmed.slice(0, eqIdx).trim(); + const value = trimmed.slice(eqIdx + 1).trim(); + if (key === portName) { + const num = parseInt(value, 10); + return isNaN(num) ? undefined : num; + } + } + return undefined; +} + +const DEFAULT_PORTAL_PORT = 5100; + +function main() { + const scriptDir = dirname(fileURLToPath(import.meta.url)); + const repoRoot = join(scriptDir, ".."); + const envFile = join(repoRoot, ".env"); + + let port = DEFAULT_PORTAL_PORT; + + if (existsSync(envFile)) { + const content = readFileSync(envFile, "utf-8"); + const parsed = extractPort(content, "PORTAL_PORT"); + if (parsed !== undefined) { + port = parsed; + } + } + + const url = `http://localhost:${port}`; + console.log(`Opening portal at ${url}`); + + // macOS: open, Linux: xdg-open, Windows: start + const platform = process.platform; + try { + if (platform === "darwin") { + execSync(`open "${url}"`); + } else if (platform === "linux") { + execSync(`xdg-open "${url}"`); + } else if (platform === "win32") { + execSync(`start "" "${url}"`); + } else { + console.log(`Open this URL in your browser: ${url}`); + } + } catch { + console.log(`Could not open browser automatically. Open this URL manually: ${url}`); + } +} + +// Only run main when executed directly (not when imported for testing) +const isDirectExecution = + process.argv[1] && + (process.argv[1].endsWith("open-portal.ts") || + process.argv[1].endsWith("open-portal.js")); + +if (isDirectExecution) { + main(); +} diff --git a/scripts/worktree-env.test.ts b/scripts/worktree-env.test.ts deleted file mode 100644 index 32f474718..000000000 --- a/scripts/worktree-env.test.ts +++ /dev/null @@ -1,325 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from "fs"; -import { join } from "path"; -import { tmpdir } from "os"; -import { - countTrailingZeros, - validateBasePorts, - parseEnvBase, - findLowestUnusedOffset, - computeWorktreeEnv, - updateEnvFile, -} from "./worktree-env.js"; - -// --- countTrailingZeros ------------------------------------------------------ - -describe("countTrailingZeros", () => { - it("returns 3 for 27000", () => expect(countTrailingZeros(27000)).toBe(3)); - it("returns 2 for 6300", () => expect(countTrailingZeros(6300)).toBe(2)); - it("returns 2 for 10100", () => expect(countTrailingZeros(10100)).toBe(2)); - it("returns 1 for 3140", () => expect(countTrailingZeros(3140)).toBe(1)); - it("returns 0 for 3141", () => expect(countTrailingZeros(3141)).toBe(0)); - it("returns 0 for 18897", () => expect(countTrailingZeros(18897)).toBe(0)); - it("returns 2 for 18800", () => expect(countTrailingZeros(18800)).toBe(2)); -}); - -// --- validateBasePorts ------------------------------------------------------- - -describe("validateBasePorts", () => { - it("returns no errors/warnings for ports with 2+ trailing zeros", () => { - const ports = [ - { name: "API_PORT", base: 3100 }, - { name: "MONGODB_PORT", base: 27000 }, - ]; - const { errors, warnings } = validateBasePorts(ports); - expect(errors).toHaveLength(0); - expect(warnings).toHaveLength(0); - }); - - it("returns warning for port with 1 trailing zero", () => { - const ports = [{ name: "API_PORT", base: 3140 }]; - const { errors, warnings } = validateBasePorts(ports); - expect(errors).toHaveLength(0); - expect(warnings).toHaveLength(1); - expect(warnings[0]).toContain("only 1 trailing zero"); - }); - - it("returns error for port with no trailing zeros", () => { - const ports = [{ name: "API_PORT", base: 3141 }]; - const { errors, warnings } = validateBasePorts(ports); - expect(errors).toHaveLength(1); - expect(errors[0]).toContain("no trailing zeros"); - expect(warnings).toHaveLength(0); - }); -}); - -// --- parseEnvBase ------------------------------------------------------------ - -describe("parseEnvBase", () => { - it("parses valid lines", () => { - const content = ` -# comment -MONGODB_PORT=27000 -REDIS_PORT=6300 -`; - const result = parseEnvBase(content); - expect(result).toEqual([ - { name: "MONGODB_PORT", base: 27000 }, - { name: "REDIS_PORT", base: 6300 }, - ]); - }); - - it("skips empty lines and comments", () => { - const content = ` -# a comment - -MONGODB_PORT=27000 -`; - const result = parseEnvBase(content); - expect(result).toHaveLength(1); - }); -}); - -// --- findLowestUnusedOffset -------------------------------------------------- - -describe("findLowestUnusedOffset", () => { - let sandbox: string; - - beforeEach(() => { - sandbox = mkdtempSync(join(tmpdir(), "worktree-env-test-")); - mkdirSync(join(sandbox, "wt-a"), { recursive: true }); - mkdirSync(join(sandbox, "wt-b"), { recursive: true }); - mkdirSync(join(sandbox, "wt-c"), { recursive: true }); - }); - - afterEach(() => { - rmSync(sandbox, { recursive: true, force: true }); - }); - - it("returns 1 when no offsets exist", () => { - expect(findLowestUnusedOffset(sandbox, "wt-a")).toBe(1); - }); - - it("returns 2 when offset 1 is taken", () => { - writeFileSync(join(sandbox, "wt-b", ".port-offset"), "1\n"); - expect(findLowestUnusedOffset(sandbox, "wt-a")).toBe(2); - }); - - it("fills gaps β€” returns 1 when 2 and 3 are taken", () => { - writeFileSync(join(sandbox, "wt-b", ".port-offset"), "2\n"); - writeFileSync(join(sandbox, "wt-c", ".port-offset"), "3\n"); - expect(findLowestUnusedOffset(sandbox, "wt-a")).toBe(1); - }); - - it("skips current worktree's offset file", () => { - writeFileSync(join(sandbox, "wt-a", ".port-offset"), "1\n"); - // wt-a's own file is excluded, so lowest is 1 again if no others - expect(findLowestUnusedOffset(sandbox, "wt-a")).toBe(1); - }); - - it("returns next after all consecutive taken", () => { - writeFileSync(join(sandbox, "wt-a", ".port-offset"), "1\n"); - writeFileSync(join(sandbox, "wt-b", ".port-offset"), "2\n"); - writeFileSync(join(sandbox, "wt-c", ".port-offset"), "3\n"); - // New worktree "wt-d" (not in sandbox yet, but that's fine) - expect(findLowestUnusedOffset(sandbox, "wt-d")).toBe(4); - }); -}); - -// --- computeWorktreeEnv ------------------------------------------------------ - -describe("computeWorktreeEnv", () => { - let sandbox: string; - - const basePorts = [ - { name: "MONGODB_PORT", base: 27000 }, - { name: "REDIS_PORT", base: 6300 }, - { name: "API_PORT", base: 3100 }, - ]; - - beforeEach(() => { - sandbox = mkdtempSync(join(tmpdir(), "worktree-env-test-")); - }); - - afterEach(() => { - rmSync(sandbox, { recursive: true, force: true }); - }); - - it("returns offset 0 for main repo (not inside .worktrees/)", () => { - const result = computeWorktreeEnv(sandbox, basePorts); - expect(result.offset).toBe(0); - expect(result.worktreeName).toBeNull(); - expect(result.composeProjectName).toBe("scope-mt-app"); - expect(result.ports.MONGODB_PORT).toBe(27000); - expect(result.ports.REDIS_PORT).toBe(6300); - expect(result.ports.API_PORT).toBe(3100); - }); - - it("assigns offset 1 for first worktree", () => { - const worktreesDir = join(sandbox, ".worktrees"); - const wtDir = join(worktreesDir, "my-feature"); - mkdirSync(wtDir, { recursive: true }); - - const result = computeWorktreeEnv(wtDir, basePorts); - expect(result.offset).toBe(1); - expect(result.worktreeName).toBe("my-feature"); - expect(result.composeProjectName).toBe("scope-mt-app-my-feature"); - expect(result.ports.MONGODB_PORT).toBe(27001); - expect(result.ports.REDIS_PORT).toBe(6301); - expect(result.ports.API_PORT).toBe(3101); - - // .port-offset written - expect(readFileSync(join(wtDir, ".port-offset"), "utf-8").trim()).toBe("1"); - }); - - it("reuses existing .port-offset", () => { - const worktreesDir = join(sandbox, ".worktrees"); - const wtDir = join(worktreesDir, "my-feature"); - mkdirSync(wtDir, { recursive: true }); - writeFileSync(join(wtDir, ".port-offset"), "5\n"); - - const result = computeWorktreeEnv(wtDir, basePorts); - expect(result.offset).toBe(5); - expect(result.ports.API_PORT).toBe(3105); - }); - - it("assigns next available offset when others are taken", () => { - const worktreesDir = join(sandbox, ".worktrees"); - const wtA = join(worktreesDir, "wt-a"); - const wtB = join(worktreesDir, "wt-b"); - mkdirSync(wtA, { recursive: true }); - mkdirSync(wtB, { recursive: true }); - writeFileSync(join(wtA, ".port-offset"), "1\n"); - - const result = computeWorktreeEnv(wtB, basePorts); - expect(result.offset).toBe(2); - }); - - it("recycles gap offsets", () => { - const worktreesDir = join(sandbox, ".worktrees"); - const wtA = join(worktreesDir, "wt-a"); - const wtB = join(worktreesDir, "wt-b"); - const wtC = join(worktreesDir, "wt-c"); - mkdirSync(wtA, { recursive: true }); - mkdirSync(wtB, { recursive: true }); - mkdirSync(wtC, { recursive: true }); - // offsets 2 and 3 are taken (1 is free) - writeFileSync(join(wtA, ".port-offset"), "2\n"); - writeFileSync(join(wtB, ".port-offset"), "3\n"); - - const result = computeWorktreeEnv(wtC, basePorts); - expect(result.offset).toBe(1); - }); - - it("throws when offset exceeds available range", () => { - const worktreesDir = join(sandbox, ".worktrees"); - // Use a port with only 1 trailing zero (max offset 9) - const tightPorts = [{ name: "TIGHT_PORT", base: 3140 }]; - - // Create 9 worktrees taking offsets 1-9 - for (let i = 1; i <= 9; i++) { - const dir = join(worktreesDir, `wt-${i}`); - mkdirSync(dir, { recursive: true }); - writeFileSync(join(dir, ".port-offset"), `${i}\n`); - } - const wtNew = join(worktreesDir, "wt-new"); - mkdirSync(wtNew, { recursive: true }); - - expect(() => computeWorktreeEnv(wtNew, tightPorts)).toThrow( - /exceeds available range/ - ); - }); -}); - -// --- updateEnvFile ----------------------------------------------------------- - -describe("updateEnvFile", () => { - let sandbox: string; - - const result = { - worktreeName: "my-feature", - offset: 1, - ports: { API_PORT: 3101, MONGODB_PORT: 27001 } as Record, - composeProjectName: "scope-mt-app-my-feature", - }; - - beforeEach(() => { - sandbox = mkdtempSync(join(tmpdir(), "worktree-env-test-")); - }); - - afterEach(() => { - rmSync(sandbox, { recursive: true, force: true }); - }); - - it("creates .env when it doesn't exist", () => { - const envPath = join(sandbox, ".env"); - updateEnvFile(envPath, result); - - const content = readFileSync(envPath, "utf-8"); - expect(content).toContain("BEGIN managed by worktree-env.sh"); - expect(content).toContain("END managed by worktree-env.sh"); - expect(content).toContain("COMPOSE_PROJECT_NAME=scope-mt-app-my-feature"); - expect(content).toContain("API_PORT=3101"); - expect(content).toContain("MONGODB_PORT=27001"); - }); - - it("appends managed block to existing .env without markers", () => { - const envPath = join(sandbox, ".env"); - writeFileSync(envPath, "MY_VAR=hello\n"); - updateEnvFile(envPath, result); - - const content = readFileSync(envPath, "utf-8"); - expect(content).toContain("MY_VAR=hello"); - expect(content).toContain("API_PORT=3101"); - }); - - it("replaces managed block preserving content above", () => { - const envPath = join(sandbox, ".env"); - writeFileSync( - envPath, - `MY_VAR=hello -# --- BEGIN managed by worktree-env.sh (do not edit) --- -OLD_CONTENT=stale -# --- END managed by worktree-env.sh --- -` - ); - updateEnvFile(envPath, result); - - const content = readFileSync(envPath, "utf-8"); - expect(content).toContain("MY_VAR=hello"); - expect(content).not.toContain("OLD_CONTENT=stale"); - expect(content).toContain("API_PORT=3101"); - }); - - it("preserves content below the END marker", () => { - const envPath = join(sandbox, ".env"); - writeFileSync( - envPath, - `ABOVE=1 -# --- BEGIN managed by worktree-env.sh (do not edit) --- -OLD=stale -# --- END managed by worktree-env.sh --- -BELOW=2 -` - ); - updateEnvFile(envPath, result); - - const content = readFileSync(envPath, "utf-8"); - expect(content).toContain("ABOVE=1"); - expect(content).toContain("BELOW=2"); - expect(content).not.toContain("OLD=stale"); - expect(content).toContain("API_PORT=3101"); - }); - - it("is idempotent β€” running twice produces same result", () => { - const envPath = join(sandbox, ".env"); - updateEnvFile(envPath, result); - const first = readFileSync(envPath, "utf-8"); - updateEnvFile(envPath, result); - const second = readFileSync(envPath, "utf-8"); - expect(first).toBe(second); - }); -}); diff --git a/scripts/worktree-env.ts b/scripts/worktree-env.ts deleted file mode 100644 index 920bcd018..000000000 --- a/scripts/worktree-env.ts +++ /dev/null @@ -1,272 +0,0 @@ -#!/usr/bin/env npx tsx -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -// ============================================================================= -// worktree-env.ts β€” Per-worktree Docker Compose port offset -// ============================================================================= -// Run this script before docker compose to compute offset ports for worktree -// isolation. Writes computed values into a managed section of .env (auto-loaded -// by Docker Compose). -// -// Usage: -// npx tsx scripts/worktree-env.ts -// -// In main repo: offset = 0, base ports used as-is. -// In worktree: offset = round-robin integer (1-99), persisted in .port-offset. -// ============================================================================= - -import { readFileSync, writeFileSync, existsSync, readdirSync } from "fs"; -import { execSync } from "child_process"; -import { join, basename, dirname } from "path"; - -const BEGIN_MARKER = "# --- BEGIN managed by worktree-env.sh (do not edit) ---"; -const END_MARKER = "# --- END managed by worktree-env.sh ---"; - -// --- Types ------------------------------------------------------------------- - -interface PortEntry { - name: string; - base: number; -} - -interface WorktreeEnvResult { - worktreeName: string | null; - offset: number; - ports: Record; - composeProjectName: string; -} - -// --- Core logic (exported for testing) --------------------------------------- - -/** - * Count trailing zeros of a number. - * e.g. 27000 β†’ 3, 6300 β†’ 2, 3140 β†’ 1, 3141 β†’ 0 - */ -export function countTrailingZeros(n: number): number { - if (n === 0) return 1; - const s = String(n); - let zeros = 0; - for (let i = s.length - 1; i >= 0; i--) { - if (s[i] === "0") zeros++; - else break; - } - return zeros; -} - -/** - * Validate base ports. Returns { errors, warnings }. - */ -export function validateBasePorts(ports: PortEntry[]): { - errors: string[]; - warnings: string[]; -} { - const errors: string[] = []; - const warnings: string[] = []; - for (const { name, base } of ports) { - const zeros = countTrailingZeros(base); - if (zeros < 1) { - errors.push( - `Base port ${name}=${base} has no trailing zeros β€” no room for offsets` - ); - } else if (zeros < 2) { - warnings.push( - `Base port ${name}=${base} has only 1 trailing zero β€” only 10 offsets available` - ); - } - } - return { errors, warnings }; -} - -/** - * Parse .env.base file content into port entries. - * Expects lines like `MONGODB_PORT=27000`. - */ -export function parseEnvBase(content: string): PortEntry[] { - const ports: PortEntry[] = []; - for (const line of content.split("\n")) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith("#")) continue; - const eqIdx = trimmed.indexOf("="); - if (eqIdx === -1) continue; - const name = trimmed.slice(0, eqIdx).trim(); - const value = trimmed.slice(eqIdx + 1).trim(); - if (!name || !value) continue; - const num = parseInt(value, 10); - if (isNaN(num)) continue; - ports.push({ name, base: num }); - } - return ports; -} - -/** - * Find the lowest unused positive integer offset by scanning - * sibling worktree .port-offset files. - */ -export function findLowestUnusedOffset( - worktreesDir: string, - currentWorktree: string -): number { - const taken = new Set(); - if (existsSync(worktreesDir)) { - for (const entry of readdirSync(worktreesDir, { withFileTypes: true })) { - if (!entry.isDirectory()) continue; - if (entry.name === currentWorktree) continue; - const offsetFile = join(worktreesDir, entry.name, ".port-offset"); - if (existsSync(offsetFile)) { - const val = parseInt(readFileSync(offsetFile, "utf-8").trim(), 10); - if (!isNaN(val)) taken.add(val); - } - } - } - let offset = 1; - while (taken.has(offset)) offset++; - return offset; -} - -/** - * Compute the worktree env: detect worktree, assign offset, compute ports. - */ -export function computeWorktreeEnv( - repoRoot: string, - basePorts: PortEntry[] -): WorktreeEnvResult { - let worktreeName: string | null = null; - let offset = 0; - - // Detect worktree by checking if repoRoot is inside a .worktrees/ directory - const parentDir = dirname(repoRoot); - const grandparentBase = basename(parentDir); - - if (grandparentBase === ".worktrees") { - worktreeName = basename(repoRoot); - const portOffsetFile = join(repoRoot, ".port-offset"); - - if (existsSync(portOffsetFile)) { - offset = parseInt(readFileSync(portOffsetFile, "utf-8").trim(), 10); - if (isNaN(offset)) offset = 1; - } else { - offset = findLowestUnusedOffset(parentDir, worktreeName); - - // Validate offset doesn't exceed range for any port - for (const { name, base } of basePorts) { - const zeros = countTrailingZeros(base); - const maxOffset = Math.pow(10, zeros) - 1; - if (offset > maxOffset) { - throw new Error( - `Offset ${offset} exceeds available range for ${name} (base=${base}, max_offset=${maxOffset})` - ); - } - } - - writeFileSync(portOffsetFile, String(offset) + "\n"); - } - } - - // Compute final ports - const ports: Record = {}; - for (const { name, base } of basePorts) { - ports[name] = base + offset; - } - - const composeProjectName = worktreeName - ? `scope-mt-app-${worktreeName}` - : "scope-mt-app"; - - return { worktreeName, offset, ports, composeProjectName }; -} - -/** - * Update the .env file with the managed section. - * Preserves any content outside the BEGIN/END markers. - */ -export function updateEnvFile( - envFilePath: string, - result: WorktreeEnvResult -): void { - // Build managed block - const sortedKeys = Object.keys(result.ports).sort(); - const managedLines = [ - BEGIN_MARKER, - `# Worktree: ${result.worktreeName ?? "main"} | Offset: ${result.offset}`, - `# Auto-generated β€” changes will be overwritten on next run.`, - `COMPOSE_PROJECT_NAME=${result.composeProjectName}`, - ...sortedKeys.map((k) => `${k}=${result.ports[k]}`), - END_MARKER, - ]; - const managedBlock = managedLines.join("\n"); - - if (existsSync(envFilePath)) { - const existing = readFileSync(envFilePath, "utf-8"); - const beginIdx = existing.indexOf(BEGIN_MARKER); - const endIdx = existing.indexOf(END_MARKER); - - if (beginIdx !== -1 && endIdx !== -1) { - // Replace existing managed block - const before = existing.slice(0, beginIdx); - const after = existing.slice(endIdx + END_MARKER.length); - writeFileSync(envFilePath, before + managedBlock + after); - } else { - // Append managed block - const trailing = existing.endsWith("\n") ? "" : "\n"; - writeFileSync(envFilePath, existing + trailing + "\n" + managedBlock + "\n"); - } - } else { - writeFileSync(envFilePath, managedBlock + "\n"); - } -} - -// --- CLI entry point --------------------------------------------------------- - -function main(): void { - const scriptDir = dirname(new URL(import.meta.url).pathname); - const repoRoot = join(scriptDir, ".."); - const envBasePath = join(repoRoot, ".env.base"); - const envFilePath = join(repoRoot, ".env"); - - // Read and parse .env.base - if (!existsSync(envBasePath)) { - console.error(`ERROR: ${envBasePath} not found`); - process.exit(1); - } - const basePorts = parseEnvBase(readFileSync(envBasePath, "utf-8")); - - // Validate base ports - const { errors, warnings } = validateBasePorts(basePorts); - for (const w of warnings) console.warn(`WARNING: ${w}`); - if (errors.length > 0) { - for (const e of errors) console.error(`ERROR: ${e}`); - process.exit(1); - } - - // Detect worktree via git - let gitToplevel: string; - try { - gitToplevel = execSync("git rev-parse --show-toplevel", { - encoding: "utf-8", - cwd: repoRoot, - }).trim(); - } catch { - gitToplevel = repoRoot; - } - - // Compute env - const result = computeWorktreeEnv(gitToplevel, basePorts); - - // Write .env - updateEnvFile(envFilePath, result); - - // Summary - const label = result.worktreeName ?? "main"; - console.log( - `[worktree-env] ${label} | Offset: ${result.offset} | API: ${result.ports.API_PORT ?? "?"} | MongoDB: ${result.ports.MONGODB_PORT ?? "?"} | Redis: ${result.ports.REDIS_PORT ?? "?"} | Project: ${result.composeProjectName}` - ); -} - -// Only run when executed directly (not imported by tests) -const isDirectRun = - process.argv[1]?.endsWith("worktree-env.ts") || - process.argv[1]?.endsWith("worktree-env.js"); -if (isDirectRun) { - main(); -} diff --git a/vitest.config.ts b/vitest.config.ts index a89795dec..4e03118e1 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,7 +5,8 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { - include: ["packages/*/src/**/*.test.ts", "apps/*/src/**/*.test.ts", "apps/workers/*/src/**/*.test.ts", "apps/model-scanners/*/src/**/*.test.ts", "scripts/**/*.test.ts"], + include: ["packages/*/src/**/*.test.ts", "apps/*/src/**/*.test.ts", "apps/workers/*/src/**/*.test.ts", "apps/workers/*/scripts/**/*.test.ts", "apps/model-scanners/*/src/**/*.test.ts", "apps/version-checkers/*/src/**/*.test.ts", "scripts/**/*.test.ts"], + exclude: ["**/node_modules/**", "**/.git/**", "**/*.integration.test.ts"], environment: "node", globals: false, reporters: [ diff --git a/vitest.integration.config.ts b/vitest.integration.config.ts new file mode 100644 index 000000000..02923cec5 --- /dev/null +++ b/vitest.integration.config.ts @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: [ + "packages/*/src/**/*.integration.test.ts", + "apps/*/src/**/*.integration.test.ts", + "apps/workers/*/src/**/*.integration.test.ts", + "apps/model-scanners/*/src/**/*.integration.test.ts", + ], + environment: "node", + globals: false, + testTimeout: 300_000, // 5 min β€” integration tests involve Docker builds + hookTimeout: 600_000, // 10 min β€” beforeAll builds Docker images + reporters: [ + "github-actions", + "default", + [ + "@d2t/vitest-ctrf-json-reporter", + { + outputFile: "scope-mt-integration-ctrf-report.json", + outputDir: "./ctrf", + }, + ], + ], + }, +});