diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b67da38..ee3b869 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,13 @@ on: permissions: contents: read +# Opt JavaScript actions into Node.js 24 ahead of the June 2026 default switch. +# Suppresses the Node 20 deprecation warning across every action in this workflow +# without forcing version bumps on actions that have not yet shipped a v5 release. +# See https://github.blog/changelog/2025-09-19-deprecation-of-node-20-on-github-actions-runners/ +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' + # Cancel in-progress runs for the same branch/PR when a new push arrives concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -101,6 +108,91 @@ jobs: - run: pnpm test + # ── Detect which Python packages changed ──────────────────────────────────── + python-changes: + name: Detect Python changes + runs-on: ubuntu-latest + timeout-minutes: 2 + outputs: + packages: ${{ steps.set.outputs.packages }} + steps: + - uses: actions/checkout@v4 + + - uses: dorny/paths-filter@v3 + id: filter + with: + filters: | + control-plane: + - 'packages/control-plane/**' + - '.github/workflows/ci.yml' + operator: + - 'packages/operator/**' + - '.github/workflows/ci.yml' + sdk-langgraph: + - 'packages/sdk-langgraph/**' + - '.github/workflows/ci.yml' + sdk-python: + - 'packages/sdk-python/**' + - '.github/workflows/ci.yml' + + - id: set + env: + EVENT_NAME: ${{ github.event_name }} + FILTER_CHANGES: ${{ steps.filter.outputs.changes }} + run: | + if [[ "$EVENT_NAME" == "push" ]]; then + echo 'packages=["control-plane","operator","sdk-langgraph","sdk-python"]' >> "$GITHUB_OUTPUT" + else + echo "packages=$FILTER_CHANGES" >> "$GITHUB_OUTPUT" + fi + + # ── Python Tests ──────────────────────────────────────────────────────────── + python-tests: + name: Python Tests (${{ matrix.package }}) + needs: python-changes + if: needs.python-changes.outputs.packages != '[]' && needs.python-changes.outputs.packages != '' + runs-on: ubuntu-latest + timeout-minutes: 10 + + strategy: + fail-fast: false + matrix: + package: ${{ fromJSON(needs.python-changes.outputs.packages) }} + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: pip + cache-dependency-path: | + packages/${{ matrix.package }}/requirements-dev.txt + packages/${{ matrix.package }}/pyproject.toml + + - name: Install dependencies + working-directory: packages/${{ matrix.package }} + env: + PACKAGE: ${{ matrix.package }} + run: | + python -m pip install --upgrade pip + case "$PACKAGE" in + control-plane|operator) + pip install -r requirements-dev.txt + ;; + sdk-langgraph) + # tests/test_sidecar_client.py needs httpx (opa extra) + pip install -e .[dev,opa] + ;; + sdk-python) + pip install -e .[dev] + ;; + esac + + - name: Run pytest + working-directory: packages/${{ matrix.package }} + run: pytest -v + # ── Sidecar E2E (Docker Compose) ───────────────────────────────────────────── sidecar-e2e: name: Sidecar E2E @@ -163,25 +255,23 @@ jobs: ci-success: name: CI runs-on: ubuntu-latest - needs: [lint, build, test, docker-validate, sidecar-e2e] + needs: [lint, build, test, python-changes, python-tests, docker-validate, sidecar-e2e] if: always() steps: - name: Check all jobs passed run: | - if [[ "${{ needs.lint.result }}" != "success" ]]; then - echo "Lint job failed" && exit 1 - fi - if [[ "${{ needs.build.result }}" != "success" ]]; then - echo "Build job failed" && exit 1 - fi - if [[ "${{ needs.test.result }}" != "success" ]]; then - echo "Test job failed" && exit 1 - fi - if [[ "${{ needs.docker-validate.result }}" != "success" ]]; then - echo "Docker validate job failed" && exit 1 - fi - if [[ "${{ needs.sidecar-e2e.result }}" != "success" ]]; then - echo "Sidecar E2E job failed" && exit 1 - fi + fail_if_not_ok() { + local name="$1" result="$2" + if [[ "$result" != "success" && "$result" != "skipped" ]]; then + echo "$name failed (result=$result)" && exit 1 + fi + } + fail_if_not_ok "Lint" "${{ needs.lint.result }}" + fail_if_not_ok "Build" "${{ needs.build.result }}" + fail_if_not_ok "Test" "${{ needs.test.result }}" + fail_if_not_ok "Python changes" "${{ needs.python-changes.result }}" + fail_if_not_ok "Python tests" "${{ needs.python-tests.result }}" + fail_if_not_ok "Docker validate" "${{ needs.docker-validate.result }}" + fail_if_not_ok "Sidecar E2E" "${{ needs.sidecar-e2e.result }}" echo "All CI jobs passed." diff --git a/packages/control-plane/api/heartbeat.py b/packages/control-plane/api/heartbeat.py index fd50449..57fa510 100644 --- a/packages/control-plane/api/heartbeat.py +++ b/packages/control-plane/api/heartbeat.py @@ -47,8 +47,12 @@ def _check_rate_limit(agent_id: str) -> None: now = time.monotonic() - last = _rate_window.get(agent_id, 0.0) - if now - last < RATE_LIMIT_SECONDS: + last = _rate_window.get(agent_id) + # `last is None` means we have never seen this agent, so always allow. + # Using a numeric sentinel like 0.0 is unsafe: time.monotonic() returns + # seconds since an unspecified point that can be small on freshly-booted + # hosts (e.g. CI runners), causing the first heartbeat to be rate-limited. + if last is not None and now - last < RATE_LIMIT_SECONDS: logger.warning("Rate limit hit for agent %s", agent_id) raise HTTPException( status_code=429,