Compare commits

..

1 Commits

Author SHA1 Message Date
Chuck Butkus
269e27e734 Use absolute paths for git hooks 2026-03-17 13:31:54 -04:00
1350 changed files with 143404 additions and 67790 deletions

View File

@@ -1,202 +0,0 @@
---
name: cross-repo-testing
description: This skill should be used when the user asks to "test a cross-repo feature", "deploy a feature branch to staging", "test SDK against OH Cloud", "e2e test a cloud workspace feature", "test provider tokens", "test secrets inheritance", or when changes span the SDK and OpenHands server repos and need end-to-end validation against a staging deployment.
triggers:
- cross-repo
- staging deployment
- feature branch deploy
- test against cloud
- e2e cloud
---
# Cross-Repo Testing: SDK ↔ OpenHands Cloud
How to end-to-end test features that span `OpenHands/software-agent-sdk` and `OpenHands/OpenHands` (the Cloud backend).
## Repository Map
| Repo | Role | What lives here |
|------|------|-----------------|
| [`software-agent-sdk`](https://github.com/OpenHands/software-agent-sdk) | Agent core | `openhands-sdk`, `openhands-workspace`, `openhands-tools` packages. `OpenHandsCloudWorkspace` lives here. |
| [`OpenHands`](https://github.com/OpenHands/OpenHands) | Cloud backend | FastAPI server (`openhands/app_server/`), sandbox management, auth, enterprise integrations. Deployed as OH Cloud. |
| [`deploy`](https://github.com/OpenHands/deploy) | Infrastructure | Helm charts + GitHub Actions that build the enterprise Docker image and deploy to staging/production. |
**Data flow:** SDK client → OH Cloud API (`/api/v1/...`) → sandbox agent-server (inside runtime container)
## When You Need This
There are **two flows** depending on which direction the dependency goes:
| Flow | When | Example |
|------|------|---------|
| **A — SDK client → new Cloud API** | The SDK calls an API that doesn't exist yet on production | `workspace.get_llm()` calling `GET /api/v1/users/me?expose_secrets=true` |
| **B — OH server → new SDK code** | The Cloud server needs unreleased SDK packages or a new agent-server image | Server consumes a new tool, agent behavior, or workspace method from the SDK |
Flow A only requires deploying the server PR. Flow B requires pinning the SDK to an unreleased commit in the server PR **and** using the SDK PR's agent-server image. Both flows may apply simultaneously.
---
## Flow A: SDK Client Tests Against New Cloud API
Use this when the SDK calls an endpoint that only exists on the server PR branch.
### A1. Write and test the server-side changes
In the `OpenHands` repo, implement the new API endpoint(s). Run unit tests:
```bash
cd OpenHands
poetry run pytest tests/unit/app_server/test_<relevant>.py -v
```
Push a PR. Wait for the **"Push Enterprise Image" (Docker) CI job** to succeed — this builds `ghcr.io/openhands/enterprise-server:sha-<COMMIT>`.
### A2. Write the SDK-side changes
In `software-agent-sdk`, implement the client code (e.g., new methods on `OpenHandsCloudWorkspace`). Run SDK unit tests:
```bash
cd software-agent-sdk
pip install -e openhands-sdk -e openhands-workspace
pytest tests/ -v
```
Push a PR. SDK CI is independent — it doesn't need the server changes to pass unit tests.
### A3. Deploy the server PR to staging
See [Deploying to a Staging Feature Environment](#deploying-to-a-staging-feature-environment) below.
### A4. Run the SDK e2e test against staging
See [Running E2E Tests Against Staging](#running-e2e-tests-against-staging) below.
---
## Flow B: OH Server Needs Unreleased SDK Code
Use this when the Cloud server depends on SDK changes that haven't been released to PyPI yet. The server's runtime containers run the `agent-server` image built from the SDK repo, so the server PR must be configured to use the SDK PR's image and packages.
### B1. Get the SDK PR merged (or identify the commit)
The SDK PR must have CI pass so its agent-server Docker image is built. The image is tagged with the **merge-commit SHA** from GitHub Actions — NOT the head-commit SHA shown in the PR.
Find the correct image tag:
- Check the SDK PR description for an `AGENT_SERVER_IMAGES` section
- Or check the "Consolidate Build Information" CI job for `"short_sha": "<tag>"`
### B2. Pin SDK packages to the commit in the OpenHands PR
In the `OpenHands` repo PR, pin all 3 SDK packages (`openhands-sdk`, `openhands-agent-server`, `openhands-tools`) to the unreleased commit and update the agent-server image tag. This involves editing 3 files and regenerating 3 lock files.
Follow the **`update-sdk` skill** → "Development: Pin SDK to an Unreleased Commit" section for the full procedure and file-by-file instructions.
### B3. Wait for the OpenHands enterprise image to build
Push the pinned changes. The OpenHands CI will build a new enterprise Docker image (`ghcr.io/openhands/enterprise-server:sha-<OH_COMMIT>`) that bundles the unreleased SDK. Wait for the "Push Enterprise Image" job to succeed.
### B4. Deploy and test
Follow [Deploying to a Staging Feature Environment](#deploying-to-a-staging-feature-environment) using the new OpenHands commit SHA.
### B5. Before merging: remove the pin
**CI guard:** `check-package-versions.yml` blocks merge to `main` if `[tool.poetry.dependencies]` contains `rev` fields. Before the OpenHands PR can merge, the SDK PR must be merged and released to PyPI, then the pin must be replaced with the released version number.
---
## Deploying to a Staging Feature Environment
The `deploy` repo creates preview environments from OpenHands PRs.
**Option A — GitHub Actions UI (preferred):**
Go to `OpenHands/deploy` → Actions → "Create OpenHands preview PR" → enter the OpenHands PR number. This creates a branch `ohpr-<PR>-<random>` and opens a deploy PR.
**Option B — Update an existing feature branch:**
```bash
cd deploy
git checkout ohpr-<PR>-<random>
# In .github/workflows/deploy.yaml, update BOTH:
# OPENHANDS_SHA: "<full-40-char-commit>"
# OPENHANDS_RUNTIME_IMAGE_TAG: "<same-commit>-nikolaik"
git commit -am "Update OPENHANDS_SHA to <commit>" && git push
```
**Before updating the SHA**, verify the enterprise Docker image exists:
```bash
gh api repos/OpenHands/OpenHands/actions/runs \
--jq '.workflow_runs[] | select(.head_sha=="<COMMIT>") | "\(.name): \(.conclusion)"' \
| grep Docker
# Must show: "Docker: success"
```
The deploy CI auto-triggers and creates the environment at:
```
https://ohpr-<PR>-<random>.staging.all-hands.dev
```
**Wait for it to be live:**
```bash
curl -s -o /dev/null -w "%{http_code}" https://ohpr-<PR>-<random>.staging.all-hands.dev/api/v1/health
# 401 = server is up (auth required). DNS may take 1-2 min on first deploy.
```
## Running E2E Tests Against Staging
**Critical: Feature deployments have their own Keycloak instance.** API keys from `app.all-hands.dev` or `$OPENHANDS_API_KEY` will NOT work. You need a test API key issued by the specific feature deployment's Keycloak.
**You (the agent) cannot obtain this key yourself** — the feature environment requires interactive browser login with credentials you do not have. You must **ask the user** to:
1. Log in to the feature deployment at `https://ohpr-<PR>-<random>.staging.all-hands.dev` in their browser
2. Generate a test API key from the UI
3. Provide the key to you so you can proceed with e2e testing
Do **not** attempt to log in via the browser or guess credentials. Wait for the user to supply the key before running any e2e tests.
```python
from openhands.workspace import OpenHandsCloudWorkspace
STAGING = "https://ohpr-<PR>-<random>.staging.all-hands.dev"
with OpenHandsCloudWorkspace(
cloud_api_url=STAGING,
cloud_api_key="<test-api-key-for-this-deployment>",
) as workspace:
# Test the new feature
llm = workspace.get_llm()
secrets = workspace.get_secrets()
print(f"LLM: {llm.model}, secrets: {list(secrets.keys())}")
```
Or run an example script:
```bash
OPENHANDS_CLOUD_API_KEY="<key>" \
OPENHANDS_CLOUD_API_URL="https://ohpr-<PR>-<random>.staging.all-hands.dev" \
python examples/02_remote_agent_server/10_cloud_workspace_saas_credentials.py
```
### Recording results
Both repos support a `.pr/` directory for temporary PR artifacts (design docs, test logs, scripts). These files are automatically removed when the PR is approved — see `.github/workflows/pr-artifacts.yml` and the "PR-Specific Artifacts" section in each repo's `AGENTS.md`.
Push test output to the `.pr/logs/` directory of whichever repo you're working in:
```bash
mkdir -p .pr/logs
python test_script.py 2>&1 | tee .pr/logs/<test_name>.log
git add -f .pr/logs/
git commit -m "docs: add e2e test results" && git push
```
Comment on **both PRs** with pass/fail summary and link to logs.
## Key Gotchas
| Gotcha | Details |
|--------|---------|
| **Feature env auth is isolated** | Each `ohpr-*` deployment has its own Keycloak. Production API keys don't work. Agents cannot log in — you must ask the user to provide a test API key from the feature deployment's UI. |
| **Two SHAs in deploy.yaml** | `OPENHANDS_SHA` and `OPENHANDS_RUNTIME_IMAGE_TAG` must both be updated. The runtime tag is `<sha>-nikolaik`. |
| **Enterprise image must exist** | The Docker CI job on the OpenHands PR must succeed before you can deploy. If it hasn't run, push an empty commit to trigger it. |
| **DNS propagation** | First deployment of a new branch takes 1-2 min for DNS. Subsequent deploys are instant. |
| **Merge-commit SHA ≠ head SHA** | SDK CI tags Docker images with GitHub Actions' merge-commit SHA, not the PR head SHA. Check the SDK PR description or CI logs for the correct tag. |
| **SDK pin blocks merge** | `check-package-versions.yml` prevents merging an OpenHands PR that has `rev` fields in `[tool.poetry.dependencies]`. The SDK must be released to PyPI first. |
| **Flow A: stock agent-server is fine** | When only the Cloud API changes, `OpenHandsCloudWorkspace` talks to the Cloud server, not the agent-server. No custom image needed. |
| **Flow B: agent-server image is required** | When the server needs new SDK code inside runtime containers, you must pin to the SDK PR's agent-server image. |

View File

@@ -1,47 +0,0 @@
---
name: custom-codereview-guide
description: Repo-specific code review guidelines for All-Hands-AI/OpenHands. Provides frontend and backend review rules in addition to the default code review skill.
triggers:
- /codereview
---
# All-Hands-AI/OpenHands Code Review Guidelines
You are an expert code reviewer for the **All-Hands-AI/OpenHands** repository. This skill provides repo-specific review guidelines.
## Frontend: i18n / Translation Key Usage
**Never dynamically construct i18n keys via string interpolation or template literals.**
All translation keys must come from the `I18nKey` enum (`frontend/src/i18n/declaration.ts`) or from canonical mapping objects like `AGENT_STATUS_MAP` (`frontend/src/utils/status.ts`). Dynamically constructed keys (e.g., `` t(`STATUS$${value.toUpperCase()}`) ``) will silently fall back to the raw key string at runtime because `i18next` returns the key itself when a translation is missing — this produces broken UI text with no build-time or test-time error.
### What to flag
- Any call to `t(...)` or `i18next.t(...)` where the key is built at runtime via template literals, string concatenation, or helper functions rather than referencing `I18nKey` or a known mapping
- Any new i18n key referenced in code that does not exist in `frontend/src/i18n/translation.json`
### Correct pattern
```ts
import { AGENT_STATUS_MAP } from "#/utils/status";
const i18nKey = AGENT_STATUS_MAP[agentState];
const message = i18nKey ? t(i18nKey) : fallback;
```
### Incorrect pattern
```ts
// BAD: constructs a key that may not exist in translation.json
const message = t(`STATUS$${agentState.toUpperCase()}`);
```
## Frontend: Data Fetching Architecture
UI components must never call API client methods (`frontend/src/api/`) directly. All data access must go through TanStack Query hooks:
```
UI components → TanStack Query hooks (frontend/src/hooks/query/ or mutation/) → API client (frontend/src/api/) → API endpoints
```
Flag any component that imports directly from `#/api/` and calls fetch/mutation functions without a TanStack Query wrapper.

View File

@@ -95,13 +95,13 @@ git tag X.Y.Z
Create a `saas-rel-X.Y.Z` branch from the tagged commit for the SaaS deployment pipeline.
#### Step 3: Images get tagged automatically
#### Step 3: CI builds Docker images automatically
Every push to `main` / `saas-rel-*` / `oss-rel-*` builds and publishes `ghcr.io/openhands/openhands` and `ghcr.io/openhands/enterprise-server` images for that commit (tagged by SHA, short SHA, and branch name).
The `ghcr-build.yml` workflow triggers on tag pushes and produces:
- `ghcr.io/openhands/openhands:X.Y.Z`, `X.Y`, `X`, `latest`
- `ghcr.io/openhands/runtime:X.Y.Z-nikolaik`, `X.Y-nikolaik`
Pushing a git tag `X.Y.Z` then tags the images for that commit with `X.Y.Z`, `X.Y`, `X`, and `latest`. Non-semver tags just get their literal name applied.
Requires the commit to already be built. If you push the tag too early, the retag CI job fails loudly — re-run it from the Actions UI once the build completes.
The tagging logic lives in `containers/build.sh` — when `GITHUB_REF_NAME` matches a semver pattern (`^[0-9]+\.[0-9]+\.[0-9]+$`), it auto-generates major, major.minor, and `latest` tags.
## Development: Pin SDK to an Unreleased Commit

View File

@@ -46,16 +46,39 @@ These files contain image tags that **must** be updated whenever the SDK version
### `openhands/version.py`
- Reads version from `pyproject.toml` at runtime → `openhands.__version__`
### `openhands/resolver/issue_resolver.py`
- Builds `ghcr.io/openhands/runtime:{openhands.__version__}-nikolaik` dynamically
### `openhands/runtime/utils/runtime_build.py`
- Base repo URL `ghcr.io/openhands/runtime` is a constant; version comes from elsewhere
### `.github/scripts/update_pr_description.sh`
- Uses `${SHORT_SHA}` variable at CI runtime, not hardcoded
### `enterprise/Dockerfile`
- `ARG BASE="ghcr.io/openhands/openhands"` — base image, version supplied at build time
## V0 Legacy Files (separate update cadence)
These reference the V0 runtime image (`ghcr.io/openhands/runtime:X.Y-nikolaik`) for local Docker/Kubernetes paths. They are **not** updated as part of a V1 release but may be updated independently.
### `Development.md`
- `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:X.Y-nikolaik`
### `openhands/runtime/impl/kubernetes/README.md`
- `runtime_container_image = "docker.openhands.dev/openhands/runtime:X.Y-nikolaik"`
### `enterprise/enterprise_local/README.md`
- Uses `ghcr.io/openhands/runtime:main-nikolaik` (points to `main`, not versioned)
### `third_party/runtime/impl/daytona/README.md`
- Uses `${OPENHANDS_VERSION}` variable, not hardcoded
## Image Registries
| Registry | Usage |
|----------|-------|
| `ghcr.io/openhands/agent-server` | V1 agent-server (sandbox) — built by SDK repo CI |
| `ghcr.io/openhands/openhands` | Main app image — built by `ghcr-build.yml` |
| `ghcr.io/openhands/runtime` | V0 runtime sandbox — built by `ghcr-build.yml` |
| `docker.openhands.dev/openhands/*` | Mirror/CDN for the above images |

1
.gitattributes vendored
View File

@@ -4,5 +4,4 @@
* text eol=lf
# Git incorrectly thinks some media is text
*.png -text
*.gif -text
*.mp4 -text

8
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1,8 @@
# CODEOWNERS file for OpenHands repository
# See https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
/frontend/ @amanape @hieptl
/openhands-ui/ @amanape @hieptl
/openhands/ @tofarr @malhotra5 @hieptl
/enterprise/ @chuckbutkus @tofarr @malhotra5
/evaluation/ @xingyaoww @neubig

View File

@@ -1,51 +0,0 @@
name: Compute Docker image tags
description: Produce the canonical OpenHands Docker tag set (ref name, short SHA, full SHA — each in bare and `sha-` prefixed form) for a given image, with optional suffix and extra raw tags.
inputs:
image:
description: Fully qualified image name (e.g. ghcr.io/owner/openhands).
required: true
ref-name:
description: Git ref name to emit as a tag (e.g. main, pr-123, saas-rel-1.2.3).
required: true
suffix:
description: Suffix appended to every tag (e.g. -amd64, -nikolaik-arm64). Leave empty for base (multi-arch manifest) tags.
required: false
default: ""
extra-tags:
description: Additional newline-separated metadata-action tag rules (e.g. extra `type=raw,value=...` lines).
required: false
default: ""
outputs:
tags:
description: Newline-separated list of fully qualified image tags.
value: ${{ steps.meta.outputs.tags }}
labels:
description: Image labels emitted by docker/metadata-action.
value: ${{ steps.meta.outputs.labels }}
version:
description: Sanitized version string (ref-name with any suffix applied). Safe to use in docker tags.
value: ${{ steps.meta.outputs.version }}
runs:
using: composite
steps:
- name: Compute tags
id: meta
uses: docker/metadata-action@v6
env:
# Use the PR head SHA (not the merge SHA) for sha-prefixed tags.
DOCKER_METADATA_PR_HEAD_SHA: "true"
with:
images: ${{ inputs.image }}
flavor: |
latest=false
suffix=${{ inputs.suffix }}
tags: |
type=raw,value=${{ inputs.ref-name }}
type=sha,prefix=sha-
type=sha,prefix=
type=sha,format=long,prefix=sha-
type=sha,format=long,prefix=
${{ inputs.extra-tags }}

View File

@@ -1,43 +0,0 @@
name: Merge multi-arch Docker manifest
description: Build a multi-arch manifest from per-arch image tags pushed by an earlier build step.
inputs:
base-tags:
description: Newline-separated list of base tags (without architecture suffix).
required: true
archs:
description: Space-separated list of architectures (e.g. "amd64 arm64").
required: true
runs:
using: composite
steps:
- name: Login to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ github.token }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Create multi-arch manifests
shell: bash
env:
BASE_TAGS: ${{ inputs.base-tags }}
ARCHS: ${{ inputs.archs }}
run: |
while IFS= read -r tag; do
[[ -z "$tag" ]] && continue
sources=""
for arch in $ARCHS; do
if ! docker buildx imagetools inspect "${tag}-${arch}" > /dev/null 2>&1; then
echo "::error::Missing image ${tag}-${arch}"
exit 1
fi
sources+=" ${tag}-${arch}"
done
echo "Creating manifest for $tag from:$sources"
docker buildx imagetools create -t "$tag" $sources
done <<< "$BASE_TAGS"

View File

@@ -4,7 +4,7 @@ updates:
directory: "/"
schedule:
interval: "daily"
open-pull-requests-limit: 5
open-pull-requests-limit: 1
groups:
# put packages in their own group if they have a history of breaking the build or needing to be reverted
pre-commit:
@@ -29,7 +29,7 @@ updates:
directory: "/frontend"
schedule:
interval: "daily"
open-pull-requests-limit: 5
open-pull-requests-limit: 1
groups:
docusaurus:
patterns:
@@ -51,7 +51,7 @@ updates:
schedule:
interval: "weekly"
day: "wednesday"
open-pull-requests-limit: 5
open-pull-requests-limit: 1
groups:
docusaurus:
patterns:
@@ -72,11 +72,9 @@ updates:
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
- package-ecosystem: "docker"
directories:
- "containers/*"
schedule:
interval: "weekly"
open-pull-requests-limit: 5

View File

@@ -1,46 +1,38 @@
<!-- Keep this PR as draft until it is ready for review. -->
<!-- If you are still working on the PR, please mark it as draft. Maintainers will review PRs marked ready for review, which leads to lost time if your PR is actually not ready yet. Keep the PR marked as draft until it is finally ready for review -->
<!-- AI/LLM agents: be concise and specific. Do not check the box below. -->
## Summary of PR
- [ ] A human has tested these changes.
<!-- Summarize what the PR does -->
---
## Demo Screenshots/Videos
## Why
<!-- AI/LLM AGENTS: This section is intended for a human author to add screenshots or videos demonstrating the PR in action (optional). While many pull requests may be generated by AI/LLM agents, we are fine with this as long as a human author has reviewed and tested the changes to ensure accuracy and functionality. -->
<!-- Describe problem, motivation, etc.-->
## Change Type
## Summary
<!-- 1-3 bullets describing what changed. -->
-
## Issue Number
<!-- Required if there is a relevant issue to this PR. -->
## How to Test
<!--
Required. Share the steps for the reviewer to be able to test your PR. e.g. You can test by running `npm install` then `npm build dev`.
If you could not test this, say why.
-->
## Video/Screenshots
<!--
Provide a video or screenshots of testing your PR. e.g. you added a new feature to the gui, show us the video of you testing it successfully.
-->
## Type
<!-- Choose the types that apply to your PR -->
- [ ] Bug fix
- [ ] Feature
- [ ] Refactor
- [ ] New feature
- [ ] Breaking change
- [ ] Docs / chore
- [ ] Refactor
- [ ] Other (dependency update, docs, typo fixes, etc.)
## Notes
## Checklist
<!-- AI/LLM AGENTS: This checklist is for a human author to complete. Do NOT check either of the two boxes below. Leave them unchecked until a human has personally reviewed and tested the changes. -->
<!-- Optional: migrations, config changes, rollout concerns, follow-ups, or anything reviewers should know. -->
- [ ] I have read and reviewed the code and I understand what the code is doing.
- [ ] I have tested the code to the best of my ability and ensured it works as expected.
## Fixes
<!-- If this resolves an issue, link it here so it will close automatically upon merge. -->
Resolves #(issue)
## Release Notes
<!-- Check the box if this change is worth adding to the release notes. If checked, you must provide an
end-user friendly description for your change below the checkbox. -->
- [ ] Include this change in the Release Notes.

View File

@@ -13,6 +13,7 @@ DOCKER_RUN_COMMAND="docker run -it --rm \
-p 3000:3000 \
-v /var/run/docker.sock:/var/run/docker.sock \
--add-host host.docker.internal:host-gateway \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.openhands.dev/openhands/runtime:${SHORT_SHA}-nikolaik \
--name openhands-app-${SHORT_SHA} \
docker.openhands.dev/openhands/openhands:${SHORT_SHA}"

View File

@@ -1,116 +0,0 @@
# Reusable workflow: build a multi-arch Docker image and publish a merged manifest.
# Called per image from .github/workflows/ghcr-build.yml.
name: Build and push multi-arch image
on:
workflow_call:
inputs:
image:
description: Fully-qualified image name (e.g. "ghcr.io/all-hands-ai/openhands").
required: true
type: string
context:
description: Docker build context.
required: false
type: string
default: "."
dockerfile:
description: Path to the Dockerfile.
required: true
type: string
extra-build-args:
description: Additional build-args (newline-separated). OPENHANDS_BUILD_VERSION is added automatically.
required: false
type: string
default: ""
provenance:
description: Value passed to docker/build-push-action provenance.
required: false
type: boolean
default: false
sbom:
description: Value passed to docker/build-push-action sbom.
required: false
type: boolean
default: false
buildx-driver-opts:
description: Extra buildx driver-opts (e.g. "network=host" for enterprise).
required: false
type: string
default: ""
env:
RELEVANT_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
RELEVANT_REF_NAME: ${{ github.event.pull_request.number && format('pr-{0}', github.event.pull_request.number) || github.ref_name }}
jobs:
build:
name: Build ${{ inputs.image }} (${{ matrix.arch }})
runs-on: ${{ matrix.arch == 'arm64' && 'ubuntu-24.04-arm' || 'ubuntu-22.04' }}
permissions:
contents: read
packages: write
strategy:
matrix:
arch: [amd64, arm64]
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Login to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver-opts: ${{ inputs.buildx-driver-opts }}
- name: Compute per-arch tags
id: meta
uses: ./.github/actions/docker-image-tags
with:
image: ${{ inputs.image }}
ref-name: ${{ env.RELEVANT_REF_NAME }}
suffix: -${{ matrix.arch }}
- name: Build and push
uses: docker/build-push-action@v7
with:
context: ${{ inputs.context }}
file: ${{ inputs.dockerfile }}
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/${{ matrix.arch }}
build-args: |
OPENHANDS_BUILD_VERSION=${{ env.RELEVANT_REF_NAME }}
${{ inputs.extra-build-args }}
cache-from: |
type=registry,ref=${{ inputs.image }}:buildcache-${{ steps.meta.outputs.version }}
type=registry,ref=${{ inputs.image }}:buildcache-main-${{ matrix.arch }}
cache-to: type=registry,ref=${{ inputs.image }}:buildcache-${{ steps.meta.outputs.version }},mode=max
provenance: ${{ inputs.provenance }}
sbom: ${{ inputs.sbom }}
merge:
name: Merge ${{ inputs.image }} manifest
runs-on: ubuntu-22.04
needs: build
permissions:
packages: write
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Compute base tags
id: meta_base
uses: ./.github/actions/docker-image-tags
with:
image: ${{ inputs.image }}
ref-name: ${{ env.RELEVANT_REF_NAME }}
- name: Merge manifests
uses: ./.github/actions/docker-merge-manifest
with:
base-tags: ${{ steps.meta_base.outputs.tags }}
archs: "amd64 arm64"

View File

@@ -12,7 +12,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v6

View File

@@ -12,7 +12,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v6

228
.github/workflows/e2e-tests.yml vendored Normal file
View File

@@ -0,0 +1,228 @@
name: End-to-End Tests
on:
pull_request:
types: [opened, synchronize, reopened, labeled]
branches:
- main
- develop
workflow_dispatch:
jobs:
e2e-tests:
if: contains(github.event.pull_request.labels.*.name, 'end-to-end') || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 60
env:
GITHUB_REPO_NAME: ${{ github.repository }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install poetry via pipx
uses: abatilo/actions-poetry@v4
with:
poetry-version: 2.1.3
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.12'
cache: 'poetry'
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-0 libnotify4 libnss3 libxss1 libxtst6 xauth xvfb libgbm1 libasound2t64 netcat-openbsd
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: 'frontend/package-lock.json'
- name: Setup environment for end-to-end tests
run: |
# Create test results directory
mkdir -p test-results
# Create downloads directory for OpenHands (use a directory in the home folder)
mkdir -p $HOME/downloads
sudo chown -R $USER:$USER $HOME/downloads
sudo chmod -R 755 $HOME/downloads
- name: Build OpenHands
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
LLM_MODEL: ${{ secrets.LLM_MODEL || 'gpt-4o' }}
LLM_API_KEY: ${{ secrets.LLM_API_KEY || 'test-key' }}
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
INSTALL_DOCKER: 1
RUNTIME: docker
FRONTEND_PORT: 12000
FRONTEND_HOST: 0.0.0.0
BACKEND_HOST: 0.0.0.0
BACKEND_PORT: 3000
ENABLE_BROWSER: true
INSTALL_PLAYWRIGHT: 1
run: |
# Fix poetry.lock file if needed
echo "Fixing poetry.lock file if needed..."
poetry lock
# Build OpenHands using make build
echo "Running make build..."
make build
# Install Chromium Headless Shell for Playwright (needed for pytest-playwright)
echo "Installing Chromium Headless Shell for Playwright..."
poetry run playwright install chromium-headless-shell
# Verify Playwright browsers are installed (for e2e tests only)
echo "Verifying Playwright browsers installation for e2e tests..."
BROWSER_CHECK=$(poetry run python tests/e2e/check_playwright.py 2>/dev/null)
if [ "$BROWSER_CHECK" != "chromium_found" ]; then
echo "ERROR: Chromium browser not found or not working for e2e tests"
echo "$BROWSER_CHECK"
exit 1
else
echo "Playwright browsers are properly installed for e2e tests."
fi
# Docker runtime will handle workspace directory creation
# Start the application using make run with custom parameters and reduced logging
echo "Starting OpenHands using make run..."
# Set environment variables to reduce logging verbosity
export PYTHONUNBUFFERED=1
export LOG_LEVEL=WARNING
export UVICORN_LOG_LEVEL=warning
export OPENHANDS_LOG_LEVEL=WARNING
FRONTEND_PORT=12000 FRONTEND_HOST=0.0.0.0 BACKEND_HOST=0.0.0.0 make run > /tmp/openhands-e2e-test.log 2>&1 &
# Store the PID of the make run process
MAKE_PID=$!
echo "OpenHands started with PID: $MAKE_PID"
# Wait for the application to start
echo "Waiting for OpenHands to start..."
max_attempts=15
attempt=1
while [ $attempt -le $max_attempts ]; do
echo "Checking if OpenHands is running (attempt $attempt of $max_attempts)..."
# Check if the process is still running
if ! ps -p $MAKE_PID > /dev/null; then
echo "ERROR: OpenHands process has terminated unexpectedly"
echo "Last 50 lines of the log:"
tail -n 50 /tmp/openhands-e2e-test.log
exit 1
fi
# Check if frontend port is open
if nc -z localhost 12000; then
# Verify we can get HTML content
if curl -s http://localhost:12000 | grep -q "<html"; then
echo "SUCCESS: OpenHands is running and serving HTML content on port 12000"
break
else
echo "Port 12000 is open but not serving HTML content yet"
fi
else
echo "Frontend port 12000 is not open yet"
fi
# Show log output on each attempt
echo "Recent log output:"
tail -n 20 /tmp/openhands-e2e-test.log
# Wait before next attempt
echo "Waiting 10 seconds before next check..."
sleep 10
attempt=$((attempt + 1))
# Exit if we've reached the maximum number of attempts
if [ $attempt -gt $max_attempts ]; then
echo "ERROR: OpenHands failed to start after $max_attempts attempts"
echo "Last 50 lines of the log:"
tail -n 50 /tmp/openhands-e2e-test.log
exit 1
fi
done
# Final verification that the app is running
if ! nc -z localhost 12000 || ! curl -s http://localhost:12000 | grep -q "<html"; then
echo "ERROR: OpenHands is not running properly on port 12000"
echo "Last 50 lines of the log:"
tail -n 50 /tmp/openhands-e2e-test.log
exit 1
fi
# Print success message
echo "OpenHands is running successfully on port 12000"
- name: Run end-to-end tests
env:
GITHUB_TOKEN: ${{ secrets.E2E_TEST_GITHUB_TOKEN }}
LLM_MODEL: ${{ secrets.LLM_MODEL || 'gpt-4o' }}
LLM_API_KEY: ${{ secrets.LLM_API_KEY || 'test-key' }}
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
run: |
# Check if the application is running
if ! nc -z localhost 12000; then
echo "ERROR: OpenHands is not running on port 12000"
echo "Last 50 lines of the log:"
tail -n 50 /tmp/openhands-e2e-test.log
exit 1
fi
# Run the tests with detailed output
cd tests/e2e
poetry run python -m pytest \
test_settings.py::test_github_token_configuration \
test_conversation.py::test_conversation_start \
test_browsing_catchphrase.py::test_browsing_catchphrase \
test_multi_conversation_resume.py::test_multi_conversation_resume \
-v --no-header --capture=no --timeout=900
- name: Upload test results
if: always()
uses: actions/upload-artifact@v6
with:
name: playwright-report
path: tests/e2e/test-results/
retention-days: 30
- name: Upload OpenHands logs
if: always()
uses: actions/upload-artifact@v6
with:
name: openhands-logs
path: |
/tmp/openhands-e2e-test.log
/tmp/openhands-e2e-build.log
/tmp/openhands-backend.log
/tmp/openhands-frontend.log
/tmp/backend-health-check.log
/tmp/frontend-check.log
/tmp/vite-config.log
/tmp/makefile-contents.log
retention-days: 30
- name: Cleanup
if: always()
run: |
# Stop OpenHands processes
echo "Stopping OpenHands processes..."
pkill -f "python -m openhands.server" || true
pkill -f "npm run dev" || true
pkill -f "make run" || true
# Print process status for debugging
echo "Checking if any OpenHands processes are still running:"
ps aux | grep -E "openhands|npm run dev" || true

View File

@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout PR branch
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
@@ -34,7 +34,7 @@ jobs:
fi
- name: Find Comment
uses: peter-evans/find-comment@v4
uses: peter-evans/find-comment@v3
id: find-comment
with:
issue-number: ${{ github.event.pull_request.number }}

View File

@@ -17,20 +17,18 @@ concurrency:
jobs:
fe-e2e-test:
name: FE E2E Tests
runs-on: ubuntu-22.04
runs-on: blacksmith-4vcpu-ubuntu-2204
strategy:
matrix:
node-version: [22]
fail-fast: true
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
uses: useblacksmith/setup-node@v5
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
working-directory: ./frontend
run: npm ci
@@ -41,7 +39,7 @@ jobs:
working-directory: ./frontend
run: npx playwright test --project=chromium
- name: Upload Playwright report
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
if: always()
with:
name: playwright-report

View File

@@ -21,20 +21,18 @@ jobs:
# Run frontend unit tests
fe-test:
name: FE Unit Tests
runs-on: ubuntu-22.04
runs-on: blacksmith-4vcpu-ubuntu-2204
strategy:
matrix:
node-version: [22]
fail-fast: true
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
uses: useblacksmith/setup-node@v5
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
working-directory: ./frontend
run: npm ci

View File

@@ -1,13 +1,17 @@
# Workflow that builds and pushes the OpenHands app and enterprise Docker images to ghcr.io.
# Per-image build logic lives in .github/workflows/_build-image.yml.
# Workflow that builds, tests and then pushes the OpenHands and runtime docker images to the ghcr.io repository
name: Docker
# Always run on "main"
# Always run on tags
# Always run on PRs
# Can also be triggered manually
on:
push:
branches:
- main
- "saas-rel-*"
- "oss-rel-*"
tags:
- "*"
pull_request:
workflow_dispatch:
inputs:
@@ -16,45 +20,247 @@ on:
required: true
default: ""
# PR events share a group so pushes supersede each other; each commit on a release branch gets its own group.
# If triggered by a PR, it will be in the same group. However, each commit on main will be in its own unique group
concurrency:
group: ${{ github.workflow }}-${{ (github.head_ref && github.ref) || github.run_id }}
cancel-in-progress: true
jobs:
build_app:
name: App
if: github.event.pull_request.head.repo.fork != true
uses: ./.github/workflows/_build-image.yml
with:
image: ghcr.io/openhands/openhands
dockerfile: containers/app/Dockerfile
env:
RELEVANT_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
build_enterprise:
name: Enterprise
jobs:
define-matrix:
runs-on: blacksmith
outputs:
base_image: ${{ steps.define-base-images.outputs.base_image }}
steps:
- name: Define base images
shell: bash
id: define-base-images
run: |
if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then
json=$(jq -n -c '[
{ image: "nikolaik/python-nodejs:python3.12-nodejs22", tag: "nikolaik" }
]')
else
json=$(jq -n -c '[
{ image: "nikolaik/python-nodejs:python3.12-nodejs22", tag: "nikolaik" },
{ image: "ubuntu:24.04", tag: "ubuntu" }
]')
fi
echo "base_image=$json" >> "$GITHUB_OUTPUT"
# Builds the OpenHands Docker images
ghcr_build_app:
name: Build App Image
runs-on: blacksmith-4vcpu-ubuntu-2204
if: "!(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/ext-v'))"
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.7.0
with:
image: tonistiigi/binfmt:latest
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
- name: Lowercase Repository Owner
run: |
echo REPO_OWNER=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
- name: Build and push app image
if: "!github.event.pull_request.head.repo.fork"
run: |
./containers/build.sh -i openhands -o ${{ env.REPO_OWNER }} --push
# Builds the runtime Docker images
ghcr_build_runtime:
name: Build Runtime Image
runs-on: blacksmith-8vcpu-ubuntu-2204
if: "!(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/ext-v'))"
permissions:
contents: read
packages: write
needs: define-matrix
strategy:
matrix:
base_image: ${{ fromJson(needs.define-matrix.outputs.base_image) }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.7.0
with:
image: tonistiigi/binfmt:latest
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
- name: Install poetry via pipx
run: pipx install poetry
- name: Set up Python
uses: useblacksmith/setup-python@v6
with:
python-version: "3.12"
cache: poetry
- name: Install Python dependencies using Poetry
run: make install-python-dependencies POETRY_GROUP=main INSTALL_PLAYWRIGHT=0
- name: Create source distribution and Dockerfile
run: poetry run python3 -m openhands.runtime.utils.runtime_build --base_image ${{ matrix.base_image.image }} --build_folder containers/runtime --force_rebuild
- name: Lowercase Repository Owner
run: |
echo REPO_OWNER=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
- name: Short SHA
run: |
echo SHORT_SHA=$(git rev-parse --short "$RELEVANT_SHA") >> $GITHUB_ENV
- name: Determine docker build params
if: github.event.pull_request.head.repo.fork != true
shell: bash
run: |
./containers/build.sh -i runtime -o ${{ env.REPO_OWNER }} -t ${{ matrix.base_image.tag }} --dry
DOCKER_BUILD_JSON=$(jq -c . < docker-build-dry.json)
echo "DOCKER_TAGS=$(echo "$DOCKER_BUILD_JSON" | jq -r '.tags | join(",")')" >> $GITHUB_ENV
echo "DOCKER_PLATFORM=$(echo "$DOCKER_BUILD_JSON" | jq -r '.platform')" >> $GITHUB_ENV
echo "DOCKER_BUILD_ARGS=$(echo "$DOCKER_BUILD_JSON" | jq -r '.build_args | join(",")')" >> $GITHUB_ENV
- name: Build and push runtime image ${{ matrix.base_image.image }}
if: github.event.pull_request.head.repo.fork != true
uses: useblacksmith/build-push-action@v1
with:
push: true
tags: ${{ env.DOCKER_TAGS }}
platforms: ${{ env.DOCKER_PLATFORM }}
# Caching directives to boost performance
cache-from: type=registry,ref=ghcr.io/${{ env.REPO_OWNER }}/runtime:buildcache-${{ matrix.base_image.tag }}
cache-to: type=registry,ref=ghcr.io/${{ env.REPO_OWNER }}/runtime:buildcache-${{ matrix.base_image.tag }},mode=max
build-args: ${{ env.DOCKER_BUILD_ARGS }}
context: containers/runtime
provenance: false
# Forked repos can't push to GHCR, so we just build in order to populate the cache for rebuilding
- name: Build runtime image ${{ matrix.base_image.image }} for fork
if: github.event.pull_request.head.repo.fork
uses: useblacksmith/build-push-action@v1
with:
tags: ghcr.io/${{ env.REPO_OWNER }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image.tag }}
context: containers/runtime
- name: Upload runtime source for fork
if: github.event.pull_request.head.repo.fork
uses: actions/upload-artifact@v6
with:
name: runtime-src-${{ matrix.base_image.tag }}
path: containers/runtime
ghcr_build_enterprise:
name: Push Enterprise Image
runs-on: blacksmith-8vcpu-ubuntu-2204
permissions:
contents: read
packages: write
needs: [define-matrix, ghcr_build_app]
# Do not build enterprise in forks
if: github.event.pull_request.head.repo.fork != true
needs: build_app
uses: ./.github/workflows/_build-image.yml
with:
image: ghcr.io/openhands/enterprise-server
dockerfile: enterprise/Dockerfile
extra-build-args: OPENHANDS_VERSION=sha-${{ github.event.pull_request.head.sha || github.sha }}
provenance: true
sbom: true
buildx-driver-opts: network=host
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
# Set up Docker Buildx for better performance
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver-opts: network=host
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/openhands/enterprise-server
tags: |
type=ref,event=branch
type=ref,event=pr
type=sha
type=sha,format=long
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
flavor: |
latest=auto
prefix=
suffix=
env:
DOCKER_METADATA_PR_HEAD_SHA: true
- name: Determine app image tag
shell: bash
run: |
# Duplicated with build.sh
sanitized_ref_name=$(echo "$GITHUB_REF_NAME" | sed 's/[^a-zA-Z0-9.-]\+/-/g')
OPENHANDS_BUILD_VERSION=$sanitized_ref_name
sanitized_ref_name=$(echo "$sanitized_ref_name" | tr '[:upper:]' '[:lower:]') # lower case is required in tagging
echo "OPENHANDS_DOCKER_TAG=${sanitized_ref_name}" >> $GITHUB_ENV
- name: Build and push Docker image
uses: useblacksmith/build-push-action@v1
with:
context: .
file: enterprise/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
OPENHANDS_VERSION=${{ env.OPENHANDS_DOCKER_TAG }}
platforms: linux/amd64
# Add build provenance
provenance: true
# Add build attestations for better security
sbom: true
# "All Runtime Tests Passed" is a required job for PRs to merge
# We can remove this once the config changes
runtime_tests_check_success:
name: All Runtime Tests Passed
runs-on: blacksmith-4vcpu-ubuntu-2204
steps:
- name: All tests passed
run: echo "All runtime tests have passed successfully!"
update_pr_description:
name: Update PR Description
if: github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork && github.actor != 'dependabot[bot]'
needs: build_app
runs-on: ubuntu-22.04
needs: [ghcr_build_runtime]
runs-on: blacksmith-4vcpu-ubuntu-2204
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Get short SHA
id: short_sha
run: echo "SHORT_SHA=$(echo ${{ github.event.pull_request.head.sha }} | cut -c1-7)" >> "$GITHUB_OUTPUT"
run: echo "SHORT_SHA=$(echo ${{ github.event.pull_request.head.sha }} | cut -c1-7)" >> $GITHUB_OUTPUT
- name: Update PR Description
env:
@@ -65,4 +271,4 @@ jobs:
shell: bash
run: |
echo "Updating PR description with Docker and uvx commands"
bash "${GITHUB_WORKSPACE}/.github/scripts/update_pr_description.sh"
bash ${GITHUB_WORKSPACE}/.github/scripts/update_pr_description.sh

View File

@@ -9,12 +9,12 @@ jobs:
lint-fix-frontend:
if: github.event.label.name == 'lint-fix'
name: Fix frontend linting issues
runs-on: ubuntu-22.04
runs-on: blacksmith-4vcpu-ubuntu-2204
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
ref: ${{ github.head_ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
@@ -22,14 +22,13 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Install Node.js 22
uses: actions/setup-node@v4
uses: useblacksmith/setup-node@v5
with:
node-version: 22
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install frontend dependencies
working-directory: ./frontend
run: npm ci
run: |
cd frontend
npm install --frozen-lockfile
- name: Generate i18n and route types
run: |
cd frontend
@@ -59,12 +58,12 @@ jobs:
lint-fix-python:
if: github.event.label.name == 'lint-fix'
name: Fix Python linting issues
runs-on: ubuntu-22.04
runs-on: blacksmith-4vcpu-ubuntu-2204
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
ref: ${{ github.head_ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
@@ -72,7 +71,7 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Set up python
uses: actions/setup-python@v6
uses: useblacksmith/setup-python@v6
with:
python-version: 3.12
cache: "pip"

View File

@@ -19,35 +19,34 @@ jobs:
# Run lint on the frontend code
lint-frontend:
name: Lint frontend
runs-on: ubuntu-22.04
runs-on: blacksmith-4vcpu-ubuntu-2204
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Install Node.js 22
uses: actions/setup-node@v4
uses: useblacksmith/setup-node@v5
with:
node-version: 22
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
working-directory: ./frontend
run: npm ci
run: |
cd frontend
npm install --frozen-lockfile
- name: Lint, TypeScript compilation, and translation checks
run: |
cd frontend
npm run lint
npm run make-i18n && npx tsc
npm run make-i18n && tsc
npm run check-translation-completeness
# Run lint on the python code
lint-python:
name: Lint python
runs-on: ubuntu-22.04
runs-on: blacksmith-4vcpu-ubuntu-2204
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up python
uses: actions/setup-python@v6
uses: useblacksmith/setup-python@v6
with:
python-version: 3.12
cache: "pip"
@@ -58,13 +57,13 @@ jobs:
lint-enterprise-python:
name: Lint enterprise python
runs-on: ubuntu-22.04
runs-on: blacksmith-4vcpu-ubuntu-2204
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up python
uses: actions/setup-python@v6
uses: useblacksmith/setup-python@v6
with:
python-version: 3.12
cache: "pip"

View File

@@ -18,7 +18,7 @@ concurrency:
jobs:
check-version:
name: Check if version has changed
runs-on: ubuntu-22.04
runs-on: blacksmith-4vcpu-ubuntu-2204
defaults:
run:
shell: bash
@@ -27,7 +27,7 @@ jobs:
current-version: ${{ steps.version-check.outputs.current-version }}
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
fetch-depth: 2 # Need previous commit to compare
@@ -55,7 +55,7 @@ jobs:
publish:
name: Publish to npm
runs-on: ubuntu-22.04
runs-on: blacksmith-4vcpu-ubuntu-2204
needs: check-version
if: needs.check-version.outputs.should-publish == 'true'
defaults:
@@ -63,7 +63,7 @@ jobs:
shell: bash
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2

433
.github/workflows/openhands-resolver.yml vendored Normal file
View File

@@ -0,0 +1,433 @@
name: Auto-Fix Tagged Issue with OpenHands
on:
workflow_call:
inputs:
max_iterations:
required: false
type: number
default: 50
macro:
required: false
type: string
default: "@openhands-agent"
target_branch:
required: false
type: string
default: "main"
description: "Target branch to pull and create PR against"
pr_type:
required: false
type: string
default: "draft"
description: "The PR type that is going to be created (draft, ready)"
LLM_MODEL:
required: false
type: string
default: "anthropic/claude-sonnet-4-20250514"
LLM_API_VERSION:
required: false
type: string
default: ""
base_container_image:
required: false
type: string
default: ""
description: "Custom sandbox env"
runner:
required: false
type: string
default: "ubuntu-latest"
secrets:
LLM_MODEL:
required: false
LLM_API_KEY:
required: true
LLM_BASE_URL:
required: false
PAT_TOKEN:
required: false
PAT_USERNAME:
required: false
issues:
types: [labeled]
pull_request:
types: [labeled]
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
pull_request_review:
types: [submitted]
permissions:
contents: write
pull-requests: write
issues: write
jobs:
auto-fix:
if: |
github.event_name == 'workflow_call' ||
github.event.label.name == 'fix-me' ||
github.event.label.name == 'fix-me-experimental' ||
(
((github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment') &&
contains(github.event.comment.body, inputs.macro || '@openhands-agent') &&
(github.event.comment.author_association == 'OWNER' || github.event.comment.author_association == 'COLLABORATOR' || github.event.comment.author_association == 'MEMBER')
) ||
(github.event_name == 'pull_request_review' &&
contains(github.event.review.body, inputs.macro || '@openhands-agent') &&
(github.event.review.author_association == 'OWNER' || github.event.review.author_association == 'COLLABORATOR' || github.event.review.author_association == 'MEMBER')
)
)
runs-on: "${{ inputs.runner || 'ubuntu-latest' }}"
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Upgrade pip
run: |
python -m pip install --upgrade pip
- name: Get latest versions and create requirements.txt
run: |
python -m pip index versions openhands-ai > openhands_versions.txt
OPENHANDS_VERSION=$(head -n 1 openhands_versions.txt | awk '{print $2}' | tr -d '()')
# Create a new requirements.txt locally within the workflow, ensuring no reference to the repo's file
echo "openhands-ai==${OPENHANDS_VERSION}" > /tmp/requirements.txt
cat /tmp/requirements.txt
- name: Cache pip dependencies
if: |
!(
github.event.label.name == 'fix-me-experimental' ||
(
(github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment') &&
contains(github.event.comment.body, '@openhands-agent-exp')
) ||
(
github.event_name == 'pull_request_review' &&
contains(github.event.review.body, '@openhands-agent-exp')
)
)
uses: actions/cache@v5
with:
path: ${{ env.pythonLocation }}/lib/python3.12/site-packages/*
key: ${{ runner.os }}-pip-openhands-resolver-${{ hashFiles('/tmp/requirements.txt') }}
restore-keys: |
${{ runner.os }}-pip-openhands-resolver-${{ hashFiles('/tmp/requirements.txt') }}
- name: Check required environment variables
env:
LLM_MODEL: ${{ secrets.LLM_MODEL || inputs.LLM_MODEL }}
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
LLM_API_VERSION: ${{ inputs.LLM_API_VERSION }}
PAT_TOKEN: ${{ secrets.PAT_TOKEN }}
PAT_USERNAME: ${{ secrets.PAT_USERNAME }}
GITHUB_TOKEN: ${{ github.token }}
run: |
required_vars=("LLM_API_KEY")
for var in "${required_vars[@]}"; do
if [ -z "${!var}" ]; then
echo "Error: Required environment variable $var is not set."
exit 1
fi
done
# Check optional variables and warn about fallbacks
if [ -z "$LLM_BASE_URL" ]; then
echo "Warning: LLM_BASE_URL is not set, will use default API endpoint"
fi
if [ -z "$PAT_TOKEN" ]; then
echo "Warning: PAT_TOKEN is not set, falling back to GITHUB_TOKEN"
fi
if [ -z "$PAT_USERNAME" ]; then
echo "Warning: PAT_USERNAME is not set, will use openhands-agent"
fi
- name: Set environment variables
env:
REVIEW_BODY: ${{ github.event.review.body || '' }}
run: |
# Handle pull request events first
if [ -n "${{ github.event.pull_request.number }}" ]; then
echo "ISSUE_NUMBER=${{ github.event.pull_request.number }}" >> $GITHUB_ENV
echo "ISSUE_TYPE=pr" >> $GITHUB_ENV
# Handle pull request review events
elif [ -n "$REVIEW_BODY" ]; then
echo "ISSUE_NUMBER=${{ github.event.pull_request.number }}" >> $GITHUB_ENV
echo "ISSUE_TYPE=pr" >> $GITHUB_ENV
# Handle issue comment events that reference a PR
elif [ -n "${{ github.event.issue.pull_request }}" ]; then
echo "ISSUE_NUMBER=${{ github.event.issue.number }}" >> $GITHUB_ENV
echo "ISSUE_TYPE=pr" >> $GITHUB_ENV
# Handle regular issue events
else
echo "ISSUE_NUMBER=${{ github.event.issue.number }}" >> $GITHUB_ENV
echo "ISSUE_TYPE=issue" >> $GITHUB_ENV
fi
if [ -n "$REVIEW_BODY" ]; then
echo "COMMENT_ID=${{ github.event.review.id || 'None' }}" >> $GITHUB_ENV
else
echo "COMMENT_ID=${{ github.event.comment.id || 'None' }}" >> $GITHUB_ENV
fi
echo "MAX_ITERATIONS=${{ inputs.max_iterations || 50 }}" >> $GITHUB_ENV
echo "SANDBOX_ENV_GITHUB_TOKEN=${{ secrets.PAT_TOKEN || github.token }}" >> $GITHUB_ENV
echo "SANDBOX_BASE_CONTAINER_IMAGE=${{ inputs.base_container_image }}" >> $GITHUB_ENV
# Set branch variables
echo "TARGET_BRANCH=${{ inputs.target_branch || 'main' }}" >> $GITHUB_ENV
- name: Comment on issue with start message
uses: actions/github-script@v7
with:
github-token: ${{ secrets.PAT_TOKEN || github.token }}
script: |
const issueType = process.env.ISSUE_TYPE;
github.rest.issues.createComment({
issue_number: ${{ env.ISSUE_NUMBER }},
owner: context.repo.owner,
repo: context.repo.repo,
body: `[OpenHands](https://github.com/OpenHands/OpenHands) started fixing the ${issueType}! You can monitor the progress [here](https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}).`
});
- name: Install OpenHands
id: install_openhands
uses: actions/github-script@v7
env:
COMMENT_BODY: ${{ github.event.comment.body || '' }}
REVIEW_BODY: ${{ github.event.review.body || '' }}
LABEL_NAME: ${{ github.event.label.name || '' }}
EVENT_NAME: ${{ github.event_name }}
with:
script: |
const commentBody = process.env.COMMENT_BODY.trim();
const reviewBody = process.env.REVIEW_BODY.trim();
const labelName = process.env.LABEL_NAME.trim();
const eventName = process.env.EVENT_NAME.trim();
// Check conditions
const isExperimentalLabel = labelName === "fix-me-experimental";
const isIssueCommentExperimental =
(eventName === "issue_comment" || eventName === "pull_request_review_comment") &&
commentBody.includes("@openhands-agent-exp");
const isReviewCommentExperimental =
eventName === "pull_request_review" && reviewBody.includes("@openhands-agent-exp");
// Set output variable
core.setOutput('isExperimental', isExperimentalLabel || isIssueCommentExperimental || isReviewCommentExperimental);
// Perform package installation
if (isExperimentalLabel || isIssueCommentExperimental || isReviewCommentExperimental) {
console.log("Installing experimental OpenHands...");
await exec.exec("pip install git+https://github.com/openhands/openhands.git");
} else {
console.log("Installing from requirements.txt...");
await exec.exec("pip install -r /tmp/requirements.txt");
}
- name: Attempt to resolve issue
env:
GITHUB_TOKEN: ${{ secrets.PAT_TOKEN || github.token }}
GITHUB_USERNAME: ${{ secrets.PAT_USERNAME || 'openhands-agent' }}
GIT_USERNAME: ${{ secrets.PAT_USERNAME || 'openhands-agent' }}
LLM_MODEL: ${{ secrets.LLM_MODEL || inputs.LLM_MODEL }}
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
LLM_API_VERSION: ${{ inputs.LLM_API_VERSION }}
PYTHONPATH: ""
run: |
cd /tmp && python -m openhands.resolver.resolve_issue \
--selected-repo ${{ github.repository }} \
--issue-number ${{ env.ISSUE_NUMBER }} \
--issue-type ${{ env.ISSUE_TYPE }} \
--max-iterations ${{ env.MAX_ITERATIONS }} \
--comment-id ${{ env.COMMENT_ID }} \
--is-experimental ${{ steps.install_openhands.outputs.isExperimental }}
- name: Check resolution result
id: check_result
run: |
if cd /tmp && grep -q '"success":true' output/output.jsonl; then
echo "RESOLUTION_SUCCESS=true" >> $GITHUB_OUTPUT
else
echo "RESOLUTION_SUCCESS=false" >> $GITHUB_OUTPUT
fi
- name: Upload output.jsonl as artifact
uses: actions/upload-artifact@v6
if: always() # Upload even if the previous steps fail
with:
name: resolver-output
path: /tmp/output/output.jsonl
retention-days: 30 # Keep the artifact for 30 days
- name: Create draft PR or push branch
if: always() # Create PR or branch even if the previous steps fail
env:
GITHUB_TOKEN: ${{ secrets.PAT_TOKEN || github.token }}
GITHUB_USERNAME: ${{ secrets.PAT_USERNAME || 'openhands-agent' }}
GIT_USERNAME: ${{ secrets.PAT_USERNAME || 'openhands-agent' }}
LLM_MODEL: ${{ secrets.LLM_MODEL || inputs.LLM_MODEL }}
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
LLM_API_VERSION: ${{ inputs.LLM_API_VERSION }}
PYTHONPATH: ""
run: |
if [ "${{ steps.check_result.outputs.RESOLUTION_SUCCESS }}" == "true" ]; then
cd /tmp && python -m openhands.resolver.send_pull_request \
--issue-number ${{ env.ISSUE_NUMBER }} \
--target-branch ${{ env.TARGET_BRANCH }} \
--pr-type ${{ inputs.pr_type || 'draft' }} \
--reviewer ${{ github.actor }} | tee pr_result.txt && \
grep "PR created" pr_result.txt | sed 's/.*\///g' > pr_number.txt
else
cd /tmp && python -m openhands.resolver.send_pull_request \
--issue-number ${{ env.ISSUE_NUMBER }} \
--pr-type branch \
--send-on-failure | tee branch_result.txt && \
grep "branch created" branch_result.txt | sed 's/.*\///g; s/.expand=1//g' > branch_name.txt
fi
# Step leaves comment for when agent is invoked on PR
- name: Analyze Push Logs (Updated PR or No Changes) # Skip comment if PR update was successful OR leave comment if the agent made no code changes
uses: actions/github-script@v7
if: always()
env:
AGENT_RESPONDED: ${{ env.AGENT_RESPONDED || 'false' }}
ISSUE_NUMBER: ${{ env.ISSUE_NUMBER }}
with:
github-token: ${{ secrets.PAT_TOKEN || github.token }}
script: |
const fs = require('fs');
const issueNumber = process.env.ISSUE_NUMBER;
let logContent = '';
try {
logContent = fs.readFileSync('/tmp/pr_result.txt', 'utf8').trim();
} catch (error) {
console.error('Error reading pr_result.txt file:', error);
}
const noChangesMessage = `No changes to commit for issue #${issueNumber}. Skipping commit.`;
// Check logs from send_pull_request.py (pushes code to GitHub)
if (logContent.includes("Updated pull request")) {
console.log("Updated pull request found. Skipping comment.");
process.env.AGENT_RESPONDED = 'true';
} else if (logContent.includes(noChangesMessage)) {
github.rest.issues.createComment({
issue_number: issueNumber,
owner: context.repo.owner,
repo: context.repo.repo,
body: `The workflow to fix this issue encountered an error. Openhands failed to create any code changes.`
});
process.env.AGENT_RESPONDED = 'true';
}
# Step leaves comment for when agent is invoked on issue
- name: Comment on issue # Comment link to either PR or branch created by agent
uses: actions/github-script@v7
if: always() # Comment on issue even if the previous steps fail
env:
AGENT_RESPONDED: ${{ env.AGENT_RESPONDED || 'false' }}
ISSUE_NUMBER: ${{ env.ISSUE_NUMBER }}
RESOLUTION_SUCCESS: ${{ steps.check_result.outputs.RESOLUTION_SUCCESS }}
with:
github-token: ${{ secrets.PAT_TOKEN || github.token }}
script: |
const fs = require('fs');
const path = require('path');
const issueNumber = process.env.ISSUE_NUMBER;
const success = process.env.RESOLUTION_SUCCESS === 'true';
let prNumber = '';
let branchName = '';
let resultExplanation = '';
try {
if (success) {
prNumber = fs.readFileSync('/tmp/pr_number.txt', 'utf8').trim();
} else {
branchName = fs.readFileSync('/tmp/branch_name.txt', 'utf8').trim();
}
} catch (error) {
console.error('Error reading file:', error);
}
try {
if (!success){
// Read result_explanation from JSON file for failed resolution
const outputFilePath = path.resolve('/tmp/output/output.jsonl');
if (fs.existsSync(outputFilePath)) {
const outputContent = fs.readFileSync(outputFilePath, 'utf8');
const jsonLines = outputContent.split('\n').filter(line => line.trim() !== '');
if (jsonLines.length > 0) {
// First entry in JSON lines has the key 'result_explanation'
const firstEntry = JSON.parse(jsonLines[0]);
resultExplanation = firstEntry.result_explanation || '';
}
}
}
} catch (error){
console.error('Error reading file:', error);
}
// Check "success" log from resolver output
if (success && prNumber) {
github.rest.issues.createComment({
issue_number: issueNumber,
owner: context.repo.owner,
repo: context.repo.repo,
body: `A potential fix has been generated and a draft PR #${prNumber} has been created. Please review the changes.`
});
process.env.AGENT_RESPONDED = 'true';
} else if (!success && branchName) {
let commentBody = `An attempt was made to automatically fix this issue, but it was unsuccessful. A branch named '${branchName}' has been created with the attempted changes. You can view the branch [here](https://github.com/${context.repo.owner}/${context.repo.repo}/tree/${branchName}). Manual intervention may be required.`;
if (resultExplanation) {
commentBody += `\n\nAdditional details about the failure:\n${resultExplanation}`;
}
github.rest.issues.createComment({
issue_number: issueNumber,
owner: context.repo.owner,
repo: context.repo.repo,
body: commentBody
});
process.env.AGENT_RESPONDED = 'true';
}
# Leave error comment when both PR/Issue comment handling fail
- name: Fallback Error Comment
uses: actions/github-script@v7
if: ${{ env.AGENT_RESPONDED == 'false' }} # Only run if no conditions were met in previous steps
env:
ISSUE_NUMBER: ${{ env.ISSUE_NUMBER }}
with:
github-token: ${{ secrets.PAT_TOKEN || github.token }}
script: |
const issueNumber = process.env.ISSUE_NUMBER;
github.rest.issues.createComment({
issue_number: issueNumber,
owner: context.repo.owner,
repo: context.repo.repo,
body: `The workflow to fix this issue encountered an error. Please check the [workflow logs](https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}) for more information.`
});

View File

@@ -1,136 +0,0 @@
---
name: PR Artifacts
on:
workflow_dispatch: # Manual trigger for testing
pull_request:
types: [opened, synchronize, reopened]
branches: [main]
pull_request_review:
types: [submitted]
jobs:
# Auto-remove .pr/ directory when a reviewer approves
cleanup-on-approval:
concurrency:
group: cleanup-pr-artifacts-${{ github.event.pull_request.number }}
cancel-in-progress: false
if: github.event_name == 'pull_request_review' && github.event.review.state == 'approved'
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Check if fork PR
id: check-fork
run: |
if [ "${{ github.event.pull_request.head.repo.full_name }}" != "${{ github.event.pull_request.base.repo.full_name }}" ]; then
echo "is_fork=true" >> $GITHUB_OUTPUT
echo "::notice::Fork PR detected - skipping auto-cleanup (manual removal required)"
else
echo "is_fork=false" >> $GITHUB_OUTPUT
fi
- uses: actions/checkout@v6
if: steps.check-fork.outputs.is_fork == 'false'
with:
ref: ${{ github.event.pull_request.head.ref }}
token: ${{ secrets.OPENHANDS_BOT_GITHUB_PAT_PUBLIC }}
- name: Remove .pr/ directory
id: remove
if: steps.check-fork.outputs.is_fork == 'false'
run: |
if [ -d ".pr" ]; then
git config user.name "allhands-bot"
git config user.email "allhands-bot@users.noreply.github.com"
git rm -rf .pr/
git commit -m "chore: Remove PR-only artifacts [automated]"
git push || {
echo "::error::Failed to push cleanup commit. Check branch protection rules."
exit 1
}
echo "removed=true" >> $GITHUB_OUTPUT
echo "::notice::Removed .pr/ directory"
else
echo "removed=false" >> $GITHUB_OUTPUT
echo "::notice::No .pr/ directory to remove"
fi
- name: Update PR comment after cleanup
if: steps.check-fork.outputs.is_fork == 'false' && steps.remove.outputs.removed == 'true'
uses: actions/github-script@v9
with:
script: |
const marker = '<!-- pr-artifacts-notice -->';
const body = `${marker}
✅ **PR Artifacts Cleaned Up**
The \`.pr/\` directory has been automatically removed.
`;
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find(c => c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body: body,
});
}
# Warn if .pr/ directory exists (will be auto-removed on approval)
check-pr-artifacts:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v6
- name: Check for .pr/ directory
id: check
run: |
if [ -d ".pr" ]; then
echo "exists=true" >> $GITHUB_OUTPUT
echo "::warning::.pr/ directory exists and will be automatically removed when the PR is approved. For fork PRs, manual removal is required before merging."
else
echo "exists=false" >> $GITHUB_OUTPUT
fi
- name: Post or update PR comment
if: steps.check.outputs.exists == 'true'
uses: actions/github-script@v9
with:
script: |
const marker = '<!-- pr-artifacts-notice -->';
const body = `${marker}
📁 **PR Artifacts Notice**
This PR contains a \`.pr/\` directory with PR-specific documents. This directory will be **automatically removed** when the PR is approved.
> For fork PRs: Manual removal is required before merging.
`;
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find(c => c.body.includes(marker));
if (!existing) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: body,
});
}

View File

@@ -44,5 +44,5 @@ jobs:
llm-base-url: https://llm-proxy.app.all-hands.dev
review-style: roasted
llm-api-key: ${{ secrets.LLM_API_KEY }}
github-token: ${{ secrets.OPENHANDS_BOT_GITHUB_PAT_PUBLIC }}
github-token: ${{ secrets.ALLHANDS_BOT_GITHUB_PAT }}
lmnr-api-key: ${{ secrets.LMNR_SKILLS_API_KEY }}

View File

@@ -28,7 +28,7 @@ jobs:
steps:
- name: Download review trace artifact
id: download-trace
uses: dawidd6/action-download-artifact@v15
uses: dawidd6/action-download-artifact@v6
continue-on-error: true
with:
workflow: pr-review-by-openhands.yml
@@ -51,7 +51,7 @@ jobs:
# Always checkout main branch for security - cannot test script changes in PRs
- name: Checkout extensions repository
if: steps.check-trace.outputs.trace_exists == 'true'
uses: actions/checkout@v6
uses: actions/checkout@v5
with:
repository: OpenHands/extensions
path: extensions
@@ -77,7 +77,7 @@ jobs:
--trace-file trace-info/laminar_trace_info.json
- name: Upload evaluation logs
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v5
if: always() && steps.check-trace.outputs.trace_exists == 'true'
with:
name: pr-review-evaluation-${{ github.event.pull_request.number }}

View File

@@ -19,7 +19,7 @@ jobs:
# Run python tests on Linux
test-on-linux:
name: Python Tests on Linux
runs-on: ubuntu-24.04
runs-on: blacksmith-4vcpu-ubuntu-2404
env:
INSTALL_DOCKER: "0" # Set to '0' to skip Docker installation
strategy:
@@ -30,22 +30,20 @@ jobs:
pull-requests: write
contents: write
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
- name: Install tmux
run: sudo apt-get update && sudo apt-get install -y tmux
- name: Setup Node.js
uses: actions/setup-node@v4
uses: useblacksmith/setup-node@v5
with:
node-version: "22.x"
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install poetry via pipx
run: pipx install poetry
- name: Set up Python
uses: actions/setup-python@v6
uses: useblacksmith/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
cache: "poetry"
@@ -60,8 +58,12 @@ jobs:
run: PYTHONPATH=".:$PYTHONPATH" poetry run pytest --forked -n auto -s ./tests/unit --cov=openhands --cov-branch
env:
COVERAGE_FILE: ".coverage.${{ matrix.python_version }}"
- name: Run Runtime Tests with CLIRuntime
run: PYTHONPATH=".:$PYTHONPATH" TEST_RUNTIME=cli poetry run pytest -n 5 --reruns 2 --reruns-delay 3 -s tests/runtime/test_bash.py --cov=openhands --cov-branch
env:
COVERAGE_FILE: ".coverage.runtime.${{ matrix.python_version }}"
- name: Store coverage file
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: coverage-openhands
path: |
@@ -71,16 +73,16 @@ jobs:
test-enterprise:
name: Enterprise Python Unit Tests
runs-on: ubuntu-24.04
runs-on: blacksmith-4vcpu-ubuntu-2404
strategy:
matrix:
python-version: ["3.12"]
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Install poetry via pipx
run: pipx install poetry
- name: Set up Python
uses: actions/setup-python@v6
uses: useblacksmith/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
cache: "poetry"
@@ -93,7 +95,7 @@ jobs:
env:
COVERAGE_FILE: ".coverage.enterprise.${{ matrix.python_version }}"
- name: Store coverage file
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: coverage-enterprise
path: ".coverage.enterprise.${{ matrix.python_version }}"
@@ -109,9 +111,9 @@ jobs:
pull-requests: write
contents: write
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- uses: actions/download-artifact@v8
- uses: actions/download-artifact@v6
id: download
with:
pattern: coverage-*

View File

@@ -17,14 +17,14 @@ on:
jobs:
release:
runs-on: ubuntu-22.04
# Run when manually dispatched for "app server" OR for tag pushes that don't contain '-cli' and don't start with 'cloud-'
runs-on: blacksmith-4vcpu-ubuntu-2204
# Run when manually dispatched for "app server" OR for tag pushes that don't contain '-cli'
if: |
(github.event_name == 'workflow_dispatch' && github.event.inputs.reason == 'app server')
|| (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && !contains(github.ref, '-cli') && !startsWith(github.ref, 'refs/tags/cloud-'))
|| (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && !contains(github.ref, '-cli'))
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
- uses: actions/checkout@v4
- uses: useblacksmith/setup-python@v6
with:
python-version: 3.12
- name: Install Poetry

View File

@@ -8,10 +8,10 @@ on:
jobs:
stale:
runs-on: ubuntu-22.04
runs-on: blacksmith-4vcpu-ubuntu-2204
if: github.repository == 'OpenHands/OpenHands'
steps:
- uses: actions/stale@v10
- uses: actions/stale@v9
with:
stale-issue-message: 'This issue is stale because it has been open for 40 days with no activity. Remove the stale label or leave a comment, otherwise it will be closed in 10 days.'
stale-pr-message: 'This PR is stale because it has been open for 40 days with no activity. Remove the stale label or leave a comment, otherwise it will be closed in 10 days.'

View File

@@ -1,59 +0,0 @@
# Adds a git-tag name to existing Docker images.
# Triggered when a tag is pushed: finds the images built at the tag's commit
# (tagged `sha-<full>`) and adds the tag name as an alias for the same manifest.
# Semver tags (X.Y.Z) also get X.Y, X, and latest aliases.
# No rebuild — pure registry-side retag via `docker buildx imagetools create`.
name: Tag Docker images
on:
push:
tags:
- "*"
jobs:
retag:
runs-on: ubuntu-22.04
permissions:
packages: write
strategy:
matrix:
image:
- ghcr.io/openhands/openhands
- ghcr.io/openhands/enterprise-server
steps:
- name: Login to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Compute tags
id: meta
uses: docker/metadata-action@v6
with:
images: ${{ matrix.image }}
flavor: latest=auto
tags: |
type=ref,event=tag
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
- name: Add tags to existing image
env:
SRC: ${{ matrix.image }}:sha-${{ github.sha }}
TAGS: ${{ steps.meta.outputs.tags }}
shell: bash
run: |
set -euo pipefail
if ! docker buildx imagetools inspect "$SRC" > /dev/null 2>&1; then
echo "::error::Source image $SRC does not exist. The Docker workflow for commit ${{ github.sha }} may not have completed successfully. Re-run this workflow once the build finishes."
exit 1
fi
args=()
while IFS= read -r tag; do
[[ -z "$tag" ]] && continue
args+=(-t "$tag")
done <<< "$TAGS"
docker buildx imagetools create "${args[@]}" "$SRC"

View File

@@ -19,10 +19,10 @@ concurrency:
jobs:
ui-build:
name: Build openhands-ui
runs-on: ubuntu-22.04
runs-on: blacksmith-4vcpu-ubuntu-2204
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version-file: "openhands-ui/.bun-version"

View File

@@ -14,7 +14,7 @@ jobs:
steps:
- name: Check if welcome comment already exists
id: check_comment
uses: actions/github-script@v9
uses: actions/github-script@v7
with:
result-encoding: string
script: |
@@ -33,7 +33,7 @@ jobs:
- name: Leave welcome comment
if: steps.check_comment.outputs.result == 'false'
uses: actions/github-script@v9
uses: actions/github-script@v7
with:
script: |
const repoUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}`;

4
.gitignore vendored
View File

@@ -254,6 +254,10 @@ run_instance_logs
runtime_*.tar
# docker build
containers/runtime/Dockerfile
containers/runtime/project.tar.gz
containers/runtime/code
**/node_modules/
# test results

View File

@@ -13,14 +13,6 @@ export RUNTIME=local
make build && make run FRONTEND_PORT=12000 FRONTEND_HOST=0.0.0.0 BACKEND_HOST=0.0.0.0 &> /tmp/openhands-log.txt &
```
Local run troubleshooting notes:
- If the backend fails with `nc: command not found`, install `netcat-openbsd`.
- If local runtime startup fails with `duplicate session: test-session`, clear the stale tmux session on the default socket: `tmux -S /tmp/tmux-$(id -u)/default kill-session -t test-session`.
- Local runtime browser startup expects Playwright browsers under `~/.cache/playwright`; if needed run `PLAYWRIGHT_BROWSERS_PATH=$HOME/.cache/playwright poetry run playwright install chromium`.
- In this sandbox environment, an inherited `SESSION_API_KEY` can make `/api/v1/settings` return 401 in the browser. Unset it before `make run` when you want to use the local web UI directly.
- In this sandbox, `frontend`'s `npm run dev:mock` / `dev:mock:saas` can start but still be awkward to browse through the work-host proxy. For PR QA screenshots, a reliable fallback is to `npm run build` with the desired `VITE_MOCK_*` env, then serve `build/` with a tiny custom HTTP server that returns the minimal mock JSON endpoints needed by the settings page.
IMPORTANT: Before making any changes to the codebase, ALWAYS run `make install-pre-commit-hooks` to ensure pre-commit hooks are properly installed.
Before pushing any changes, you MUST ensure that any lint errors or simple test errors have been fixed.
@@ -44,76 +36,6 @@ then re-run the command to ensure it passes. Common issues include:
- Be especially careful with `git reset --hard` after staging files, as it will remove accidentally staged files
- When remote has new changes, use `git fetch upstream && git rebase upstream/<branch>` on the same branch
## Lockfile Regeneration (Preserve Original Tool Versions)
When regenerating lockfiles (poetry.lock, uv.lock, etc.), you MUST use the same tool version that originally generated the lockfile to avoid unnecessary diff noise. Each lockfile contains a version header indicating which tool version was used.
### Poetry (poetry.lock)
1. Extract the version from the lockfile header:
```bash
POETRY_VERSION=$(grep -m1 "^# This file is automatically @generated by Poetry" poetry.lock | sed 's/.*Poetry \([0-9.]*\).*/\1/')
```
2. If a version is found, install that specific version:
```bash
pipx install poetry==$POETRY_VERSION --force
```
3. Then regenerate the lockfile:
```bash
poetry lock --no-update
```
### uv (uv.lock)
1. Extract the version from the lockfile header:
```bash
UV_VERSION=$(grep -m1 "^# This file was autogenerated by uv" uv.lock | sed 's/.*uv version \([0-9.]*\).*/\1/')
```
2. If a version is found, install that specific version:
```bash
pipx install uv==$UV_VERSION --force
```
3. Then regenerate the lockfile:
```bash
uv lock
```
This ensures that lockfile updates only contain actual dependency changes, not tool version migration artifacts.
## PR-Specific Artifacts (`.pr/` directory)
When working on a PR that requires design documents, scripts meant for development-only, or other temporary artifacts that should NOT be merged to main, store them in a `.pr/` directory at the repository root.
### Usage
```
.pr/
├── design.md # Design decisions and architecture notes
├── analysis.md # Investigation or debugging notes
├── logs/ # Test output or CI logs for reviewer reference
└── notes.md # Any other PR-specific content
```
### How It Works
1. **Notification**: When `.pr/` exists, a comment is posted to the PR conversation alerting reviewers
2. **Auto-cleanup**: When the PR is approved, the `.pr/` directory is automatically removed via `.github/workflows/pr-artifacts.yml`
3. **Fork PRs**: Auto-cleanup cannot push to forks, so manual removal is required before merging
### Important Notes
- Do NOT put anything in `.pr/` that needs to be preserved after merge
- The `.pr/` check passes (green ✅) during development — it only posts a notification, not a blocking error
- For fork PRs: You must manually remove `.pr/` before the PR can be merged
### When to Use
- Complex refactoring that benefits from written design rationale
- Debugging sessions where you want to document your investigation
- E2E test results or logs that demonstrate a cross-repo feature works
- Feature implementations that need temporary planning docs
- Any analysis that helps reviewers understand the PR but isn't needed long-term
## Repository Structure
Backend:
- Located in the `openhands` directory
@@ -146,8 +68,6 @@ Frontend:
- Query hooks should follow the pattern use[Resource] (e.g., `useConversationSkills`)
- Mutation hooks should follow the pattern use[Action] (e.g., `useDeleteConversation`)
- Architecture rule: UI components → TanStack Query hooks → Data Access Layer (`frontend/src/api`) → API endpoints
- For SaaS organization management screens, prefer deriving the selected organization from `useOrganizations()` plus the selected org ID store instead of adding a dedicated single-org fetch when only list-level fields (for example `name`) are needed.
VSCode Extension:
- Located in the `openhands/integrations/vscode` directory
@@ -236,7 +156,6 @@ Each integration follows a consistent pattern with service classes, storage mode
- Database changes require careful migration planning in `enterprise/migrations/`
- Always test changes in both OpenHands and enterprise contexts
- Use the enterprise-specific Makefile commands for development
- When the `openhands-ai` package (root project) version has been updated, run `poetry lock` in the `enterprise/` folder to update the version in the enterprise poetry lockfile.
**Enterprise Testing Best Practices:**

View File

@@ -36,6 +36,8 @@ Full details in our [Development Guide](./Development.md).
- **[Frontend](./frontend/README.md)** - React application
- **[App Server (V1)](./openhands/app_server/README.md)** - Current FastAPI application server and REST API modules
- **[Agents](./openhands/agenthub/README.md)** - AI agent implementations
- **[Runtime](./openhands/runtime/README.md)** - Execution environments
- **[Evaluation](https://github.com/OpenHands/benchmarks)** - Testing and benchmarks
## What Can You Build?
@@ -123,17 +125,6 @@ For example, a PR title could be:
- If your changes are user-facing (e.g. a new feature in the UI, a change in behavior, or a bugfix),
please include a short message that we can add to our changelog
## Becoming a Maintainer
For contributors who have made significant and sustained contributions to the project, there is a possibility of joining the maintainer team.
The process for this is as follows:
1. Any contributor who has made sustained and high-quality contributions to the codebase can be nominated by any maintainer. If you feel that you may qualify you can reach out to any of the maintainers that have reviewed your PRs and ask if you can be nominated.
2. Once a maintainer nominates a new maintainer, there will be a discussion period among the maintainers for at least 3 days.
3. If no concerns are raised the nomination will be accepted by acclamation, and if concerns are raised there will be a discussion and possible vote.
Note that just making many PRs does not immediately imply that you will become a maintainer. We will be looking at sustained high-quality contributions over a period of time, as well as good teamwork and adherence to our [Code of Conduct](./CODE_OF_CONDUCT.md).
## Need Help?
- **Slack**: [Join our community](https://openhands.dev/joinslack)

View File

@@ -16,7 +16,7 @@ open source community:
#### [Aider](https://github.com/paul-gauthier/aider)
- License: Apache License 2.0
- Description: AI pair programming tool. OpenHands has adapted and integrated its linter module for code-related tasks.
- Description: AI pair programming tool. OpenHands has adapted and integrated its linter module for code-related tasks in [`agentskills utilities`](https://github.com/OpenHands/OpenHands/tree/main/openhands/runtime/plugins/agent_skills/utils/aider)
#### [BrowserGym](https://github.com/ServiceNow/BrowserGym)
- License: Apache License 2.0

View File

@@ -309,6 +309,16 @@ poetry run pytest ./tests/unit/test_*.py
---
## Using Existing Docker Images
To reduce build time, you can use an existing runtime image:
```bash
export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:1.2-nikolaik
```
---
## Help
```bash
@@ -329,3 +339,4 @@ make help
- [/tests/unit/README.md](./tests/unit/README.md): Guide to writing and running unit tests
- [OpenHands/benchmarks](https://github.com/OpenHands/benchmarks): Documentation for the evaluation framework and benchmarks
- [/skills/README.md](./skills/README.md): Information about the skills architecture and implementation
- [/openhands/runtime/README.md](./openhands/runtime/README.md): Documentation for the runtime environment and execution model

View File

@@ -11,15 +11,7 @@ DEFAULT_WORKSPACE_DIR = "./workspace"
DEFAULT_MODEL = "gpt-4o"
CONFIG_FILE = config.toml
PRE_COMMIT_CONFIG_PATH = "./dev_config/python/.pre-commit-config.yaml"
PYTHON_MIN_VERSION = 3.12
PYTHON_MAX_VERSION = 3.14
PYTHON_CANDIDATES ?= python3.13 python3.12 python3
PYTHON ?= $(shell for cmd in $(PYTHON_CANDIDATES); do \
if command -v $$cmd > /dev/null 2>&1 && $$cmd -c 'import sys; raise SystemExit(0 if ((3, 12) <= sys.version_info[:2] < (3, 14)) else 1)' > /dev/null 2>&1; then \
echo $$cmd; \
exit 0; \
fi; \
done)
PYTHON_VERSION = 3.12
KIND_CLUSTER_NAME = "local-hands"
# ANSI color codes
@@ -71,10 +63,10 @@ check-system:
check-python:
@echo "$(YELLOW)Checking Python installation...$(RESET)"
@if [ -n "$(PYTHON)" ]; then \
echo "$(BLUE)$$($(PYTHON) --version) is already installed (using $(PYTHON)).$(RESET)"; \
@if command -v python$(PYTHON_VERSION) > /dev/null; then \
echo "$(BLUE)$(shell python$(PYTHON_VERSION) --version) is already installed.$(RESET)"; \
else \
echo "$(RED)A compatible Python interpreter (>= $(PYTHON_MIN_VERSION), < $(PYTHON_MAX_VERSION)) is required. Please install Python 3.12 or 3.13 to continue.$(RESET)"; \
echo "$(RED)Python $(PYTHON_VERSION) is not installed. Please install Python $(PYTHON_VERSION) to continue.$(RESET)"; \
exit 1; \
fi
@@ -126,34 +118,31 @@ check-tmux:
check-poetry:
@echo "$(YELLOW)Checking Poetry installation...$(RESET)"
@if [ -z "$(PYTHON)" ]; then \
echo "$(RED)A compatible Python interpreter (>= $(PYTHON_MIN_VERSION), < $(PYTHON_MAX_VERSION)) is required. Please install Python 3.12 or 3.13 to continue.$(RESET)"; \
exit 1; \
elif command -v poetry > /dev/null; then \
@if command -v poetry > /dev/null; then \
POETRY_VERSION=$(shell poetry --version 2>&1 | sed -E 's/Poetry \(version ([0-9]+\.[0-9]+\.[0-9]+)\)/\1/'); \
IFS='.' read -r -a POETRY_VERSION_ARRAY <<< "$$POETRY_VERSION"; \
if [ $${POETRY_VERSION_ARRAY[0]} -gt 1 ] || ([ $${POETRY_VERSION_ARRAY[0]} -eq 1 ] && [ $${POETRY_VERSION_ARRAY[1]} -ge 8 ]); then \
echo "$(BLUE)$(shell poetry --version) is already installed.$(RESET)"; \
else \
echo "$(RED)Poetry 1.8 or later is required. You can install poetry by running the following command, then adding Poetry to your PATH:"; \
echo "$(RED) curl -sSL https://install.python-poetry.org | $(PYTHON) -$(RESET)"; \
echo "$(RED) curl -sSL https://install.python-poetry.org | python$(PYTHON_VERSION) -$(RESET)"; \
echo "$(RED)More detail here: https://python-poetry.org/docs/#installing-with-the-official-installer$(RESET)"; \
exit 1; \
fi; \
else \
echo "$(RED)Poetry is not installed. You can install poetry by running the following command, then adding Poetry to your PATH:"; \
echo "$(RED) curl -sSL https://install.python-poetry.org | $(PYTHON) -$(RESET)"; \
echo "$(RED) curl -sSL https://install.python-poetry.org | python$(PYTHON_VERSION) -$(RESET)"; \
echo "$(RED)More detail here: https://python-poetry.org/docs/#installing-with-the-official-installer$(RESET)"; \
exit 1; \
fi
install-python-dependencies: check-python
install-python-dependencies:
@echo "$(GREEN)Installing Python dependencies...$(RESET)"
@if [ -z "${TZ}" ]; then \
echo "Defaulting TZ (timezone) to UTC"; \
export TZ="UTC"; \
fi
poetry env use $(PYTHON)
poetry env use python$(PYTHON_VERSION)
@if [ "$(shell uname)" = "Darwin" ]; then \
echo "$(BLUE)Installing chroma-hnswlib...$(RESET)"; \
export HNSWLIB_NO_NATIVE=1; \

View File

@@ -23,6 +23,7 @@
<a href="https://www.readme-i18n.com/OpenHands/OpenHands?lang=pt">Português</a> |
<a href="https://www.readme-i18n.com/OpenHands/OpenHands?lang=ru">Русский</a> |
<a href="https://www.readme-i18n.com/OpenHands/OpenHands?lang=zh">中文</a>
</div>
<hr>
@@ -83,71 +84,3 @@ All our work is available under the MIT license, except for the `enterprise/` di
The core `openhands` and `agent-server` Docker images are fully MIT-licensed as well.
If you need help with anything, or just want to chat, [come find us on Slack](https://dub.sh/openhands).
<hr>
### Thank You to Our Contributors
<div align="center">
[![OpenHands Contributors](https://assets.openhands.dev/readme/openhands-openhands-contributors.svg)](https://github.com/OpenHands/OpenHands/graphs/contributors)
</div>
<hr>
### Trusted by Engineers at
<div align="center">
<br/><br/>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/tiktok.svg">
<img src="https://assets.openhands.dev/logos/external/black/tiktok.svg" alt="TikTok" height="17" hspace="5">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/vmware.svg">
<img src="https://assets.openhands.dev/logos/external/black/vmware.svg" alt="VMware" height="17" hspace="5">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/roche.svg">
<img src="https://assets.openhands.dev/logos/external/black/roche.svg" alt="Roche" height="17" hspace="5">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/amazon.svg">
<img src="https://assets.openhands.dev/logos/external/black/amazon.svg" alt="Amazon" height="17" hspace="5">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/c3-ai.svg">
<img src="https://assets.openhands.dev/logos/external/black/c3-ai.svg" alt="C3 AI" height="17" hspace="5">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/netflix.svg">
<img src="https://assets.openhands.dev/logos/external/black/netflix.svg" alt="Netflix" height="17" hspace="5">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/mastercard.svg">
<img src="https://assets.openhands.dev/logos/external/black/mastercard.svg" alt="Mastercard" height="17" hspace="5">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/red-hat.svg">
<img src="https://assets.openhands.dev/logos/external/black/red-hat.svg" alt="Red Hat" height="17" hspace="5">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/mongodb.svg">
<img src="https://assets.openhands.dev/logos/external/black/mongodb.svg" alt="MongoDB" height="17" hspace="5">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/apple.svg">
<img src="https://assets.openhands.dev/logos/external/black/apple.svg" alt="Apple" height="17" hspace="5">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/nvidia.svg">
<img src="https://assets.openhands.dev/logos/external/black/nvidia.svg" alt="NVIDIA" height="17" hspace="5">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/google.svg">
<img src="https://assets.openhands.dev/logos/external/black/google.svg" alt="Google" height="17" hspace="5">
</picture>
</div>
</div>

View File

@@ -296,7 +296,7 @@ classpath = "my_package.my_module.MyCustomAgent"
#user_id = 1000
# Container image to use for the sandbox
#base_container_image = "nikolaik/python-nodejs:python3.12-nodejs22-slim"
#base_container_image = "nikolaik/python-nodejs:python3.12-nodejs22"
# Use host network
#use_host_network = false

View File

@@ -1,5 +1,5 @@
ARG OPENHANDS_BUILD_VERSION=dev
FROM node:25.9-trixie-slim AS frontend-builder
FROM node:25.2-trixie-slim AS frontend-builder
WORKDIR /app
@@ -20,11 +20,9 @@ ENV POETRY_NO_INTERACTION=1 \
POETRY_VIRTUALENVS_CREATE=1 \
POETRY_CACHE_DIR=/tmp/poetry_cache
# Pin Poetry version to match the version used to generate poetry.lock
ARG POETRY_VERSION=2.3.3
RUN apt-get update -y \
&& apt-get install -y curl make git build-essential jq gettext \
&& python3 -m pip install "poetry==${POETRY_VERSION}" --break-system-packages
&& python3 -m pip install poetry --break-system-packages
COPY pyproject.toml poetry.lock ./
RUN touch README.md
@@ -52,7 +50,7 @@ RUN mkdir -p $FILE_STORE_PATH
RUN mkdir -p $WORKSPACE_BASE
RUN apt-get update -y \
&& apt-get install -y curl git ssh sudo \
&& apt-get install -y curl ssh sudo \
&& rm -rf /var/lib/apt/lists/*
# Default is 1000, but OSX is often 501
@@ -75,21 +73,13 @@ ENV VIRTUAL_ENV=/app/.venv \
COPY --chown=openhands:openhands --chmod=770 --from=backend-builder ${VIRTUAL_ENV} ${VIRTUAL_ENV}
# Pin pip to a known-good version (reproducible builds) and fix CVE-2025-8869
# Pin both venv pip and system pip (Trivy scans both)
# - `python -m pip` uses the venv because `PATH` is prefixed with `${VIRTUAL_ENV}/bin`
# - `/usr/local/bin/python3 -m pip` uses the system interpreter regardless of `PATH`
ARG PIP_VERSION=26.0.1
RUN python -m pip install --no-cache-dir "pip==${PIP_VERSION}"
USER root
RUN /usr/local/bin/python3 -m pip install --no-cache-dir "pip==${PIP_VERSION}" --break-system-packages
USER openhands
COPY --chown=openhands:openhands --chmod=770 ./skills ./skills
COPY --chown=openhands:openhands --chmod=770 ./openhands ./openhands
COPY --chown=openhands:openhands --chmod=777 ./openhands/runtime/plugins ./openhands/runtime/plugins
COPY --chown=openhands:openhands pyproject.toml poetry.lock README.md MANIFEST.in LICENSE ./
# This is run as "openhands" user, and will create __pycache__ with openhands:openhands ownership
RUN python openhands/core/download.py # No-op to download assets
# Add this line to set group ownership of all files/directories not already in "app" group
# openhands:openhands -> openhands:openhands
RUN find /app \! -group openhands -exec chgrp openhands {} +

4
containers/app/config.sh Normal file
View File

@@ -0,0 +1,4 @@
DOCKER_REGISTRY=ghcr.io
DOCKER_ORG=openhands
DOCKER_IMAGE=openhands
DOCKER_BASE_DIR="."

View File

@@ -23,6 +23,18 @@ if [ -z "$WORKSPACE_MOUNT_PATH" ]; then
unset WORKSPACE_BASE
fi
if [[ "$INSTALL_THIRD_PARTY_RUNTIMES" == "true" ]]; then
echo "Downloading and installing third_party_runtimes..."
echo "Warning: Third-party runtimes are provided as-is, not actively supported and may be removed in future releases."
if pip install 'openhands-ai[third_party_runtimes]' -qqq 2> >(tee /dev/stderr); then
echo "third_party_runtimes installed successfully."
else
echo "Failed to install third_party_runtimes." >&2
exit 1
fi
fi
if [[ "$SANDBOX_USER_ID" -eq 0 ]]; then
echo "Running OpenHands as root"
export RUN_AS_OPENHANDS=false

182
containers/build.sh Executable file
View File

@@ -0,0 +1,182 @@
#!/usr/bin/env bash
set -eo pipefail
# Initialize variables with default values
image_name=""
org_name=""
push=0
load=0
tag_suffix=""
dry_run=0
# Function to display usage information
usage() {
echo "Usage: $0 -i <image_name> [-o <org_name>] [--push] [--load] [-t <tag_suffix>] [--dry]"
echo " -i: Image name (required)"
echo " -o: Organization name"
echo " --push: Push the image"
echo " --load: Load the image"
echo " -t: Tag suffix"
echo " --dry: Don't build, only create build-args.json"
exit 1
}
# Parse command-line options
while [[ $# -gt 0 ]]; do
case $1 in
-i) image_name="$2"; shift 2 ;;
-o) org_name="$2"; shift 2 ;;
--push) push=1; shift ;;
--load) load=1; shift ;;
-t) tag_suffix="$2"; shift 2 ;;
--dry) dry_run=1; shift ;;
*) usage ;;
esac
done
# Check if required arguments are provided
if [[ -z "$image_name" ]]; then
echo "Error: Image name is required."
usage
fi
echo "Building: $image_name"
tags=()
OPENHANDS_BUILD_VERSION="dev"
cache_tag_base="buildcache"
cache_tag="$cache_tag_base"
if [[ -n $RELEVANT_SHA ]]; then
git_hash=$(git rev-parse --short "$RELEVANT_SHA")
tags+=("$git_hash")
tags+=("$RELEVANT_SHA")
fi
if [[ -n $GITHUB_REF_NAME ]]; then
# check if ref name is a version number
if [[ $GITHUB_REF_NAME =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
major_version=$(echo "$GITHUB_REF_NAME" | cut -d. -f1)
minor_version=$(echo "$GITHUB_REF_NAME" | cut -d. -f1,2)
tags+=("$major_version" "$minor_version")
tags+=("latest")
fi
sanitized_ref_name=$(echo "$GITHUB_REF_NAME" | sed 's/[^a-zA-Z0-9.-]\+/-/g')
OPENHANDS_BUILD_VERSION=$sanitized_ref_name
sanitized_ref_name=$(echo "$sanitized_ref_name" | tr '[:upper:]' '[:lower:]') # lower case is required in tagging
tags+=("$sanitized_ref_name")
cache_tag+="-${sanitized_ref_name}"
fi
if [[ -n $tag_suffix ]]; then
cache_tag+="-${tag_suffix}"
for i in "${!tags[@]}"; do
tags[$i]="${tags[$i]}-$tag_suffix"
done
fi
echo "Tags: ${tags[@]}"
if [[ "$image_name" == "openhands" ]]; then
dir="./containers/app"
elif [[ "$image_name" == "runtime" ]]; then
dir="./containers/runtime"
else
dir="./containers/$image_name"
fi
if [[ (! -f "$dir/Dockerfile") && "$image_name" != "runtime" ]]; then
# Allow runtime to be built without a Dockerfile
echo "No Dockerfile found"
exit 1
fi
if [[ ! -f "$dir/config.sh" ]]; then
echo "No config.sh found for Dockerfile"
exit 1
fi
source "$dir/config.sh"
if [[ -n "$org_name" ]]; then
DOCKER_ORG="$org_name"
fi
# If $DOCKER_IMAGE_SOURCE_TAG is set, add it to the tags
if [[ -n "$DOCKER_IMAGE_SOURCE_TAG" ]]; then
tags+=("$DOCKER_IMAGE_SOURCE_TAG")
fi
# If $DOCKER_IMAGE_TAG is set, add it to the tags
if [[ -n "$DOCKER_IMAGE_TAG" ]]; then
tags+=("$DOCKER_IMAGE_TAG")
fi
DOCKER_REPOSITORY="$DOCKER_REGISTRY/$DOCKER_ORG/$DOCKER_IMAGE"
DOCKER_REPOSITORY=${DOCKER_REPOSITORY,,} # lowercase
echo "Repo: $DOCKER_REPOSITORY"
echo "Base dir: $DOCKER_BASE_DIR"
args=""
full_tags=()
for tag in "${tags[@]}"; do
args+=" -t $DOCKER_REPOSITORY:$tag"
full_tags+=("$DOCKER_REPOSITORY:$tag")
done
if [[ $push -eq 1 ]]; then
args+=" --push"
args+=" --cache-to=type=registry,ref=$DOCKER_REPOSITORY:$cache_tag,mode=max"
fi
if [[ $load -eq 1 ]]; then
args+=" --load"
fi
echo "Args: $args"
# Modify the platform selection based on --load flag
if [[ $load -eq 1 ]]; then
# When loading, build only for the current platform
platform=$(docker version -f '{{.Server.Os}}/{{.Server.Arch}}')
else
# For push or without load, build for multiple platforms
platform="linux/amd64,linux/arm64"
fi
if [[ $dry_run -eq 1 ]]; then
echo "Dry Run is enabled. Writing build config to docker-build-dry.json"
jq -n \
--argjson tags "$(printf '%s\n' "${full_tags[@]}" | jq -R . | jq -s .)" \
--arg platform "$platform" \
--arg openhands_build_version "$OPENHANDS_BUILD_VERSION" \
--arg dockerfile "$dir/Dockerfile" \
'{
tags: $tags,
platform: $platform,
build_args: [
"OPENHANDS_BUILD_VERSION=" + $openhands_build_version
],
dockerfile: $dockerfile
}' > docker-build-dry.json
exit 0
fi
echo "Building for platform(s): $platform"
docker buildx build \
$args \
--build-arg OPENHANDS_BUILD_VERSION="$OPENHANDS_BUILD_VERSION" \
--cache-from=type=registry,ref=$DOCKER_REPOSITORY:$cache_tag \
--cache-from=type=registry,ref=$DOCKER_REPOSITORY:$cache_tag_base-main \
--platform $platform \
--provenance=false \
-f "$dir/Dockerfile" \
"$DOCKER_BASE_DIR"
# If load was requested, print the loaded images
if [[ $load -eq 1 ]]; then
echo "Local images built:"
docker images "$DOCKER_REPOSITORY" --format "{{.Repository}}:{{.Tag}}"
fi

View File

@@ -13,7 +13,7 @@ services:
- DOCKER_HOST_ADDR=host.docker.internal
#
- AGENT_SERVER_IMAGE_REPOSITORY=${AGENT_SERVER_IMAGE_REPOSITORY:-ghcr.io/openhands/agent-server}
- AGENT_SERVER_IMAGE_TAG=${AGENT_SERVER_IMAGE_TAG:-1.15.0-python}
- AGENT_SERVER_IMAGE_TAG=${AGENT_SERVER_IMAGE_TAG:-1.12.0-python}
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:

View File

@@ -0,0 +1,12 @@
# Dynamically constructed Dockerfile
This folder builds a runtime image (sandbox), which will use a dynamically generated `Dockerfile`
that depends on the `base_image` **AND** a [Python source distribution](https://docs.python.org/3.10/distutils/sourcedist.html) that is based on the current commit of `openhands`.
The following command will generate a `Dockerfile` file for `nikolaik/python-nodejs:python3.12-nodejs22` (the default base image), an updated `config.sh` and the runtime source distribution files/folders into `containers/runtime`:
```bash
poetry run python3 -m openhands.runtime.utils.runtime_build \
--base_image nikolaik/python-nodejs:python3.12-nodejs22 \
--build_folder containers/runtime
```

View File

@@ -0,0 +1,7 @@
DOCKER_REGISTRY=ghcr.io
DOCKER_ORG=openhands
DOCKER_BASE_DIR="./containers/runtime"
DOCKER_IMAGE=runtime
# These variables will be appended by the runtime_build.py script
# DOCKER_IMAGE_TAG=
# DOCKER_IMAGE_SOURCE_TAG=

View File

@@ -3,9 +3,9 @@ repos:
rev: v5.0.0
hooks:
- id: trailing-whitespace
exclude: ^(docs/|modules/|python/|openhands-ui/|enterprise/)
exclude: ^(docs/|modules/|python/|openhands-ui/|third_party/|enterprise/)
- id: end-of-file-fixer
exclude: ^(docs/|modules/|python/|openhands-ui/|enterprise/)
exclude: ^(docs/|modules/|python/|openhands-ui/|third_party/|enterprise/)
- id: check-yaml
args: ["--allow-multiple-documents"]
- id: debug-statements
@@ -37,12 +37,12 @@ repos:
entry: ruff check --config dev_config/python/ruff.toml
types_or: [python, pyi, jupyter]
args: [--fix, --unsafe-fixes]
exclude: ^(enterprise/)
exclude: ^(third_party/|enterprise/)
# Run the formatter.
- id: ruff-format
entry: ruff format --config dev_config/python/ruff.toml
types_or: [python, pyi, jupyter]
exclude: ^(enterprise/)
exclude: ^(third_party/|enterprise/)
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.15.0
@@ -58,9 +58,6 @@ repos:
types-Markdown,
pydantic,
lxml,
"openhands-sdk==1.17.0",
"openhands-tools==1.17.0",
"sqlalchemy>=2.0",
]
# To see gaps add `--html-report mypy-report/`
entry: mypy --config-file dev_config/python/mypy.ini openhands/

View File

@@ -10,12 +10,7 @@ strict_optional = True
disable_error_code = type-abstract
# Exclude third-party runtime directory from type checking
exclude = (enterprise/)
exclude = (third_party/|enterprise/)
[mypy-openai.*]
follow_imports = skip
ignore_missing_imports = True
[mypy-litellm.*]
follow_imports = skip
ignore_missing_imports = True
[mypy-openhands.memory.condenser.impl.*]
disable_error_code = override

View File

@@ -1,5 +1,5 @@
# Exclude third-party runtime directory from linting
exclude = ["enterprise/"]
exclude = ["third_party/", "enterprise/"]
[lint]
select = [

View File

@@ -8,7 +8,7 @@ services:
container_name: openhands-app-${DATE:-}
environment:
- AGENT_SERVER_IMAGE_REPOSITORY=${AGENT_SERVER_IMAGE_REPOSITORY:-ghcr.io/openhands/agent-server}
- AGENT_SERVER_IMAGE_TAG=${AGENT_SERVER_IMAGE_TAG:-1.15.0-python}
- AGENT_SERVER_IMAGE_TAG=${AGENT_SERVER_IMAGE_TAG:-1.12.0-python}
#- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} # enable this only if you want a specific non-root sandbox user but you will have to manually adjust permissions of ~/.openhands for this user
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:

View File

@@ -10,7 +10,7 @@ LABEL com.datadoghq.tags.env="${DD_ENV}"
# Apply security updates to fix CVEs
RUN apt-get update && \
apt-get install -y curl && \
curl -fsSL https://deb.nodesource.com/setup_24.x | bash - && \
curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
apt-get install -y nodejs && \
apt-get install -y jq gettext && \
# Apply security updates for packages with available fixes

View File

@@ -1,7 +1,5 @@
# PolyForm Free Trial License 1.0.0
Copyright (c) 2026 All Hands AI
## Acceptance
In order to get any license under these terms, you must agree

View File

@@ -59,7 +59,7 @@ handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
level = DEBUG
handlers =
qualname = sqlalchemy.engine

View File

@@ -723,15 +723,11 @@
"https://$WEB_HOST/slack/keycloak-callback",
"https://$WEB_HOST/oauth/device/keycloak-callback",
"https://$WEB_HOST/api/email/verified",
"/realms/$KEYCLOAK_REALM_NAME/$KEYCLOAK_CLIENT_ID/*",
"https://laminar.$WEB_HOST/api/auth/callback/keycloak",
"https://analytics.$WEB_HOST/api/auth/callback/keycloak"
"/realms/$KEYCLOAK_REALM_NAME/$KEYCLOAK_CLIENT_ID/*"
],
"webOrigins": [
"https://$WEB_HOST",
"https://$AUTH_WEB_HOST",
"https://laminar.$WEB_HOST",
"https://analytics.$WEB_HOST"
"https://$AUTH_WEB_HOST"
],
"notBefore": 0,
"bearerOnly": false,

View File

@@ -50,7 +50,6 @@ repos:
- ./
- stripe==11.5.0
- pygithub==2.6.1
- sqlalchemy>=2.0
# Use -p (package) to avoid dual module name conflict when using MYPYPATH
# MYPYPATH=enterprise allows resolving bare imports like "from integrations.xxx"
# Note: tests package excluded to avoid conflict with core openhands tests

View File

@@ -61,6 +61,13 @@ export LITE_LLM_API_KEY=<your LLM API key>
python enterprise_local/convert_to_env.py
```
You'll also need to set up the runtime image, so that the dev server doesn't try to rebuild it.
```
export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:main-nikolaik
docker pull $SANDBOX_RUNTIME_CONTAINER_IMAGE
```
By default the application will log in json, you can override.
```
@@ -196,6 +203,7 @@ And then invoking `printenv`. NOTE: _DO NOT DO THIS WITH PROD!!!_ (Hopefully by
"REDIS_HOST": "localhost:6379",
"OPENHANDS": "<YOUR LOCAL OPENHANDS DIR>",
"FRONTEND_DIRECTORY": "<YOUR LOCAL OPENHANDS DIR>/frontend/build",
"SANDBOX_RUNTIME_CONTAINER_IMAGE": "ghcr.io/openhands/runtime:main-nikolaik",
"FILE_STORE_PATH": "<YOUR HOME DIRECTORY>>/.openhands-state",
"OPENHANDS_CONFIG_CLS": "server.config.SaaSServerConfig",
"GITHUB_APP_ID": "1062351",
@@ -229,6 +237,7 @@ And then invoking `printenv`. NOTE: _DO NOT DO THIS WITH PROD!!!_ (Hopefully by
"REDIS_HOST": "localhost:6379",
"OPENHANDS": "<YOUR LOCAL OPENHANDS DIR>",
"FRONTEND_DIRECTORY": "<YOUR LOCAL OPENHANDS DIR>/frontend/build",
"SANDBOX_RUNTIME_CONTAINER_IMAGE": "ghcr.io/openhands/runtime:main-nikolaik",
"FILE_STORE_PATH": "<YOUR HOME DIRECTORY>>/.openhands-state",
"OPENHANDS_CONFIG_CLS": "server.config.SaaSServerConfig",
"GITHUB_APP_ID": "1062351",

View File

@@ -429,11 +429,6 @@ class GitHubDataCollector:
- Num openhands review comments
"""
pr_number = openhands_pr.pr_number
if openhands_pr.installation_id is None:
logger.warning(
f'Skipping PR {openhands_pr.repo_name}#{pr_number}: missing installation_id'
)
return
installation_id = int(openhands_pr.installation_id)
repo_id = openhands_pr.repo_id

View File

@@ -32,6 +32,7 @@ from pydantic import SecretStr
from server.auth.auth_error import ExpiredError
from server.auth.constants import GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY
from server.auth.token_manager import TokenManager
from server.utils.conversation_callback_utils import register_callback_processor
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.provider import ProviderToken, ProviderType
@@ -317,12 +318,17 @@ class GithubManager(Manager[GithubViewType]):
return
async def start_job(self, github_view: GithubViewType) -> None:
"""Kick off a job with openhands agent using V1 app conversation system.
"""Kick off a job with openhands agent.
1. Get user credential
2. Initialize new conversation with repo
3. Save interaction data
"""
# Importing here prevents circular import
from server.conversation_callback_processor.github_callback_processor import (
GithubCallbackProcessor,
)
try:
msg_info: str = ''
@@ -396,7 +402,19 @@ class GithubManager(Manager[GithubViewType]):
f'[GitHub] Created conversation {conversation_id} for user {user_info.username}'
)
# V1 callback processors are registered by the view during conversation creation
if not github_view.v1_enabled:
# Create a GithubCallbackProcessor
processor = GithubCallbackProcessor(
github_view=github_view,
send_summary_instruction=True,
)
# Register the callback processor
register_callback_processor(conversation_id, processor)
logger.info(
f'[Github] Registered callback processor for conversation {conversation_id}'
)
# Send message with conversation link
conversation_link = CONVERSATION_URL.format(conversation_id)

View File

@@ -106,18 +106,16 @@ async def summarize_issue_solvability(
f'Solvability analysis disabled for user {github_view.user_info.user_id}'
)
agent_settings = user_settings.agent_settings
llm_settings = agent_settings.llm
if llm_settings.api_key is None:
if user_settings.llm_api_key is None:
raise ValueError(
f'[Solvability] No LLM API key found for user {github_view.user_info.user_id}'
)
try:
llm_config = LLMConfig(
model=llm_settings.model,
api_key=llm_settings.api_key.get_secret_value(),
base_url=llm_settings.base_url,
model=user_settings.llm_model,
api_key=user_settings.llm_api_key.get_secret_value(),
base_url=user_settings.llm_base_url,
)
except ValidationError as e:
raise ValueError(

View File

@@ -43,20 +43,15 @@ class GithubV1CallbackProcessor(EventCallbackProcessor):
event: Event,
) -> EventCallbackResult | None:
"""Process events for GitHub V1 integration."""
# Only handle ConversationStateUpdateEvent for execution_status
# Only handle ConversationStateUpdateEvent
if not isinstance(event, ConversationStateUpdateEvent):
return None
if event.key != 'execution_status':
# Only act when execution has finished
if not (event.key == 'execution_status' and event.value == 'finished'):
return None
# Log ALL terminal states for monitoring (finished, error, stuck)
_logger.info('[GitHub V1] Callback agent state was %s', event)
# Only request summary when execution has finished successfully
if event.value != 'finished':
return None
_logger.info(
'[GitHub V1] Should request summary: %s', self.should_request_summary
)

View File

@@ -10,7 +10,6 @@ from integrations.github.github_types import (
)
from integrations.models import Message
from integrations.resolver_context import ResolverUserContext
from integrations.resolver_org_router import resolve_org_for_repo
from integrations.types import ResolverViewInterface, UserData
from integrations.utils import (
ENABLE_PROACTIVE_CONVERSATION_STARTERS,
@@ -27,7 +26,6 @@ from server.auth.token_manager import TokenManager
from server.config import get_config
from storage.org_store import OrgStore
from storage.proactive_conversation_store import ProactiveConversationStore
from storage.saas_conversation_store import SaasConversationStore
from storage.saas_secrets_store import SaasSecretsStore
from openhands.agent_server.models import SendMessageRequest
@@ -43,13 +41,16 @@ from openhands.integrations.github.github_service import GithubServiceImpl
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderType
from openhands.integrations.service_types import Comment
from openhands.sdk import TextContent
from openhands.server.services.conversation_service import (
initialize_conversation,
start_conversation,
)
from openhands.server.user_auth.user_auth import UserAuth
from openhands.storage.data_models.conversation_metadata import (
ConversationMetadata,
ConversationTrigger,
)
from openhands.utils.async_utils import call_sync_from_async
from openhands.utils.conversation_summary import get_default_conversation_title
OH_LABEL, INLINE_OH_LABEL = get_oh_labels(HOST)
@@ -153,17 +154,12 @@ class GithubIssue(ResolverViewInterface):
return user_secrets.custom_secrets if user_secrets else None
async def initialize_new_conversation(self) -> ConversationMetadata:
# FIXME: Handle if initialize_conversation returns None
self.v1_enabled = await is_v1_enabled_for_github_resolver(
self.user_info.keycloak_user_id
)
# Resolve target org based on claimed git organizations
self.resolved_org_id = await resolve_org_for_repo(
provider='github',
full_repo_name=self.full_repo_name,
keycloak_user_id=self.user_info.keycloak_user_id,
)
logger.info(
f'[GitHub V1]: User flag found for {self.user_info.keycloak_user_id} is {self.v1_enabled}'
)
@@ -177,28 +173,16 @@ class GithubIssue(ResolverViewInterface):
selected_repository=self.full_repo_name,
)
# Create the conversation store with resolver org routing
# (bypasses initialize_conversation to avoid threading enterprise-only
# resolver_org_id through the generic OSS interface)
store = await SaasConversationStore.get_resolver_instance(
get_config(),
self.user_info.keycloak_user_id,
self.resolved_org_id,
)
conversation_id = uuid4().hex
conversation_metadata = ConversationMetadata(
trigger=ConversationTrigger.RESOLVER,
conversation_id=conversation_id,
title=get_default_conversation_title(conversation_id),
conversation_metadata: ConversationMetadata = await initialize_conversation( # type: ignore[assignment]
user_id=self.user_info.keycloak_user_id,
conversation_id=None,
selected_repository=self.full_repo_name,
selected_branch=self._get_branch_name(),
conversation_trigger=ConversationTrigger.RESOLVER,
git_provider=ProviderType.GITHUB,
)
await store.save_metadata(conversation_metadata)
self.conversation_id = conversation_id
self.conversation_id = conversation_metadata.conversation_id
return conversation_metadata
async def create_new_conversation(
@@ -208,9 +192,43 @@ class GithubIssue(ResolverViewInterface):
conversation_metadata: ConversationMetadata,
saas_user_auth: UserAuth,
):
# V0 conversation path has been removed - all conversations use V1 app conversation service
await self._create_v1_conversation(
jinja_env, saas_user_auth, conversation_metadata
logger.info(
f'[GitHub V1]: User flag found for {self.user_info.keycloak_user_id} is {self.v1_enabled}'
)
if self.v1_enabled:
# Use V1 app conversation service
await self._create_v1_conversation(
jinja_env, saas_user_auth, conversation_metadata
)
else:
await self._create_v0_conversation(
jinja_env, git_provider_tokens, conversation_metadata
)
async def _create_v0_conversation(
self,
jinja_env: Environment,
git_provider_tokens: PROVIDER_TOKEN_TYPE,
conversation_metadata: ConversationMetadata,
):
"""Create conversation using the legacy V0 system."""
logger.info('[GitHub]: Creating V0 conversation')
custom_secrets = await self._get_user_secrets()
user_instructions, conversation_instructions = await self._get_instructions(
jinja_env
)
await start_conversation(
user_id=self.user_info.keycloak_user_id,
git_provider_tokens=git_provider_tokens,
custom_secrets=custom_secrets,
initial_user_msg=user_instructions,
image_urls=None,
replay_json=None,
conversation_id=conversation_metadata.conversation_id,
conversation_metadata=conversation_metadata,
conversation_instructions=conversation_instructions,
)
async def _get_v1_initial_user_message(self, jinja_env: Environment) -> str:
@@ -223,6 +241,7 @@ class GithubIssue(ResolverViewInterface):
comments, inline review comments) override this method to control ordering
(e.g., context first, then the triggering comment, then previous comments).
"""
user_instructions, conversation_instructions = await self._get_instructions(
jinja_env
)
@@ -275,10 +294,7 @@ class GithubIssue(ResolverViewInterface):
)
# Set up the GitHub user context for the V1 system
github_user_context = ResolverUserContext(
saas_user_auth=saas_user_auth,
resolver_org_id=self.resolved_org_id,
)
github_user_context = ResolverUserContext(saas_user_auth=saas_user_auth)
setattr(injector_state, USER_CONTEXT_ATTR, github_user_context)
async with get_app_conversation_service(
@@ -306,7 +322,7 @@ class GithubIssue(ResolverViewInterface):
'full_repo_name': self.full_repo_name,
'installation_id': self.installation_id,
},
should_request_summary=self.send_summary_instruction,
send_summary_instruction=self.send_summary_instruction,
)
@@ -460,7 +476,7 @@ class GithubInlinePRComment(GithubPRComment):
'comment_id': self.comment_id,
},
inline_pr_comment=True,
should_request_summary=self.send_summary_instruction,
send_summary_instruction=self.send_summary_instruction,
)

View File

@@ -24,6 +24,7 @@ from integrations.v1_utils import get_saas_user_auth
from jinja2 import Environment, FileSystemLoader
from pydantic import SecretStr
from server.auth.token_manager import TokenManager
from server.utils.conversation_callback_utils import register_callback_processor
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.gitlab.gitlab_service import GitLabServiceImpl
@@ -170,11 +171,17 @@ class GitlabManager(Manager[GitlabViewType]):
)
async def start_job(self, gitlab_view: GitlabViewType) -> None:
"""Start a job for the GitLab view using V1 app conversation system.
"""
Start a job for the GitLab view.
Args:
gitlab_view: The GitLab view object containing issue/PR/comment info
"""
# Importing here prevents circular import
from server.conversation_callback_processor.gitlab_callback_processor import (
GitlabCallbackProcessor,
)
try:
try:
user_info = gitlab_view.user_info
@@ -228,7 +235,19 @@ class GitlabManager(Manager[GitlabViewType]):
f'[GitLab] Created conversation {conversation_id} for user {user_info.username}'
)
# V1 callback processors are registered by the view during conversation creation
if not gitlab_view.v1_enabled:
# Create a GitlabCallbackProcessor for this conversation
processor = GitlabCallbackProcessor(
gitlab_view=gitlab_view,
send_summary_instruction=True,
)
# Register the callback processor
register_callback_processor(conversation_id, processor)
logger.info(
f'[GitLab] Created callback processor for conversation {conversation_id}'
)
conversation_link = CONVERSATION_URL.format(conversation_id)
msg_info = f"I'm on it! {user_info.username} can [track my progress at all-hands.dev]({conversation_link})"

View File

@@ -41,20 +41,15 @@ class GitlabV1CallbackProcessor(EventCallbackProcessor):
event: Event,
) -> EventCallbackResult | None:
"""Process events for GitLab V1 integration."""
# Only handle ConversationStateUpdateEvent for execution_status
# Only handle ConversationStateUpdateEvent
if not isinstance(event, ConversationStateUpdateEvent):
return None
if event.key != 'execution_status':
# Only act when execution has finished
if not (event.key == 'execution_status' and event.value == 'finished'):
return None
# Log ALL terminal states for monitoring (finished, error, stuck)
_logger.info('[GitLab V1] Callback agent state was %s', event)
# Only request summary when execution has finished successfully
if event.value != 'finished':
return None
_logger.info(
'[GitLab V1] Should request summary: %s', self.should_request_summary
)

View File

@@ -3,7 +3,6 @@ from uuid import UUID, uuid4
from integrations.models import Message
from integrations.resolver_context import ResolverUserContext
from integrations.resolver_org_router import resolve_org_for_repo
from integrations.types import ResolverViewInterface, UserData
from integrations.utils import (
ENABLE_V1_GITLAB_RESOLVER,
@@ -15,7 +14,6 @@ from integrations.utils import (
from jinja2 import Environment
from server.auth.token_manager import TokenManager
from server.config import get_config
from storage.saas_conversation_store import SaasConversationStore
from storage.saas_secrets_store import SaasSecretsStore
from openhands.agent_server.models import SendMessageRequest
@@ -31,12 +29,15 @@ from openhands.integrations.gitlab.gitlab_service import GitLabServiceImpl
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderType
from openhands.integrations.service_types import Comment
from openhands.sdk import TextContent
from openhands.server.services.conversation_service import (
initialize_conversation,
start_conversation,
)
from openhands.server.user_auth.user_auth import UserAuth
from openhands.storage.data_models.conversation_metadata import (
ConversationMetadata,
ConversationTrigger,
)
from openhands.utils.conversation_summary import get_default_conversation_title
OH_LABEL, INLINE_OH_LABEL = get_oh_labels(HOST)
CONFIDENTIAL_NOTE = 'confidential_note'
@@ -117,14 +118,6 @@ class GitlabIssue(ResolverViewInterface):
async def initialize_new_conversation(self) -> ConversationMetadata:
# v1_enabled is already set at construction time in the factory method
# This is the source of truth for the conversation type
# Resolve target org based on claimed git organizations
self.resolved_org_id = await resolve_org_for_repo(
provider='gitlab',
full_repo_name=self.full_repo_name,
keycloak_user_id=self.user_info.keycloak_user_id,
)
if self.v1_enabled:
# Create dummy conversation metadata
# Don't save to conversation store
@@ -135,28 +128,16 @@ class GitlabIssue(ResolverViewInterface):
selected_repository=self.full_repo_name,
)
# Create the conversation store with resolver org routing
# (bypasses initialize_conversation to avoid threading enterprise-only
# resolver_org_id through the generic OSS interface)
store = await SaasConversationStore.get_resolver_instance(
get_config(),
self.user_info.keycloak_user_id,
self.resolved_org_id,
)
conversation_id = uuid4().hex
conversation_metadata = ConversationMetadata(
trigger=ConversationTrigger.RESOLVER,
conversation_id=conversation_id,
title=get_default_conversation_title(conversation_id),
conversation_metadata: ConversationMetadata = await initialize_conversation( # type: ignore[assignment]
user_id=self.user_info.keycloak_user_id,
conversation_id=None,
selected_repository=self.full_repo_name,
selected_branch=self._get_branch_name(),
conversation_trigger=ConversationTrigger.RESOLVER,
git_provider=ProviderType.GITLAB,
)
await store.save_metadata(conversation_metadata)
self.conversation_id = conversation_id
self.conversation_id = conversation_metadata.conversation_id
return conversation_metadata
async def create_new_conversation(
@@ -166,9 +147,41 @@ class GitlabIssue(ResolverViewInterface):
conversation_metadata: ConversationMetadata,
saas_user_auth: UserAuth,
):
# V0 conversation path has been removed - all conversations use V1 app conversation service
await self._create_v1_conversation(
jinja_env, saas_user_auth, conversation_metadata
# v1_enabled is already set at construction time in the factory method
if self.v1_enabled:
# Use V1 app conversation service
await self._create_v1_conversation(
jinja_env, saas_user_auth, conversation_metadata
)
else:
await self._create_v0_conversation(
jinja_env, git_provider_tokens, conversation_metadata
)
async def _create_v0_conversation(
self,
jinja_env: Environment,
git_provider_tokens: PROVIDER_TOKEN_TYPE,
conversation_metadata: ConversationMetadata,
):
"""Create conversation using the legacy V0 system."""
logger.info('[GitLab]: Creating V0 conversation')
custom_secrets = await self._get_user_secrets()
user_instructions, conversation_instructions = await self._get_instructions(
jinja_env
)
await start_conversation(
user_id=self.user_info.keycloak_user_id,
git_provider_tokens=git_provider_tokens,
custom_secrets=custom_secrets,
initial_user_msg=user_instructions,
image_urls=None,
replay_json=None,
conversation_id=conversation_metadata.conversation_id,
conversation_metadata=conversation_metadata,
conversation_instructions=conversation_instructions,
)
async def _create_v1_conversation(
@@ -215,10 +228,7 @@ class GitlabIssue(ResolverViewInterface):
)
# Set up the GitLab user context for the V1 system
gitlab_user_context = ResolverUserContext(
saas_user_auth=saas_user_auth,
resolver_org_id=self.resolved_org_id,
)
gitlab_user_context = ResolverUserContext(saas_user_auth=saas_user_auth)
setattr(injector_state, USER_CONTEXT_ATTR, gitlab_user_context)
async with get_app_conversation_service(
@@ -250,7 +260,7 @@ class GitlabIssue(ResolverViewInterface):
'is_mr': self.is_mr,
'discussion_id': getattr(self, 'discussion_id', None),
},
should_request_summary=self.send_summary_instruction,
send_summary_instruction=self.send_summary_instruction,
)

View File

@@ -24,20 +24,20 @@ from integrations.jira.jira_types import (
RepositoryNotFoundError,
StartingConvoException,
)
from integrations.jira.jira_view import JiraFactory
from integrations.jira.jira_view import JiraFactory, JiraNewConversationView
from integrations.manager import Manager
from integrations.models import Message
from integrations.utils import (
HOST,
HOST_URL,
OPENHANDS_RESOLVER_TEMPLATES_DIR,
format_jira_comment_body,
get_oh_labels,
get_session_expired_message,
)
from jinja2 import Environment, FileSystemLoader
from server.auth.saas_user_auth import get_user_auth_from_keycloak_id
from server.auth.token_manager import TokenManager
from server.utils.conversation_callback_utils import register_callback_processor
from storage.jira_integration_store import JiraIntegrationStore
from storage.jira_user import JiraUser
from storage.jira_workspace import JiraWorkspace
@@ -259,6 +259,11 @@ class JiraManager(Manager[JiraViewInterface]):
async def start_job(self, view: JiraViewInterface) -> None:
"""Start a Jira job/conversation."""
# Import here to prevent circular import
from server.conversation_callback_processor.jira_callback_processor import (
JiraCallbackProcessor,
)
try:
logger.info(
'[Jira] Starting job',
@@ -280,7 +285,19 @@ class JiraManager(Manager[JiraViewInterface]):
},
)
# Create success message
# Register callback processor for updates
if isinstance(view, JiraNewConversationView):
processor = JiraCallbackProcessor(
issue_key=view.payload.issue_key,
workspace_name=view.jira_workspace.name,
)
register_callback_processor(conversation_id, processor)
logger.info(
'[Jira] Callback processor registered',
extra={'conversation_id': conversation_id},
)
# Send success response
msg_info = view.get_response_msg()
except MissingSettingsError as e:
@@ -342,7 +359,7 @@ class JiraManager(Manager[JiraViewInterface]):
url = (
f'{JIRA_CLOUD_API_URL}/{jira_cloud_id}/rest/api/2/issue/{issue_key}/comment'
)
data = format_jira_comment_body(message)
data = {'body': message}
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
response = await client.post(
url, auth=(svc_acc_email, svc_acc_api_key), json=data

View File

@@ -136,10 +136,11 @@ class JiraPayloadParser:
items = changelog.get('items', [])
# Extract labels that were added
labels = set()
for item in items:
if item.get('field') == 'labels' and item.get('toString'):
labels.update(item['toString'].split())
labels = [
item.get('toString', '')
for item in items
if item.get('field') == 'labels' and 'toString' in item
]
if self.oh_label not in labels:
return JiraPayloadSkipped(

View File

@@ -1,238 +0,0 @@
import logging
from uuid import UUID
import httpx
from integrations.utils import format_jira_comment_body, get_summary_instruction
from pydantic import Field
from openhands.agent_server.models import AskAgentRequest, AskAgentResponse
from openhands.app_server.event_callback.event_callback_models import (
EventCallback,
EventCallbackProcessor,
)
from openhands.app_server.event_callback.event_callback_result_models import (
EventCallbackResult,
EventCallbackResultStatus,
)
from openhands.app_server.event_callback.util import (
ensure_conversation_found,
ensure_running_sandbox,
get_agent_server_url_from_sandbox,
)
from openhands.sdk import Event
from openhands.sdk.event import ConversationStateUpdateEvent
from openhands.utils.http_session import httpx_verify_option
_logger = logging.getLogger(__name__)
JIRA_CLOUD_API_URL = 'https://api.atlassian.com/ex/jira'
class JiraV1CallbackProcessor(EventCallbackProcessor):
"""Callback processor for Jira V1 integrations."""
should_request_summary: bool = Field(default=True)
svc_acc_email: str
decrypted_api_key: str
issue_key: str
jira_cloud_id: str
async def __call__(
self,
conversation_id: UUID,
callback: EventCallback,
event: Event,
) -> EventCallbackResult | None:
"""Process events for Jira V1 integration."""
# Only handle ConversationStateUpdateEvent for execution_status
if not isinstance(event, ConversationStateUpdateEvent):
return None
if event.key != 'execution_status':
return None
_logger.info('[Jira] Callback agent state was %s', event)
# Only request summary when execution has finished successfully
if event.value != 'finished':
return None
_logger.info('[Jira] Should request summary: %s', self.should_request_summary)
if not self.should_request_summary:
return None
self.should_request_summary = False
try:
_logger.info(f'[Jira] Requesting summary {conversation_id}')
summary = await self._request_summary(conversation_id)
_logger.info(
f'[Jira] Posting summary {conversation_id}',
extra={'summary': summary},
)
await self._post_summary_to_jira(summary)
return EventCallbackResult(
status=EventCallbackResultStatus.SUCCESS,
event_callback_id=callback.id,
event_id=event.id,
conversation_id=conversation_id,
detail=summary,
)
except Exception as e:
_logger.exception(f'[Jira] Failed to post summary: {e}', stack_info=True)
return EventCallbackResult(
status=EventCallbackResultStatus.ERROR,
event_callback_id=callback.id,
event_id=event.id,
conversation_id=conversation_id,
detail=str(e),
)
async def _request_summary(self, conversation_id: UUID) -> str:
"""Ask the agent to produce a summary of its work and return the agent response."""
# Import services within the method to avoid circular imports
from openhands.app_server.config import (
get_app_conversation_info_service,
get_httpx_client,
get_sandbox_service,
)
from openhands.app_server.services.injector import InjectorState
from openhands.app_server.user.specifiy_user_context import (
ADMIN,
USER_CONTEXT_ATTR,
)
# Create injector state for dependency injection
state = InjectorState()
setattr(state, USER_CONTEXT_ATTR, ADMIN)
async with (
get_app_conversation_info_service(state) as app_conversation_info_service,
get_sandbox_service(state) as sandbox_service,
get_httpx_client(state) as httpx_client,
):
# 1. Conversation lookup
app_conversation_info = ensure_conversation_found(
await app_conversation_info_service.get_app_conversation_info(
conversation_id
),
conversation_id,
)
# 2. Sandbox lookup + validation
sandbox = ensure_running_sandbox(
await sandbox_service.get_sandbox(app_conversation_info.sandbox_id),
app_conversation_info.sandbox_id,
)
assert (
sandbox.session_api_key is not None
), f'No session API key for sandbox: {sandbox.id}'
# 3. URL + instruction
agent_server_url = get_agent_server_url_from_sandbox(sandbox)
# Prepare message based on agent state
message_content = get_summary_instruction()
# Ask the agent and return the response text
return await self._ask_question(
httpx_client=httpx_client,
agent_server_url=agent_server_url,
conversation_id=conversation_id,
session_api_key=sandbox.session_api_key,
message_content=message_content,
)
async def _ask_question(
self,
httpx_client: httpx.AsyncClient,
agent_server_url: str,
conversation_id: UUID,
session_api_key: str,
message_content: str,
) -> str:
"""Send a message to the agent server via the V1 API and return response text."""
send_message_request = AskAgentRequest(question=message_content)
url = (
f"{agent_server_url.rstrip('/')}"
f"/api/conversations/{conversation_id}/ask_agent"
)
headers = {'X-Session-API-Key': session_api_key}
payload = send_message_request.model_dump()
try:
response = await httpx_client.post(
url,
json=payload,
headers=headers,
timeout=30.0,
)
response.raise_for_status()
agent_response = AskAgentResponse.model_validate(response.json())
return agent_response.response
except httpx.HTTPStatusError as e:
error_detail = f'HTTP {e.response.status_code} error'
try:
error_body = e.response.text
if error_body:
error_detail += f': {error_body}'
except Exception:
pass
_logger.exception(
'[Jira] HTTP error sending message to %s: %s. '
'Request payload: %s. Response headers: %s',
url,
error_detail,
payload,
dict(e.response.headers),
stack_info=True,
)
raise Exception(f'Failed to send message to agent server: {error_detail}')
except httpx.TimeoutException:
error_detail = f'Request timeout after 30 seconds to {url}'
_logger.exception(
'[Jira] Timeout error: %s. Request payload: %s',
error_detail,
payload,
stack_info=True,
)
raise Exception(f'Failed to send message to agent server: {error_detail}')
async def _post_summary_to_jira(self, summary: str):
"""Post the summary back to the Jira issue."""
if not all(
[
self.svc_acc_email,
self.decrypted_api_key,
self.issue_key,
self.jira_cloud_id,
]
):
_logger.warning('[Jira] Missing required data for posting summary')
return
# Add a comment to the Jira issue with the summary
comment_url = (
f'{JIRA_CLOUD_API_URL}/{self.jira_cloud_id}'
f'/rest/api/2/issue/{self.issue_key}/comment'
)
message = f'OpenHands resolved this issue:\n\n{summary}'
comment_body = format_jira_comment_body(message)
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
response = await client.post(
comment_url,
auth=(self.svc_acc_email, self.decrypted_api_key),
json=comment_body,
)
response.raise_for_status()
_logger.info(f'[Jira] Posted summary to {self.issue_key}')

View File

@@ -7,7 +7,6 @@ Views are responsible for:
"""
from dataclasses import dataclass, field
from uuid import UUID, uuid4
import httpx
from integrations.jira.jira_payload import JiraWebhookPayload
@@ -16,37 +15,18 @@ from integrations.jira.jira_types import (
RepositoryNotFoundError,
StartingConvoException,
)
from integrations.jira.jira_v1_callback_processor import (
JiraV1CallbackProcessor,
)
from integrations.resolver_context import ResolverUserContext
from integrations.resolver_org_router import resolve_org_for_repo
from integrations.utils import (
CONVERSATION_URL,
infer_repo_from_message,
)
from integrations.utils import CONVERSATION_URL, infer_repo_from_message
from jinja2 import Environment
from storage.jira_conversation import JiraConversation
from storage.jira_integration_store import JiraIntegrationStore
from storage.jira_user import JiraUser
from storage.jira_workspace import JiraWorkspace
from openhands.agent_server.models import SendMessageRequest
from openhands.app_server.app_conversation.app_conversation_models import (
AppConversationStartRequest,
AppConversationStartTaskStatus,
)
from openhands.app_server.config import get_app_conversation_service
from openhands.app_server.services.injector import InjectorState
from openhands.app_server.user.specifiy_user_context import USER_CONTEXT_ATTR
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.provider import ProviderHandler, ProviderType
from openhands.sdk import TextContent
from openhands.integrations.provider import ProviderHandler
from openhands.server.services.conversation_service import create_new_conversation
from openhands.server.user_auth.user_auth import UserAuth
from openhands.storage.data_models.conversation_metadata import (
ConversationMetadata,
ConversationTrigger,
)
from openhands.storage.data_models.conversation_metadata import ConversationTrigger
from openhands.utils.http_session import httpx_verify_option
JIRA_CLOUD_API_URL = 'https://api.atlassian.com/ex/jira'
@@ -66,7 +46,7 @@ class JiraNewConversationView(JiraViewInterface):
saas_user_auth: UserAuth
jira_user: JiraUser
jira_workspace: JiraWorkspace
selected_repo: str = ''
selected_repo: str | None = None
conversation_id: str = ''
# Lazy-loaded issue details (cached after first fetch)
@@ -76,9 +56,6 @@ class JiraNewConversationView(JiraViewInterface):
# Decrypted API key (set by factory)
_decrypted_api_key: str = field(default='', repr=False)
# Resolved org ID for V1 conversations
resolved_org_id: UUID | None = None
async def get_issue_details(self) -> tuple[str, str]:
"""Fetch issue details from Jira API (cached after first call).
@@ -184,131 +161,56 @@ class JiraNewConversationView(JiraViewInterface):
if not self.selected_repo:
raise StartingConvoException('No repository selected for this conversation')
jira_conversation = JiraConversation(
conversation_id=self.conversation_id,
issue_id=self.payload.issue_id,
issue_key=self.payload.issue_key,
jira_user_id=self.jira_user.id,
)
await integration_store.create_conversation(jira_conversation)
conversation_metadata = await self._create_v1_metadata()
await self._create_v1_conversation(jinja_env, conversation_metadata)
return self.conversation_id
async def _create_v1_metadata(self) -> ConversationMetadata:
"""Create conversation metadata for V1 conversations.
The JiraConversation mapping is saved to the integration store (above), but
V1 conversation metadata is managed by the app conversation system, not
the legacy conversation store.
"""
logger.info('[Jira]: Creating V1 metadata')
# Generate a dummy conversation for V1 (not saved to store)
self.conversation_id = uuid4().hex
self.resolved_org_id = await self._get_resolved_org_id()
return ConversationMetadata(
conversation_id=self.conversation_id,
selected_repository=self.selected_repo,
)
async def _create_v1_conversation(
self,
jinja_env: Environment,
conversation_metadata: ConversationMetadata,
):
"""Create conversation using the new V1 app conversation system."""
logger.info('[Jira]: Creating V1 conversation')
initial_user_text = await self._get_v1_initial_user_message(jinja_env)
# Create the initial message request
initial_message = SendMessageRequest(
role='user', content=[TextContent(text=initial_user_text)]
)
# Create the Jira V1 callback processor
jira_callback_processor = self._create_jira_v1_callback_processor()
injector_state = InjectorState()
# Create the V1 conversation start request
start_request = AppConversationStartRequest(
conversation_id=UUID(conversation_metadata.conversation_id),
system_message_suffix=None,
initial_message=initial_message,
selected_repository=self.selected_repo,
selected_branch=None,
git_provider=ProviderType.GITHUB,
title=f'Jira Issue {self.payload.issue_key}: {self._issue_title or "Unknown"}',
trigger=ConversationTrigger.JIRA,
processors=[jira_callback_processor],
)
# Set up the Jira user context for the V1 system
jira_user_context = ResolverUserContext(
saas_user_auth=self.saas_user_auth,
resolver_org_id=self.resolved_org_id,
)
setattr(injector_state, USER_CONTEXT_ATTR, jira_user_context)
async with get_app_conversation_service(
injector_state
) as app_conversation_service:
async for task in app_conversation_service.start_app_conversation(
start_request
):
if task.status == AppConversationStartTaskStatus.ERROR:
logger.error(f'Failed to start V1 conversation: {task.detail}')
raise RuntimeError(
f'Failed to start V1 conversation: {task.detail}'
)
async def _get_v1_initial_user_message(self, jinja_env: Environment) -> str:
"""Build the initial user message for V1 resolver conversations."""
issue_title, issue_description = await self.get_issue_details()
user_msg_template = jinja_env.get_template('jira_new_conversation.j2')
user_msg = user_msg_template.render(
issue_key=self.payload.issue_key,
issue_title=issue_title,
issue_description=issue_description,
user_message=self.payload.user_msg,
)
return user_msg
def _create_jira_v1_callback_processor(self):
"""Create a V1 callback processor for Jira integration."""
return JiraV1CallbackProcessor(
svc_acc_email=self.jira_workspace.svc_acc_email,
decrypted_api_key=self._decrypted_api_key,
issue_key=self.payload.issue_key,
jira_cloud_id=self.jira_workspace.jira_cloud_id,
)
async def _get_resolved_org_id(self) -> UUID | None:
"""Resolve the org ID for V1 conversations."""
provider_tokens = await self.saas_user_auth.get_provider_tokens()
if not provider_tokens:
return None
user_secrets = await self.saas_user_auth.get_secrets()
instructions, user_msg = await self._get_instructions(jinja_env)
try:
provider_handler = ProviderHandler(provider_tokens)
repository = await provider_handler.verify_repo_provider(self.selected_repo)
resolved_org_id = await resolve_org_for_repo(
provider=repository.git_provider.value,
full_repo_name=self.selected_repo,
keycloak_user_id=self.jira_user.keycloak_user_id,
agent_loop_info = await create_new_conversation(
user_id=self.jira_user.keycloak_user_id,
git_provider_tokens=provider_tokens,
selected_repository=self.selected_repo,
selected_branch=None,
initial_user_msg=user_msg,
conversation_instructions=instructions,
image_urls=None,
replay_json=None,
conversation_trigger=ConversationTrigger.JIRA,
custom_secrets=user_secrets.custom_secrets if user_secrets else None,
)
return resolved_org_id
self.conversation_id = agent_loop_info.conversation_id
logger.info(
'[Jira] Created conversation',
extra={
'conversation_id': self.conversation_id,
'issue_key': self.payload.issue_key,
'selected_repo': self.selected_repo,
},
)
# Store Jira conversation mapping
jira_conversation = JiraConversation(
conversation_id=self.conversation_id,
issue_id=self.payload.issue_id,
issue_key=self.payload.issue_key,
jira_user_id=self.jira_user.id,
)
await integration_store.create_conversation(jira_conversation)
return self.conversation_id
except Exception as e:
logger.warning(
f'[Jira] Failed to resolve org for {self.selected_repo}: {e}'
if isinstance(e, StartingConvoException):
raise
logger.error(
'[Jira] Failed to create conversation',
extra={'issue_key': self.payload.issue_key, 'error': str(e)},
exc_info=True,
)
return None
raise StartingConvoException(f'Failed to create conversation: {str(e)}')
def get_response_msg(self) -> str:
"""Get the response message to send back to Jira."""

View File

@@ -20,11 +20,11 @@ from integrations.utils import (
OPENHANDS_RESOLVER_TEMPLATES_DIR,
filter_potential_repos_by_user_msg,
get_session_expired_message,
markdown_to_jira_markup,
)
from jinja2 import Environment, FileSystemLoader
from server.auth.saas_user_auth import get_user_auth_from_keycloak_id
from server.auth.token_manager import TokenManager
from server.utils.conversation_callback_utils import register_callback_processor
from storage.jira_dc_integration_store import JiraDcIntegrationStore
from storage.jira_dc_user import JiraDcUser
from storage.jira_dc_workspace import JiraDcWorkspace
@@ -354,7 +354,12 @@ class JiraDcManager(Manager[JiraDcViewInterface]):
return False
async def start_job(self, jira_dc_view: JiraDcViewInterface) -> None:
"""Start a Jira DC job/conversation using V1 app conversation system."""
"""Start a Jira DC job/conversation."""
# Import here to prevent circular import
from server.conversation_callback_processor.jira_dc_callback_processor import (
JiraDcCallbackProcessor,
)
try:
user_info: JiraDcUser = jira_dc_view.jira_dc_user
logger.info(
@@ -362,15 +367,7 @@ class JiraDcManager(Manager[JiraDcViewInterface]):
f'issue {jira_dc_view.job_context.issue_key}',
)
# Set decrypted API key for new conversations (needed for V1 callback processor)
if isinstance(jira_dc_view, JiraDcNewConversationView):
api_key = self.token_manager.decrypt_text(
jira_dc_view.jira_dc_workspace.svc_acc_api_key
)
jira_dc_view._decrypted_api_key = api_key
# Create conversation using V1 app conversation system
# The callback processor is registered automatically by the view
# Create conversation
conversation_id = await jira_dc_view.create_or_update_conversation(
self.jinja_env
)
@@ -379,6 +376,21 @@ class JiraDcManager(Manager[JiraDcViewInterface]):
f'[Jira DC] Created/Updated conversation {conversation_id} for issue {jira_dc_view.job_context.issue_key}'
)
if isinstance(jira_dc_view, JiraDcNewConversationView):
# Register callback processor for updates
processor = JiraDcCallbackProcessor(
issue_key=jira_dc_view.job_context.issue_key,
workspace_name=jira_dc_view.jira_dc_workspace.name,
base_api_url=jira_dc_view.job_context.base_api_url,
)
# Register the callback processor
register_callback_processor(conversation_id, processor)
logger.info(
f'[Jira DC] Created callback processor for conversation {conversation_id}'
)
# Send initial response
msg_info = jira_dc_view.get_response_msg()
@@ -456,8 +468,7 @@ class JiraDcManager(Manager[JiraDcViewInterface]):
"""
url = f'{base_api_url}/rest/api/2/issue/{issue_key}/comment'
headers = {'Authorization': f'Bearer {svc_acc_api_key}'}
# Convert standard Markdown to Jira Wiki Markup for proper rendering
data = {'body': markdown_to_jira_markup(message)}
data = {'body': message}
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
response = await client.post(url, headers=headers, json=data)
response.raise_for_status()

View File

@@ -1,243 +0,0 @@
"""Jira Data Center V1 callback processor.
This processor handles events from V1 conversations and posts
summaries back to Jira DC issues when the agent finishes work.
"""
import logging
from uuid import UUID
import httpx
from integrations.utils import get_summary_instruction, markdown_to_jira_markup
from pydantic import Field
from openhands.agent_server.models import AskAgentRequest, AskAgentResponse
from openhands.app_server.event_callback.event_callback_models import (
EventCallback,
EventCallbackProcessor,
)
from openhands.app_server.event_callback.event_callback_result_models import (
EventCallbackResult,
EventCallbackResultStatus,
)
from openhands.app_server.event_callback.util import (
ensure_conversation_found,
ensure_running_sandbox,
get_agent_server_url_from_sandbox,
)
from openhands.sdk import Event
from openhands.sdk.event import ConversationStateUpdateEvent
from openhands.utils.http_session import httpx_verify_option
_logger = logging.getLogger(__name__)
class JiraDcV1CallbackProcessor(EventCallbackProcessor):
"""Callback processor for Jira Data Center V1 integrations."""
should_request_summary: bool = Field(default=True)
issue_key: str
workspace_name: str
base_api_url: str
svc_acc_api_key: str # Decrypted API key
async def __call__(
self,
conversation_id: UUID,
callback: EventCallback,
event: Event,
) -> EventCallbackResult | None:
"""Process events for Jira DC V1 integration."""
# Only handle ConversationStateUpdateEvent for execution_status
if not isinstance(event, ConversationStateUpdateEvent):
return None
if event.key != 'execution_status':
return None
_logger.info('[Jira DC] Callback agent state was %s', event)
# Only request summary when execution has finished successfully
if event.value != 'finished':
return None
_logger.info(
'[Jira DC] Should request summary: %s', self.should_request_summary
)
if not self.should_request_summary:
return None
self.should_request_summary = False
try:
_logger.info(f'[Jira DC] Requesting summary {conversation_id}')
summary = await self._request_summary(conversation_id)
_logger.info(
f'[Jira DC] Posting summary {conversation_id}',
extra={'summary': summary},
)
await self._post_summary_to_jira_dc(summary)
return EventCallbackResult(
status=EventCallbackResultStatus.SUCCESS,
event_callback_id=callback.id,
event_id=event.id,
conversation_id=conversation_id,
detail=summary,
)
except Exception as e:
_logger.exception(f'[Jira DC] Failed to post summary: {e}', stack_info=True)
return EventCallbackResult(
status=EventCallbackResultStatus.ERROR,
event_callback_id=callback.id,
event_id=event.id,
conversation_id=conversation_id,
detail=str(e),
)
async def _request_summary(self, conversation_id: UUID) -> str:
"""Ask the agent to produce a summary of its work and return the agent response."""
# Import services within the method to avoid circular imports
from openhands.app_server.config import (
get_app_conversation_info_service,
get_httpx_client,
get_sandbox_service,
)
from openhands.app_server.services.injector import InjectorState
from openhands.app_server.user.specifiy_user_context import (
ADMIN,
USER_CONTEXT_ATTR,
)
# Create injector state for dependency injection
state = InjectorState()
setattr(state, USER_CONTEXT_ATTR, ADMIN)
async with (
get_app_conversation_info_service(state) as app_conversation_info_service,
get_sandbox_service(state) as sandbox_service,
get_httpx_client(state) as httpx_client,
):
# 1. Conversation lookup
app_conversation_info = ensure_conversation_found(
await app_conversation_info_service.get_app_conversation_info(
conversation_id
),
conversation_id,
)
# 2. Sandbox lookup + validation
sandbox = ensure_running_sandbox(
await sandbox_service.get_sandbox(app_conversation_info.sandbox_id),
app_conversation_info.sandbox_id,
)
assert (
sandbox.session_api_key is not None
), f'No session API key for sandbox: {sandbox.id}'
# 3. URL + instruction
agent_server_url = get_agent_server_url_from_sandbox(sandbox)
# Prepare message based on agent state
message_content = get_summary_instruction()
# Ask the agent and return the response text
return await self._ask_question(
httpx_client=httpx_client,
agent_server_url=agent_server_url,
conversation_id=conversation_id,
session_api_key=sandbox.session_api_key,
message_content=message_content,
)
async def _ask_question(
self,
httpx_client: httpx.AsyncClient,
agent_server_url: str,
conversation_id: UUID,
session_api_key: str,
message_content: str,
) -> str:
"""Send a message to the agent server via the V1 API and return response text."""
send_message_request = AskAgentRequest(question=message_content)
url = (
f"{agent_server_url.rstrip('/')}"
f"/api/conversations/{conversation_id}/ask_agent"
)
headers = {'X-Session-API-Key': session_api_key}
payload = send_message_request.model_dump()
try:
response = await httpx_client.post(
url,
json=payload,
headers=headers,
timeout=30.0,
)
response.raise_for_status()
agent_response = AskAgentResponse.model_validate(response.json())
return agent_response.response
except httpx.HTTPStatusError as e:
error_detail = f'HTTP {e.response.status_code} error'
try:
error_body = e.response.text
if error_body:
error_detail += f': {error_body}'
except Exception:
pass
_logger.exception(
'[Jira DC] HTTP error sending message to %s: %s. '
'Request payload: %s. Response headers: %s',
url,
error_detail,
payload,
dict(e.response.headers),
stack_info=True,
)
raise Exception(f'Failed to send message to agent server: {error_detail}')
except httpx.TimeoutException:
error_detail = f'Request timeout after 30 seconds to {url}'
_logger.exception(
'[Jira DC] Timeout error: %s. Request payload: %s',
error_detail,
payload,
stack_info=True,
)
raise Exception(f'Failed to send message to agent server: {error_detail}')
async def _post_summary_to_jira_dc(self, summary: str):
"""Post the summary back to the Jira DC issue."""
if not all(
[
self.svc_acc_api_key,
self.issue_key,
self.base_api_url,
]
):
_logger.warning('[Jira DC] Missing required data for posting summary')
return
# Add a comment to the Jira DC issue with the summary
comment_url = f'{self.base_api_url}/rest/api/2/issue/{self.issue_key}/comment'
message = f'OpenHands resolved this issue:\n\n{summary}'
# Convert standard Markdown to Jira Wiki Markup for proper rendering
comment_body = {'body': markdown_to_jira_markup(message)}
headers = {'Authorization': f'Bearer {self.svc_acc_api_key}'}
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
response = await client.post(
comment_url,
headers=headers,
json=comment_body,
)
response.raise_for_status()
_logger.info(f'[Jira DC] Posted summary to {self.issue_key}')

View File

@@ -1,51 +1,34 @@
"""Jira Data Center view implementations and factory.
Views are responsible for:
- Holding the webhook payload and auth context
- Creating conversations using V1 app conversation system
"""
from dataclasses import dataclass, field
from uuid import UUID, uuid4
from dataclasses import dataclass
from integrations.jira_dc.jira_dc_types import (
JiraDcViewInterface,
StartingConvoException,
)
from integrations.jira_dc.jira_dc_v1_callback_processor import JiraDcV1CallbackProcessor
from integrations.models import JobContext
from integrations.resolver_context import ResolverUserContext
from integrations.resolver_org_router import resolve_org_for_repo
from integrations.utils import CONVERSATION_URL
from integrations.utils import CONVERSATION_URL, get_final_agent_observation
from jinja2 import Environment
from storage.jira_dc_conversation import JiraDcConversation
from storage.jira_dc_integration_store import JiraDcIntegrationStore
from storage.jira_dc_user import JiraDcUser
from storage.jira_dc_workspace import JiraDcWorkspace
from openhands.agent_server.models import SendMessageRequest
from openhands.app_server.app_conversation.app_conversation_models import (
AppConversationStartRequest,
AppConversationStartTaskStatus,
)
from openhands.app_server.config import get_app_conversation_service
from openhands.app_server.services.injector import InjectorState
from openhands.app_server.user.specifiy_user_context import USER_CONTEXT_ATTR
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.provider import ProviderHandler, ProviderType
from openhands.sdk import TextContent
from openhands.server.user_auth.user_auth import UserAuth
from openhands.storage.data_models.conversation_metadata import (
ConversationTrigger,
from openhands.core.schema.agent import AgentState
from openhands.events.action import MessageAction
from openhands.events.serialization.event import event_to_dict
from openhands.server.services.conversation_service import (
create_new_conversation,
setup_init_conversation_settings,
)
from openhands.server.shared import ConversationStoreImpl, config, conversation_manager
from openhands.server.user_auth.user_auth import UserAuth
from openhands.storage.data_models.conversation_metadata import ConversationTrigger
integration_store = JiraDcIntegrationStore.get_instance()
@dataclass
class JiraDcNewConversationView(JiraDcViewInterface):
"""View for creating a new Jira DC conversation."""
job_context: JobContext
saas_user_auth: UserAuth
jira_dc_user: JiraDcUser
@@ -53,14 +36,9 @@ class JiraDcNewConversationView(JiraDcViewInterface):
selected_repo: str | None
conversation_id: str
# Decrypted API key (set by manager)
_decrypted_api_key: str = field(default='', repr=False)
# Resolved org ID for V1 conversations
resolved_org_id: UUID | None = None
async def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
"""Instructions passed when conversation is first initialized."""
"""Instructions passed when conversation is first initialized"""
instructions_template = jinja_env.get_template('jira_dc_instructions.j2')
instructions = instructions_template.render()
@@ -76,148 +54,58 @@ class JiraDcNewConversationView(JiraDcViewInterface):
return instructions, user_msg
async def create_or_update_conversation(self, jinja_env: Environment) -> str:
"""Create a new Jira DC conversation using V1 app conversation system.
"""Create a new Jira DC conversation"""
Returns:
The conversation ID
Raises:
StartingConvoException: If conversation creation fails
"""
if not self.selected_repo:
raise StartingConvoException('No repository selected for this conversation')
# Generate conversation ID
self.conversation_id = uuid4().hex
# Save the JiraDC conversation mapping
jira_dc_conversation = JiraDcConversation(
conversation_id=self.conversation_id,
issue_id=self.job_context.issue_id,
issue_key=self.job_context.issue_key,
jira_dc_user_id=self.jira_dc_user.id,
)
await integration_store.create_conversation(jira_dc_conversation)
# Create V1 conversation
await self._create_v1_conversation(jinja_env)
return self.conversation_id
async def _create_v1_conversation(self, jinja_env: Environment):
"""Create conversation using the V1 app conversation system."""
logger.info('[Jira DC]: Creating V1 conversation')
provider_tokens = await self.saas_user_auth.get_provider_tokens()
user_secrets = await self.saas_user_auth.get_secrets()
instructions, user_msg = await self._get_instructions(jinja_env)
# Create the initial message request
initial_message = SendMessageRequest(
role='user', content=[TextContent(text=user_msg)]
)
# Create the Jira DC V1 callback processor
jira_dc_callback_processor = self._create_jira_dc_v1_callback_processor()
# Resolve org ID for the V1 system
self.resolved_org_id = await self._get_resolved_org_id()
# Determine git provider
git_provider = await self._get_git_provider()
injector_state = InjectorState()
# Create the V1 conversation start request
start_request = AppConversationStartRequest(
conversation_id=UUID(self.conversation_id),
system_message_suffix=instructions if instructions else None,
initial_message=initial_message,
selected_repository=self.selected_repo,
selected_branch=None,
git_provider=git_provider,
title=f'Jira DC Issue {self.job_context.issue_key}: {self.job_context.issue_title or "Unknown"}',
trigger=ConversationTrigger.JIRA,
processors=[jira_dc_callback_processor],
)
# Set up the Jira DC user context for the V1 system
jira_dc_user_context = ResolverUserContext(
saas_user_auth=self.saas_user_auth,
resolver_org_id=self.resolved_org_id,
)
setattr(injector_state, USER_CONTEXT_ATTR, jira_dc_user_context)
async with get_app_conversation_service(
injector_state
) as app_conversation_service:
async for task in app_conversation_service.start_app_conversation(
start_request
):
if task.status == AppConversationStartTaskStatus.ERROR:
logger.error(f'Failed to start V1 conversation: {task.detail}')
raise RuntimeError(
f'Failed to start V1 conversation: {task.detail}'
)
logger.info(f'[Jira DC]: Created new conversation: {self.conversation_id}')
def _create_jira_dc_v1_callback_processor(self) -> JiraDcV1CallbackProcessor:
"""Create a V1 callback processor for Jira DC integration."""
return JiraDcV1CallbackProcessor(
issue_key=self.job_context.issue_key,
workspace_name=self.jira_dc_workspace.name,
base_api_url=self.job_context.base_api_url,
svc_acc_api_key=self._decrypted_api_key,
)
async def _get_git_provider(self) -> ProviderType | None:
"""Determine the git provider from the selected repository."""
if not self.selected_repo:
return None
provider_tokens = await self.saas_user_auth.get_provider_tokens()
if not provider_tokens:
return None
try:
provider_handler = ProviderHandler(provider_tokens)
repository = await provider_handler.verify_repo_provider(self.selected_repo)
return repository.git_provider
except Exception as e:
logger.warning(
f'[Jira DC] Failed to determine git provider for {self.selected_repo}: {e}'
agent_loop_info = await create_new_conversation(
user_id=self.jira_dc_user.keycloak_user_id,
git_provider_tokens=provider_tokens,
selected_repository=self.selected_repo,
selected_branch=None,
initial_user_msg=user_msg,
conversation_instructions=instructions,
image_urls=None,
replay_json=None,
conversation_trigger=ConversationTrigger.JIRA_DC,
custom_secrets=user_secrets.custom_secrets if user_secrets else None,
)
return None
async def _get_resolved_org_id(self) -> UUID | None:
"""Resolve the org ID for V1 conversations."""
provider_tokens = await self.saas_user_auth.get_provider_tokens()
if not provider_tokens or not self.selected_repo:
return None
self.conversation_id = agent_loop_info.conversation_id
try:
provider_handler = ProviderHandler(provider_tokens)
repository = await provider_handler.verify_repo_provider(self.selected_repo)
resolved_org_id = await resolve_org_for_repo(
provider=repository.git_provider.value,
full_repo_name=self.selected_repo,
keycloak_user_id=self.jira_dc_user.keycloak_user_id,
logger.info(f'[Jira DC] Created conversation {self.conversation_id}')
# Store Jira DC conversation mapping
jira_dc_conversation = JiraDcConversation(
conversation_id=self.conversation_id,
issue_id=self.job_context.issue_id,
issue_key=self.job_context.issue_key,
jira_dc_user_id=self.jira_dc_user.id,
)
return resolved_org_id
await integration_store.create_conversation(jira_dc_conversation)
return self.conversation_id
except Exception as e:
logger.warning(
f'[Jira DC] Failed to resolve org for {self.selected_repo}: {e}'
logger.error(
f'[Jira DC] Failed to create conversation: {str(e)}', exc_info=True
)
return None
raise StartingConvoException(f'Failed to create conversation: {str(e)}')
def get_response_msg(self) -> str:
"""Get the response message to send back to Jira DC."""
"""Get the response message to send back to Jira DC"""
conversation_link = CONVERSATION_URL.format(self.conversation_id)
return f"I'm on it! {self.job_context.display_name} can [track my progress here|{conversation_link}]."
@dataclass
class JiraDcExistingConversationView(JiraDcViewInterface):
"""View for sending messages to an existing Jira DC conversation."""
job_context: JobContext
saas_user_auth: UserAuth
jira_dc_user: JiraDcUser
@@ -226,7 +114,8 @@ class JiraDcExistingConversationView(JiraDcViewInterface):
conversation_id: str
async def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
"""Instructions passed when conversation is updated."""
"""Instructions passed when conversation is first initialized"""
user_msg_template = jinja_env.get_template('jira_dc_existing_conversation.j2')
user_msg = user_msg_template.render(
issue_key=self.job_context.issue_key,
@@ -238,107 +127,64 @@ class JiraDcExistingConversationView(JiraDcViewInterface):
return '', user_msg
async def create_or_update_conversation(self, jinja_env: Environment) -> str:
"""Send a message to an existing V1 conversation.
"""Update an existing Jira conversation"""
Returns:
The conversation ID
"""
await self._send_message_to_v1_conversation(jinja_env)
return self.conversation_id
user_id = self.jira_dc_user.keycloak_user_id
async def _send_message_to_v1_conversation(self, jinja_env: Environment):
"""Send a message to an existing V1 conversation using the agent server API."""
import httpx
from openhands.app_server.config import (
get_app_conversation_info_service,
get_httpx_client,
get_sandbox_service,
)
from openhands.app_server.event_callback.util import (
ensure_conversation_found,
get_agent_server_url_from_sandbox,
)
from openhands.app_server.sandbox.sandbox_models import SandboxStatus
from openhands.app_server.services.injector import InjectorState
from openhands.app_server.user.specifiy_user_context import (
ADMIN,
USER_CONTEXT_ATTR,
)
_, user_msg = await self._get_instructions(jinja_env)
# Create injector state for dependency injection
state = InjectorState()
setattr(state, USER_CONTEXT_ATTR, ADMIN)
async with (
get_app_conversation_info_service(state) as app_conversation_info_service,
get_sandbox_service(state) as sandbox_service,
get_httpx_client(state) as httpx_client,
):
# 1. Conversation lookup
conversation_uuid = UUID(self.conversation_id)
app_conversation_info = ensure_conversation_found(
await app_conversation_info_service.get_app_conversation_info(
conversation_uuid
),
conversation_uuid,
try:
conversation_store = await ConversationStoreImpl.get_instance(
config, user_id
)
# 2. Sandbox lookup + validation
sandbox = await sandbox_service.get_sandbox(
app_conversation_info.sandbox_id
)
if sandbox is None or sandbox.status != SandboxStatus.RUNNING:
logger.warning(
f'[Jira DC] Sandbox not running for conversation {self.conversation_id}'
)
return
if sandbox.session_api_key is None:
logger.warning(
f'[Jira DC] No session API key for sandbox: {sandbox.id}'
)
return
# 3. Build URL and send message
agent_server_url = get_agent_server_url_from_sandbox(sandbox)
send_message_request = SendMessageRequest(
role='user', content=[TextContent(text=user_msg)]
)
url = (
f"{agent_server_url.rstrip('/')}"
f'/api/conversations/{self.conversation_id}/messages'
)
headers = {'X-Session-API-Key': sandbox.session_api_key}
payload = send_message_request.model_dump()
try:
response = await httpx_client.post(
url,
json=payload,
headers=headers,
timeout=30.0,
)
response.raise_for_status()
logger.info(
f'[Jira DC] Sent message to existing conversation {self.conversation_id}'
)
except httpx.HTTPStatusError as e:
logger.error(
f'[Jira DC] Failed to send message: HTTP {e.response.status_code}'
)
raise
except Exception as e:
logger.error(f'[Jira DC] Failed to send message: {e}')
raise
await conversation_store.get_metadata(self.conversation_id)
except FileNotFoundError:
raise StartingConvoException('Conversation no longer exists.')
provider_tokens = await self.saas_user_auth.get_provider_tokens()
if provider_tokens is None:
raise ValueError('Could not load provider tokens')
providers_set = list(provider_tokens.keys())
conversation_init_data = await setup_init_conversation_settings(
user_id, self.conversation_id, providers_set
)
# Either join ongoing conversation, or restart the conversation
agent_loop_info = await conversation_manager.maybe_start_agent_loop(
self.conversation_id, conversation_init_data, user_id
)
if agent_loop_info.event_store is None:
raise StartingConvoException('Event store not available')
final_agent_observation = get_final_agent_observation(
agent_loop_info.event_store
)
agent_state = (
None
if len(final_agent_observation) == 0
else final_agent_observation[0].agent_state
)
if not agent_state or agent_state == AgentState.LOADING:
raise StartingConvoException('Conversation is still starting')
_, user_msg = await self._get_instructions(jinja_env)
user_message_event = MessageAction(content=user_msg)
await conversation_manager.send_event_to_conversation(
self.conversation_id, event_to_dict(user_message_event)
)
return self.conversation_id
except Exception as e:
logger.error(
f'[Jira] Failed to create conversation: {str(e)}', exc_info=True
)
raise StartingConvoException(f'Failed to create conversation: {str(e)}')
def get_response_msg(self) -> str:
"""Get the response message to send back to Jira."""
"""Get the response message to send back to Jira"""
conversation_link = CONVERSATION_URL.format(self.conversation_id)
return f"I'm on it! {self.job_context.display_name} can [continue tracking my progress here|{conversation_link}]."
@@ -354,6 +200,7 @@ class JiraDcFactory:
jira_dc_workspace: JiraDcWorkspace,
) -> JiraDcViewInterface:
"""Create appropriate Jira DC view based on the payload."""
if not jira_dc_user or not saas_user_auth or not jira_dc_workspace:
raise StartingConvoException('User not authenticated with Jira integration')

View File

@@ -0,0 +1,536 @@
import hashlib
import hmac
from typing import Dict, Optional, Tuple
import httpx
from fastapi import Request
from integrations.linear.linear_types import LinearViewInterface
from integrations.linear.linear_view import (
LinearExistingConversationView,
LinearFactory,
LinearNewConversationView,
)
from integrations.manager import Manager
from integrations.models import JobContext, Message
from integrations.utils import (
HOST_URL,
OPENHANDS_RESOLVER_TEMPLATES_DIR,
filter_potential_repos_by_user_msg,
get_session_expired_message,
)
from jinja2 import Environment, FileSystemLoader
from server.auth.saas_user_auth import get_user_auth_from_keycloak_id
from server.auth.token_manager import TokenManager
from server.utils.conversation_callback_utils import register_callback_processor
from storage.linear_integration_store import LinearIntegrationStore
from storage.linear_user import LinearUser
from storage.linear_workspace import LinearWorkspace
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.provider import ProviderHandler
from openhands.integrations.service_types import Repository
from openhands.server.shared import server_config
from openhands.server.types import (
LLMAuthenticationError,
MissingSettingsError,
SessionExpiredError,
)
from openhands.server.user_auth.user_auth import UserAuth
from openhands.utils.http_session import httpx_verify_option
class LinearManager(Manager[LinearViewInterface]):
def __init__(self, token_manager: TokenManager):
self.token_manager = token_manager
self.integration_store = LinearIntegrationStore.get_instance()
self.api_url = 'https://api.linear.app/graphql'
self.jinja_env = Environment(
loader=FileSystemLoader(OPENHANDS_RESOLVER_TEMPLATES_DIR + 'linear')
)
async def authenticate_user(
self, linear_user_id: str, workspace_id: int
) -> tuple[LinearUser | None, UserAuth | None]:
"""Authenticate Linear user and get their OpenHands user auth."""
# Find active Linear user by Linear user ID and workspace ID
linear_user = await self.integration_store.get_active_user(
linear_user_id, workspace_id
)
if not linear_user:
logger.warning(
f'[Linear] No active Linear user found for {linear_user_id} in workspace {workspace_id}'
)
return None, None
saas_user_auth = await get_user_auth_from_keycloak_id(
linear_user.keycloak_user_id
)
return linear_user, saas_user_auth
async def _get_repositories(self, user_auth: UserAuth) -> list[Repository]:
"""Get repositories that the user has access to."""
provider_tokens = await user_auth.get_provider_tokens()
if provider_tokens is None:
return []
access_token = await user_auth.get_access_token()
user_id = await user_auth.get_user_id()
client = ProviderHandler(
provider_tokens=provider_tokens,
external_auth_token=access_token,
external_auth_id=user_id,
)
repos: list[Repository] = await client.get_repositories(
'pushed', server_config.app_mode, None, None, None, None
)
return repos
async def validate_request(
self, request: Request
) -> Tuple[bool, Optional[str], Optional[Dict]]:
"""Verify Linear webhook signature."""
signature = request.headers.get('linear-signature')
body = await request.body()
payload = await request.json()
actor_url = payload.get('actor', {}).get('url', '')
workspace_name = ''
# Extract workspace name from actor URL
# Format: https://linear.app/{workspace}/profiles/{user}
if actor_url.startswith('https://linear.app/'):
url_parts = actor_url.split('/')
if len(url_parts) >= 4:
workspace_name = url_parts[3] # Extract workspace name
else:
logger.warning(f'[Linear] Invalid actor URL format: {actor_url}')
return False, None, None
else:
logger.warning(
f'[Linear] Actor URL does not match expected format: {actor_url}'
)
return False, None, None
if not workspace_name:
logger.warning('[Linear] No workspace name found in webhook payload')
return False, None, None
if not signature:
logger.warning('[Linear] No signature found in webhook headers')
return False, None, None
workspace = await self.integration_store.get_workspace_by_name(workspace_name)
if not workspace:
logger.warning('[Linear] Could not identify workspace for webhook')
return False, None, None
if workspace.status != 'active':
logger.warning(f'[Linear] Workspace {workspace.id} is not active')
return False, None, None
webhook_secret = self.token_manager.decrypt_text(workspace.webhook_secret)
digest = hmac.new(webhook_secret.encode(), body, hashlib.sha256).hexdigest()
if hmac.compare_digest(signature, digest):
logger.info('[Linear] Webhook signature verified successfully')
return True, signature, payload
return False, None, None
def parse_webhook(self, payload: Dict) -> JobContext | None:
action = payload.get('action')
type = payload.get('type')
if action == 'create' and type == 'Comment':
data = payload.get('data', {})
comment = data.get('body', '')
if '@openhands' not in comment:
return None
issue_data = data.get('issue', {})
issue_id = issue_data.get('id', '')
issue_key = issue_data.get('identifier', '')
elif action == 'update' and type == 'Issue':
data = payload.get('data', {})
labels = data.get('labels', [])
has_openhands_label = False
label_id = ''
for label in labels:
if label.get('name') == 'openhands':
label_id = label.get('id', '')
has_openhands_label = True
break
if not has_openhands_label and not label_id:
return None
labelIdChanges = data.get('updatedFrom', {}).get('labelIds', [])
if labelIdChanges and label_id in labelIdChanges:
return None # Label was added previously, ignore this webhook
issue_id = data.get('id', '')
issue_key = data.get('identifier', '')
comment = ''
else:
return None
actor = payload.get('actor', {})
display_name = actor.get('name', '')
user_email = actor.get('email', '')
actor_url = actor.get('url', '')
actor_id = actor.get('id', '')
workspace_name = ''
if actor_url.startswith('https://linear.app/'):
url_parts = actor_url.split('/')
if len(url_parts) >= 4:
workspace_name = url_parts[3] # Extract workspace name
else:
logger.warning(f'[Linear] Invalid actor URL format: {actor_url}')
return None
else:
logger.warning(
f'[Linear] Actor URL does not match expected format: {actor_url}'
)
return None
if not all(
[issue_id, issue_key, display_name, user_email, actor_id, workspace_name]
):
logger.warning('[Linear] Missing required fields in webhook payload')
return None
return JobContext(
issue_id=issue_id,
issue_key=issue_key,
user_msg=comment,
user_email=user_email,
platform_user_id=actor_id,
workspace_name=workspace_name,
display_name=display_name,
)
async def receive_message(self, message: Message):
"""Process incoming Linear webhook message."""
payload = message.message.get('payload', {})
job_context = self.parse_webhook(payload)
if not job_context:
logger.info('[Linear] Webhook does not match trigger conditions')
return
# Get workspace by user email domain
workspace = await self.integration_store.get_workspace_by_name(
job_context.workspace_name
)
if not workspace:
logger.warning(
f'[Linear] No workspace found for email domain: {job_context.workspace_name}'
)
await self._send_error_comment(
job_context.issue_id,
'Your workspace is not configured with Linear integration.',
None,
)
return
# Prevent any recursive triggers from the service account
if job_context.user_email == workspace.svc_acc_email:
return
if workspace.status != 'active':
logger.warning(f'[Linear] Workspace {workspace.id} is not active')
await self._send_error_comment(
job_context.issue_id,
'Linear integration is not active for your workspace.',
workspace,
)
return
# Authenticate user
linear_user, saas_user_auth = await self.authenticate_user(
job_context.platform_user_id, workspace.id
)
if not linear_user or not saas_user_auth:
logger.warning(
f'[Linear] User authentication failed for {job_context.user_email}'
)
await self._send_error_comment(
job_context.issue_id,
f'User {job_context.user_email} is not authenticated or active in the Linear integration.',
workspace,
)
return
# Get issue details
try:
api_key = self.token_manager.decrypt_text(workspace.svc_acc_api_key)
issue_title, issue_description = await self.get_issue_details(
job_context.issue_id, api_key
)
job_context.issue_title = issue_title
job_context.issue_description = issue_description
except Exception as e:
logger.error(f'[Linear] Failed to get issue context: {str(e)}')
await self._send_error_comment(
job_context.issue_id,
'Failed to retrieve issue details. Please check the issue ID and try again.',
workspace,
)
return
try:
# Create Linear view
linear_view = await LinearFactory.create_linear_view_from_payload(
job_context,
saas_user_auth,
linear_user,
workspace,
)
except Exception as e:
logger.error(
f'[Linear] Failed to create linear view: {str(e)}', exc_info=True
)
await self._send_error_comment(
job_context.issue_id,
'Failed to initialize conversation. Please try again.',
workspace,
)
return
if not await self.is_job_requested(message, linear_view):
return
await self.start_job(linear_view)
async def is_job_requested(
self, message: Message, linear_view: LinearViewInterface
) -> bool:
"""
Check if a job is requested and handle repository selection.
"""
if isinstance(linear_view, LinearExistingConversationView):
return True
try:
# Get user repositories
user_repos: list[Repository] = await self._get_repositories(
linear_view.saas_user_auth
)
target_str = f'{linear_view.job_context.issue_description}\n{linear_view.job_context.user_msg}'
# Try to infer repository from issue description
match, repos = filter_potential_repos_by_user_msg(target_str, user_repos)
if match:
# Found exact repository match
linear_view.selected_repo = repos[0].full_name
logger.info(f'[Linear] Inferred repository: {repos[0].full_name}')
return True
else:
# No clear match - send repository selection comment
await self._send_repo_selection_comment(linear_view)
return False
except Exception as e:
logger.error(f'[Linear] Error in is_job_requested: {str(e)}')
return False
async def start_job(self, linear_view: LinearViewInterface) -> None:
"""Start a Linear job/conversation."""
# Import here to prevent circular import
from server.conversation_callback_processor.linear_callback_processor import (
LinearCallbackProcessor,
)
try:
user_info: LinearUser = linear_view.linear_user
logger.info(
f'[Linear] Starting job for user {user_info.keycloak_user_id} '
f'issue {linear_view.job_context.issue_key}',
)
# Create conversation
conversation_id = await linear_view.create_or_update_conversation(
self.jinja_env
)
logger.info(
f'[Linear] Created/Updated conversation {conversation_id} for issue {linear_view.job_context.issue_key}'
)
if isinstance(linear_view, LinearNewConversationView):
# Register callback processor for updates
processor = LinearCallbackProcessor(
issue_id=linear_view.job_context.issue_id,
issue_key=linear_view.job_context.issue_key,
workspace_name=linear_view.linear_workspace.name,
)
# Register the callback processor
register_callback_processor(conversation_id, processor)
logger.info(
f'[Linear] Created callback processor for conversation {conversation_id}'
)
# Send initial response
msg_info = linear_view.get_response_msg()
except MissingSettingsError as e:
logger.warning(f'[Linear] Missing settings error: {str(e)}')
msg_info = f'Please re-login into [OpenHands Cloud]({HOST_URL}) before starting a job.'
except LLMAuthenticationError as e:
logger.warning(f'[Linear] LLM authentication error: {str(e)}')
msg_info = f'Please set a valid LLM API key in [OpenHands Cloud]({HOST_URL}) before starting a job.'
except SessionExpiredError as e:
logger.warning(f'[Linear] Session expired: {str(e)}')
msg_info = get_session_expired_message()
except Exception as e:
logger.error(
f'[Linear] Unexpected error starting job: {str(e)}', exc_info=True
)
msg_info = 'Sorry, there was an unexpected error starting the job. Please try again.'
# Send response comment
try:
api_key = self.token_manager.decrypt_text(
linear_view.linear_workspace.svc_acc_api_key
)
await self.send_message(
msg_info,
linear_view.job_context.issue_id,
api_key,
)
except Exception as e:
logger.error(f'[Linear] Failed to send response message: {str(e)}')
async def _query_api(self, query: str, variables: Dict, api_key: str) -> Dict:
"""Query Linear GraphQL API."""
headers = {'Authorization': api_key}
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
response = await client.post(
self.api_url,
headers=headers,
json={'query': query, 'variables': variables},
)
response.raise_for_status()
return response.json()
async def get_issue_details(self, issue_id: str, api_key: str) -> Tuple[str, str]:
"""Get issue details from Linear API."""
query = """
query Issue($issueId: String!) {
issue(id: $issueId) {
id
identifier
title
description
syncedWith {
metadata {
... on ExternalEntityInfoGithubMetadata {
owner
repo
}
}
}
}
}
"""
issue_payload = await self._query_api(query, {'issueId': issue_id}, api_key)
if not issue_payload:
raise ValueError(f'Issue with ID {issue_id} not found.')
issue_data = issue_payload.get('data', {}).get('issue', {})
title = issue_data.get('title', '')
description = issue_data.get('description', '')
synced_with = issue_data.get('syncedWith', [])
owner = ''
repo = ''
if synced_with:
owner = synced_with[0].get('metadata', {}).get('owner', '')
repo = synced_with[0].get('metadata', {}).get('repo', '')
if not title:
raise ValueError(f'Issue with ID {issue_id} does not have a title.')
if not description:
raise ValueError(f'Issue with ID {issue_id} does not have a description.')
if owner and repo:
description += f'\n\nGit Repo: {owner}/{repo}'
return title, description
async def send_message(self, message: str, issue_id: str, api_key: str):
"""Send message/comment to Linear issue.
Args:
message: The message content to send (plain text string)
issue_id: The Linear issue ID to comment on
api_key: The Linear API key for authentication
"""
query = """
mutation CommentCreate($input: CommentCreateInput!) {
commentCreate(input: $input) {
success
comment {
id
}
}
}
"""
variables = {'input': {'issueId': issue_id, 'body': message}}
return await self._query_api(query, variables, api_key)
async def _send_error_comment(
self, issue_id: str, error_msg: str, workspace: LinearWorkspace | None
):
"""Send error comment to Linear issue."""
if not workspace:
logger.error('[Linear] Cannot send error comment - no workspace available')
return
try:
api_key = self.token_manager.decrypt_text(workspace.svc_acc_api_key)
await self.send_message(error_msg, issue_id, api_key)
except Exception as e:
logger.error(f'[Linear] Failed to send error comment: {str(e)}')
async def _send_repo_selection_comment(self, linear_view: LinearViewInterface):
"""Send a comment with repository options for the user to choose."""
try:
comment_msg = (
'I need to know which repository to work with. '
'Please add it to your issue description or send a followup comment.'
)
api_key = self.token_manager.decrypt_text(
linear_view.linear_workspace.svc_acc_api_key
)
await self.send_message(
comment_msg,
linear_view.job_context.issue_id,
api_key,
)
logger.info(
f'[Linear] Sent repository selection comment for issue {linear_view.job_context.issue_key}'
)
except Exception as e:
logger.error(
f'[Linear] Failed to send repository selection comment: {str(e)}'
)

View File

@@ -0,0 +1,40 @@
from abc import ABC, abstractmethod
from integrations.models import JobContext
from jinja2 import Environment
from storage.linear_user import LinearUser
from storage.linear_workspace import LinearWorkspace
from openhands.server.user_auth.user_auth import UserAuth
class LinearViewInterface(ABC):
"""Interface for Linear views that handle different types of Linear interactions."""
job_context: JobContext
saas_user_auth: UserAuth
linear_user: LinearUser
linear_workspace: LinearWorkspace
selected_repo: str | None
conversation_id: str
@abstractmethod
async def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
"""Get initial instructions for the conversation."""
pass
@abstractmethod
async def create_or_update_conversation(self, jinja_env: Environment) -> str:
"""Create or update a conversation and return the conversation ID."""
pass
@abstractmethod
def get_response_msg(self) -> str:
"""Get the response message to send back to Linear."""
pass
class StartingConvoException(Exception):
"""Exception raised when starting a conversation fails."""
pass

View File

@@ -0,0 +1,229 @@
from dataclasses import dataclass
from integrations.linear.linear_types import LinearViewInterface, StartingConvoException
from integrations.models import JobContext
from integrations.utils import CONVERSATION_URL, get_final_agent_observation
from jinja2 import Environment
from storage.linear_conversation import LinearConversation
from storage.linear_integration_store import LinearIntegrationStore
from storage.linear_user import LinearUser
from storage.linear_workspace import LinearWorkspace
from openhands.core.logger import openhands_logger as logger
from openhands.core.schema.agent import AgentState
from openhands.events.action import MessageAction
from openhands.events.serialization.event import event_to_dict
from openhands.server.services.conversation_service import (
create_new_conversation,
setup_init_conversation_settings,
)
from openhands.server.shared import ConversationStoreImpl, config, conversation_manager
from openhands.server.user_auth.user_auth import UserAuth
from openhands.storage.data_models.conversation_metadata import ConversationTrigger
integration_store = LinearIntegrationStore.get_instance()
@dataclass
class LinearNewConversationView(LinearViewInterface):
job_context: JobContext
saas_user_auth: UserAuth
linear_user: LinearUser
linear_workspace: LinearWorkspace
selected_repo: str | None
conversation_id: str
async def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
"""Instructions passed when conversation is first initialized"""
instructions_template = jinja_env.get_template('linear_instructions.j2')
instructions = instructions_template.render()
user_msg_template = jinja_env.get_template('linear_new_conversation.j2')
user_msg = user_msg_template.render(
issue_key=self.job_context.issue_key,
issue_title=self.job_context.issue_title,
issue_description=self.job_context.issue_description,
user_message=self.job_context.user_msg or '',
)
return instructions, user_msg
async def create_or_update_conversation(self, jinja_env: Environment) -> str:
"""Create a new Linear conversation"""
if not self.selected_repo:
raise StartingConvoException('No repository selected for this conversation')
provider_tokens = await self.saas_user_auth.get_provider_tokens()
user_secrets = await self.saas_user_auth.get_secrets()
instructions, user_msg = await self._get_instructions(jinja_env)
try:
agent_loop_info = await create_new_conversation(
user_id=self.linear_user.keycloak_user_id,
git_provider_tokens=provider_tokens,
selected_repository=self.selected_repo,
selected_branch=None,
initial_user_msg=user_msg,
conversation_instructions=instructions,
image_urls=None,
replay_json=None,
conversation_trigger=ConversationTrigger.LINEAR,
custom_secrets=user_secrets.custom_secrets if user_secrets else None,
)
self.conversation_id = agent_loop_info.conversation_id
logger.info(f'[Linear] Created conversation {self.conversation_id}')
# Store Linear conversation mapping
linear_conversation = LinearConversation(
conversation_id=self.conversation_id,
issue_id=self.job_context.issue_id,
issue_key=self.job_context.issue_key,
linear_user_id=self.linear_user.id,
)
await integration_store.create_conversation(linear_conversation)
return self.conversation_id
except Exception as e:
logger.error(
f'[Linear] Failed to create conversation: {str(e)}', exc_info=True
)
raise StartingConvoException(f'Failed to create conversation: {str(e)}')
def get_response_msg(self) -> str:
"""Get the response message to send back to Linear"""
conversation_link = CONVERSATION_URL.format(self.conversation_id)
return f"I'm on it! {self.job_context.display_name} can [track my progress here]({conversation_link})."
@dataclass
class LinearExistingConversationView(LinearViewInterface):
job_context: JobContext
saas_user_auth: UserAuth
linear_user: LinearUser
linear_workspace: LinearWorkspace
selected_repo: str | None
conversation_id: str
async def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
"""Instructions passed when conversation is first initialized"""
user_msg_template = jinja_env.get_template('linear_existing_conversation.j2')
user_msg = user_msg_template.render(
issue_key=self.job_context.issue_key,
user_message=self.job_context.user_msg or '',
issue_title=self.job_context.issue_title,
issue_description=self.job_context.issue_description,
)
return '', user_msg
async def create_or_update_conversation(self, jinja_env: Environment) -> str:
"""Update an existing Linear conversation"""
user_id = self.linear_user.keycloak_user_id
try:
conversation_store = await ConversationStoreImpl.get_instance(
config, user_id
)
try:
await conversation_store.get_metadata(self.conversation_id)
except FileNotFoundError:
raise StartingConvoException('Conversation no longer exists.')
provider_tokens = await self.saas_user_auth.get_provider_tokens()
if provider_tokens is None:
raise ValueError('Could not load provider tokens')
providers_set = list(provider_tokens.keys())
conversation_init_data = await setup_init_conversation_settings(
user_id, self.conversation_id, providers_set
)
# Either join ongoing conversation, or restart the conversation
agent_loop_info = await conversation_manager.maybe_start_agent_loop(
self.conversation_id, conversation_init_data, user_id
)
if agent_loop_info.event_store is None:
raise StartingConvoException('Event store not available')
final_agent_observation = get_final_agent_observation(
agent_loop_info.event_store
)
agent_state = (
None
if len(final_agent_observation) == 0
else final_agent_observation[0].agent_state
)
if not agent_state or agent_state == AgentState.LOADING:
raise StartingConvoException('Conversation is still starting')
_, user_msg = await self._get_instructions(jinja_env)
user_message_event = MessageAction(content=user_msg)
await conversation_manager.send_event_to_conversation(
self.conversation_id, event_to_dict(user_message_event)
)
return self.conversation_id
except Exception as e:
logger.error(
f'[Linear] Failed to create conversation: {str(e)}', exc_info=True
)
raise StartingConvoException(f'Failed to create conversation: {str(e)}')
def get_response_msg(self) -> str:
"""Get the response message to send back to Linear"""
conversation_link = CONVERSATION_URL.format(self.conversation_id)
return f"I'm on it! {self.job_context.display_name} can [continue tracking my progress here]({conversation_link})."
class LinearFactory:
"""Factory for creating Linear views based on message content"""
@staticmethod
async def create_linear_view_from_payload(
job_context: JobContext,
saas_user_auth: UserAuth,
linear_user: LinearUser,
linear_workspace: LinearWorkspace,
) -> LinearViewInterface:
"""Create appropriate Linear view based on the message and user state"""
if not linear_user or not saas_user_auth or not linear_workspace:
raise StartingConvoException(
'User not authenticated with Linear integration'
)
conversation = await integration_store.get_user_conversations_by_issue_id(
job_context.issue_id, linear_user.id
)
if conversation:
logger.info(
f'[Linear] Found existing conversation for issue {job_context.issue_id}'
)
return LinearExistingConversationView(
job_context=job_context,
saas_user_auth=saas_user_auth,
linear_user=linear_user,
linear_workspace=linear_workspace,
selected_repo=None,
conversation_id=conversation.conversation_id,
)
return LinearNewConversationView(
job_context=job_context,
saas_user_auth=saas_user_auth,
linear_user=linear_user,
linear_workspace=linear_workspace,
selected_repo=None, # Will be set later after repo inference
conversation_id='', # Will be set when conversation is created
)

View File

@@ -1,9 +1,7 @@
from uuid import UUID
from openhands.app_server.user.user_context import UserContext
from openhands.app_server.user.user_models import UserInfo
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderHandler
from openhands.integrations.service_types import ProviderType, UserGitInfo
from openhands.integrations.service_types import ProviderType
from openhands.sdk.secret import SecretSource, StaticSecret
from openhands.server.user_auth.user_auth import UserAuth
@@ -14,10 +12,8 @@ class ResolverUserContext(UserContext):
def __init__(
self,
saas_user_auth: UserAuth,
resolver_org_id: UUID | None = None,
):
self.saas_user_auth = saas_user_auth
self.resolver_org_id = resolver_org_id
self._provider_handler: ProviderHandler | None = None
async def get_user_id(self) -> str | None:
@@ -85,6 +81,3 @@ class ResolverUserContext(UserContext):
async def get_mcp_api_key(self) -> str | None:
return await self.saas_user_auth.get_mcp_api_key()
async def get_user_git_info(self) -> UserGitInfo | None:
return await self.saas_user_auth.get_user_git_info()

View File

@@ -1,78 +0,0 @@
"""Resolve which OpenHands organization workspace a resolver conversation should be created in.
This module provides a reusable utility for routing resolver conversations
(GitHub, GitLab, Bitbucket, Slack, etc.) to the correct OpenHands organization
workspace based on claimed Git organizations.
"""
from uuid import UUID
from storage.org_git_claim_store import OrgGitClaimStore
from storage.org_member_store import OrgMemberStore
from openhands.core.logger import openhands_logger as logger
async def resolve_org_for_repo(
provider: str,
full_repo_name: str,
keycloak_user_id: str | None = None,
) -> UUID | None:
"""Determine the OpenHands org_id for a resolver conversation.
If the repo's git organization is claimed by an OpenHands org, returns the
claiming org's ID. When keycloak_user_id is provided, also verifies the user
is a member of that org.
Args:
provider: Git provider name ("github", "gitlab", "bitbucket")
full_repo_name: Full repository name (e.g., "OpenHands/foo")
keycloak_user_id: The user's Keycloak UUID string (optional). If provided,
membership is verified before returning the org_id.
Returns:
The org_id if the repo's org is claimed (and user is a member when
keycloak_user_id is provided), else None
"""
git_org = full_repo_name.split('/')[0].lower()
try:
claim = await OrgGitClaimStore.get_claim_by_provider_and_git_org(
provider, git_org
)
if not claim:
logger.debug(
f'[OrgResolver] No claim found for {provider}/{git_org}',
)
return None
# Skip membership check if no user_id provided
if keycloak_user_id is None:
logger.info(
f'[OrgResolver] Resolved org {claim.org_id} '
f'for {provider}/{git_org} (no user membership check)',
)
return claim.org_id
member = await OrgMemberStore.get_org_member(
claim.org_id, UUID(keycloak_user_id)
)
if not member:
logger.debug(
f'[OrgResolver] User {keycloak_user_id} is not a member of org '
f'{claim.org_id} (claimed {provider}/{git_org}). '
f'Falling back to personal workspace.',
)
return None
logger.info(
f'[OrgResolver] Routing conversation to org {claim.org_id} '
f'for {provider}/{git_org} (user {keycloak_user_id})',
)
return claim.org_id
except Exception as e:
logger.error(
f'[OrgResolver] Error resolving org for {provider}/{git_org}: {e}',
exc_info=True,
)
return None

View File

@@ -24,6 +24,7 @@ from integrations.utils import (
from integrations.v1_utils import get_saas_user_auth
from jinja2 import Environment, FileSystemLoader
from server.constants import SLACK_CLIENT_ID
from server.utils.conversation_callback_utils import register_callback_processor
from slack_sdk.oauth import AuthorizeUrlGenerator
from slack_sdk.web.async_client import AsyncWebClient
from sqlalchemy import select
@@ -238,14 +239,12 @@ class SlackManager(Manager[SlackViewInterface]):
def _generate_repo_selection_form(
self, message_ts: str, thread_ts: str | None
) -> list[dict[str, Any]]:
"""Generate a repo selection form with immediate "No Repository" button and search dropdown.
"""Generate a repo selection form using external_select for dynamic loading.
This form provides two options side-by-side:
1. A "No Repository" button - immediately clickable without any loading
2. An external_select dropdown - for searching repositories dynamically
This design ensures "No Repository" is always immediately available while
still providing full dynamic search capability for repositories.
This uses Slack's external_select element which allows:
- Type-ahead search for repositories
- Dynamic loading of options from an external endpoint
- Support for users with many repositories (no 100 option limit)
Args:
message_ts: The message timestamp for tracking
@@ -267,22 +266,12 @@ class SlackManager(Manager[SlackViewInterface]):
'type': 'section',
'text': {
'type': 'mrkdwn',
'text': 'Select a repository or continue without one:',
'text': 'Type to search your repositories:',
},
},
{
'type': 'actions',
'elements': [
{
'type': 'button',
'action_id': f'no_repository:{message_ts}:{thread_ts}',
'text': {
'type': 'plain_text',
'text': 'No Repository',
'emoji': True,
},
'value': '-',
},
{
'type': 'external_select',
'action_id': f'repository_select:{message_ts}:{thread_ts}',
@@ -290,8 +279,8 @@ class SlackManager(Manager[SlackViewInterface]):
'type': 'plain_text',
'text': 'Search repositories...',
},
'min_query_length': 0,
},
'min_query_length': 0, # Load initial options immediately
}
],
},
]
@@ -299,11 +288,8 @@ class SlackManager(Manager[SlackViewInterface]):
def _build_repo_options(self, repos: list[Repository]) -> list[dict[str, Any]]:
"""Build Slack options list from repositories.
Returns up to 100 repositories formatted as Slack options
(Slack has a 100 option limit for external_select).
Note: "No Repository" is handled by a separate button in the form,
so it's not included in the dropdown options.
Always includes a "No Repository" option at the top, followed by up to 99
repositories (Slack has a 100 option limit for external_select).
Args:
repos: List of Repository objects
@@ -311,7 +297,13 @@ class SlackManager(Manager[SlackViewInterface]):
Returns:
List of Slack option objects
"""
return [
options: list[dict[str, Any]] = [
{
'text': {'type': 'plain_text', 'text': 'No Repository'},
'value': '-',
}
]
options.extend(
{
'text': {
'type': 'plain_text',
@@ -319,8 +311,9 @@ class SlackManager(Manager[SlackViewInterface]):
},
'value': repo.full_name,
}
for repo in repos[:100]
]
for repo in repos[:99] # Leave room for "No Repository" option
)
return options
async def search_repos_for_slack(
self, user_auth: UserAuth, query: str, per_page: int = 20
@@ -370,69 +363,33 @@ class SlackManager(Manager[SlackViewInterface]):
SlackError(SlackErrorCode.UNEXPECTED_ERROR),
)
def _parse_form_action(self, action: dict) -> tuple[str, str | None, str] | None:
"""Parse action payload and extract message_ts, thread_ts, and selected value.
This handles the different payload structures for button clicks vs dropdown
selections in the repository selection form.
Args:
action: The action object from the Slack payload
Returns:
Tuple of (message_ts, thread_ts, selected_value) if action is recognized,
None if the action_id is unknown.
"""
action_id = action['action_id']
if action_id.startswith('no_repository:'):
# Button click - value is in 'value' field
attribs = action_id.split('no_repository:')[-1]
selected_value = action.get('value', '-')
elif action_id.startswith('repository_select:'):
# Dropdown selection - value is in 'selected_option'
attribs = action_id.split('repository_select:')[-1]
selected_value = action['selected_option']['value']
else:
return None
message_ts, thread_ts = attribs.split(':')
thread_ts = None if thread_ts == 'None' else thread_ts
return message_ts, thread_ts, selected_value
async def receive_form_interaction(self, slack_payload: dict):
"""Process a Slack form interaction (repository selection or button click).
"""Process a Slack form interaction (repository selection).
This handles the block_actions payload when a user interacts with the
repository selection form. It can handle:
- "No Repository" button click: proceeds with conversation without a repo
- Repository selection from dropdown: proceeds with the selected repo
This handles the block_actions payload when a user selects a repository
from the dropdown form. It retrieves the original user message from Redis
and delegates to receive_message for processing.
Args:
slack_payload: The raw Slack interaction payload
"""
# Extract fields from the Slack interaction payload
action = slack_payload['actions'][0]
selected_repository = slack_payload['actions'][0]['selected_option']['value']
if selected_repository == '-':
selected_repository = None
slack_user_id = slack_payload['user']['id']
channel_id = slack_payload['container']['channel_id']
team_id = slack_payload['team']['id']
# Parse the action to extract message_ts, thread_ts, and selected value
parsed = self._parse_form_action(action)
if parsed is None:
logger.warning(
'slack_unknown_action_id',
extra={
'action_id': action['action_id'],
'slack_user_id': slack_user_id,
},
)
return
# Get original message_ts and thread_ts from action_id
attribs = slack_payload['actions'][0]['action_id'].split('repository_select:')[
-1
]
message_ts, thread_ts = attribs.split(':')
thread_ts = None if thread_ts == 'None' else thread_ts
message_ts, thread_ts, selected_value = parsed
# Build partial payload for error handling
# Build partial payload for error handling during Redis retrieval
payload = {
'team_id': team_id,
'channel_id': channel_id,
@@ -441,9 +398,6 @@ class SlackManager(Manager[SlackViewInterface]):
'thread_ts': thread_ts,
}
# Convert "-" (No Repository) to None
selected_repository = None if selected_value == '-' else selected_value
# Retrieve the original user message from Redis
try:
user_msg = await self._retrieve_user_msg_for_form(message_ts, thread_ts)
@@ -697,7 +651,11 @@ class SlackManager(Manager[SlackViewInterface]):
return False
async def start_job(self, slack_view: SlackViewInterface) -> None:
"""Start a Slack job using V1 app conversation system."""
# Importing here prevents circular import
from server.conversation_callback_processor.slack_callback_processor import (
SlackCallbackProcessor,
)
try:
msg_info = None
user_info = slack_view.slack_to_openhands_user
@@ -714,7 +672,37 @@ class SlackManager(Manager[SlackViewInterface]):
f'[Slack] Created conversation {conversation_id} for user {user_info.slack_display_name}'
)
# V1 callback processors are registered by the view during conversation creation
# Only add SlackCallbackProcessor for new conversations (not updates) and non-v1 conversations
if (
not isinstance(slack_view, SlackUpdateExistingConversationView)
and not slack_view.v1_enabled
):
# We don't re-subscribe for follow up messages from slack.
# Summaries are generated for every messages anyways, we only need to do
# this subscription once for the event which kicked off the job.
processor = SlackCallbackProcessor(
slack_user_id=slack_view.slack_user_id,
channel_id=slack_view.channel_id,
message_ts=slack_view.message_ts,
thread_ts=slack_view.thread_ts,
team_id=slack_view.team_id,
)
# Register the callback processor
register_callback_processor(conversation_id, processor)
logger.info(
f'[Slack] Created callback processor for conversation {conversation_id}'
)
elif isinstance(slack_view, SlackUpdateExistingConversationView):
logger.info(
f'[Slack] Skipping callback processor for existing conversation update {conversation_id}'
)
elif slack_view.v1_enabled:
logger.info(
f'[Slack] Skipping callback processor for v1 conversation {conversation_id}'
)
msg_info = slack_view.get_response_msg()

View File

@@ -40,20 +40,16 @@ class SlackV1CallbackProcessor(EventCallbackProcessor):
event: Event,
) -> EventCallbackResult | None:
"""Process events for Slack V1 integration."""
# Only handle ConversationStateUpdateEvent for execution_status
# Only handle ConversationStateUpdateEvent
if not isinstance(event, ConversationStateUpdateEvent):
return None
if event.key != 'execution_status':
# Only act when execution has finished
if not (event.key == 'execution_status' and event.value == 'finished'):
return None
# Log ALL terminal states for monitoring (finished, error, stuck)
_logger.info('[Slack V1] Callback agent state was %s', event)
# Only request summary when execution has finished successfully
if event.value != 'finished':
return None
try:
summary = await self._request_summary(conversation_id)
await self._post_summary_to_slack(summary)
@@ -111,11 +107,9 @@ class SlackV1CallbackProcessor(EventCallbackProcessor):
try:
# Post the summary as a threaded reply
# Use markdown_text instead of text to properly render standard Markdown
# (e.g., **bold**, [link](url)) which is used throughout the codebase
response = client.chat_postMessage(
channel=channel_id,
markdown_text=summary,
text=summary,
thread_ts=thread_ts,
unfurl_links=False,
unfurl_media=False,

View File

@@ -4,7 +4,6 @@ from uuid import UUID, uuid4
from integrations.models import Message
from integrations.resolver_context import ResolverUserContext
from integrations.resolver_org_router import resolve_org_for_repo
from integrations.slack.slack_types import (
SlackMessageView,
SlackViewInterface,
@@ -14,6 +13,7 @@ from integrations.slack.slack_v1_callback_processor import SlackV1CallbackProces
from integrations.utils import (
CONVERSATION_URL,
ENABLE_V1_SLACK_RESOLVER,
get_final_agent_observation,
get_user_v1_enabled_setting,
)
from jinja2 import Environment
@@ -33,8 +33,16 @@ from openhands.app_server.sandbox.sandbox_models import SandboxStatus
from openhands.app_server.services.injector import InjectorState
from openhands.app_server.user.specifiy_user_context import USER_CONTEXT_ATTR
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.provider import ProviderHandler
from openhands.core.schema.agent import AgentState
from openhands.events.action import MessageAction
from openhands.events.serialization.event import event_to_dict
from openhands.integrations.provider import ProviderHandler, ProviderType
from openhands.sdk import TextContent
from openhands.server.services.conversation_service import (
create_new_conversation,
setup_init_conversation_settings,
)
from openhands.server.shared import ConversationStoreImpl, config, conversation_manager
from openhands.server.user_auth.user_auth import UserAuth
from openhands.storage.data_models.conversation_metadata import (
ConversationTrigger,
@@ -192,26 +200,56 @@ class SlackNewConversationView(SlackViewInterface):
self._verify_necessary_values_are_set()
provider_tokens = await self.saas_user_auth.get_provider_tokens()
user_secrets = await self.saas_user_auth.get_secrets()
# Determine git provider from repository (needed for both org routing and conversation creation)
self._resolved_git_provider = None
# Check if V1 conversations are enabled for this user
self.v1_enabled = await is_v1_enabled_for_slack_resolver(
self.slack_to_openhands_user.keycloak_user_id
)
if self.v1_enabled:
# Use V1 app conversation service
await self._create_v1_conversation(jinja)
return self.conversation_id
else:
# Use existing V0 conversation service
await self._create_v0_conversation(jinja, provider_tokens, user_secrets)
return self.conversation_id
async def _create_v0_conversation(
self, jinja: Environment, provider_tokens, user_secrets
) -> None:
"""Create conversation using the legacy V0 system."""
user_instructions, conversation_instructions = await self._get_instructions(
jinja
)
# Determine git provider from repository
git_provider = None
if self.selected_repo and provider_tokens:
provider_handler = ProviderHandler(provider_tokens)
repository = await provider_handler.verify_repo_provider(self.selected_repo)
self._resolved_git_provider = repository.git_provider
git_provider = repository.git_provider
# Resolve target org based on claimed git organizations
self.resolved_org_id = None
if self._resolved_git_provider and self.selected_repo:
self.resolved_org_id = await resolve_org_for_repo(
provider=self._resolved_git_provider.value,
full_repo_name=self.selected_repo,
keycloak_user_id=self.slack_to_openhands_user.keycloak_user_id,
)
agent_loop_info = await create_new_conversation(
user_id=self.slack_to_openhands_user.keycloak_user_id,
git_provider_tokens=provider_tokens,
selected_repository=self.selected_repo,
selected_branch=None,
initial_user_msg=user_instructions,
conversation_instructions=(
conversation_instructions if conversation_instructions else None
),
image_urls=None,
replay_json=None,
conversation_trigger=ConversationTrigger.SLACK,
custom_secrets=user_secrets.custom_secrets if user_secrets else None,
git_provider=git_provider,
)
# V0 conversation path has been removed - all conversations use V1 app conversation service
await self._create_v1_conversation(jinja)
return self.conversation_id
self.conversation_id = agent_loop_info.conversation_id
logger.info(f'[Slack]: Created V0 conversation: {self.conversation_id}')
await self.save_slack_convo(v1_enabled=False)
async def _create_v1_conversation(self, jinja: Environment) -> None:
"""Create conversation using the new V1 app conversation system."""
@@ -227,8 +265,13 @@ class SlackNewConversationView(SlackViewInterface):
# Create the Slack V1 callback processor
slack_callback_processor = self._create_slack_v1_callback_processor()
# Use git provider resolved in create_or_update_conversation
git_provider = self._resolved_git_provider
# Determine git provider from repository
git_provider = None
provider_tokens = await self.saas_user_auth.get_provider_tokens()
if self.selected_repo and provider_tokens:
provider_handler = ProviderHandler(provider_tokens)
repository = await provider_handler.verify_repo_provider(self.selected_repo)
git_provider = ProviderType(repository.git_provider.value)
# Get the app conversation service and start the conversation
injector_state = InjectorState()
@@ -249,10 +292,7 @@ class SlackNewConversationView(SlackViewInterface):
)
# Set up the Slack user context for the V1 system
slack_user_context = ResolverUserContext(
saas_user_auth=self.saas_user_auth,
resolver_org_id=self.resolved_org_id,
)
slack_user_context = ResolverUserContext(saas_user_auth=self.saas_user_auth)
setattr(injector_state, USER_CONTEXT_ATTR, slack_user_context)
async with get_app_conversation_service(
@@ -305,6 +345,53 @@ class SlackUpdateExistingConversationView(SlackNewConversationView):
return user_message, ''
async def send_message_to_v0_conversation(self, jinja: Environment):
user_info: SlackUser = self.slack_to_openhands_user
user_id = user_info.keycloak_user_id
saas_user_auth: UserAuth = self.saas_user_auth
provider_tokens = await saas_user_auth.get_provider_tokens()
try:
conversation_store = await ConversationStoreImpl.get_instance(
config, user_id
)
await conversation_store.get_metadata(self.conversation_id)
except FileNotFoundError:
raise StartingConvoException('Conversation no longer exists.')
# Should we raise here if there are no provider tokens?
providers_set = list(provider_tokens.keys()) if provider_tokens else []
conversation_init_data = await setup_init_conversation_settings(
user_id, self.conversation_id, providers_set
)
# Either join ongoing conversation, or restart the conversation
agent_loop_info = await conversation_manager.maybe_start_agent_loop(
self.conversation_id, conversation_init_data, user_id
)
if agent_loop_info.event_store is None:
raise StartingConvoException('Event store not available')
final_agent_observation = get_final_agent_observation(
agent_loop_info.event_store
)
agent_state = (
None
if len(final_agent_observation) == 0
else final_agent_observation[0].agent_state
)
if not agent_state or agent_state == AgentState.LOADING:
raise StartingConvoException('Conversation is still starting')
instructions, _ = await self._get_instructions(jinja)
user_msg = MessageAction(content=instructions)
await conversation_manager.send_event_to_conversation(
self.conversation_id, event_to_dict(user_msg)
)
async def send_message_to_v1_conversation(self, jinja: Environment):
"""Send a message to a v1 conversation using the agent server API."""
# Import services within the method to avoid circular imports
@@ -399,7 +486,7 @@ class SlackUpdateExistingConversationView(SlackNewConversationView):
raise Exception(f'Failed to send message to v1 conversation: {str(e)}')
async def create_or_update_conversation(self, jinja: Environment) -> str:
"""Send new user message to conversation."""
"""Send new user message to converation"""
user_info: SlackUser = self.slack_to_openhands_user
user_id = user_info.keycloak_user_id
@@ -411,8 +498,10 @@ class SlackUpdateExistingConversationView(SlackNewConversationView):
f'{user_info.slack_display_name} is not authorized to send messages to this conversation.'
)
# All conversations use V1 app conversation system
await self.send_message_to_v1_conversation(jinja)
if self.slack_conversation.v1_enabled:
await self.send_message_to_v1_conversation(jinja)
else:
await self.send_message_to_v0_conversation(jinja)
return self.conversation_id

View File

@@ -59,11 +59,11 @@ async def find_or_create_customer_by_user_id(user_id: str) -> dict | None:
extra={'user_id': user_id, 'org_id': str(org.id)},
)
# Create the customer in stripe (only include email if available)
create_params: dict = {'metadata': {'org_id': str(org.id)}}
if org.contact_email:
create_params['email'] = org.contact_email
customer = await stripe.Customer.create_async(**create_params)
# Create the customer in stripe
customer = await stripe.Customer.create_async(
email=org.contact_email,
metadata={'org_id': str(org.id)},
)
# Save the stripe customer in the local db
async with a_session_maker() as session:
@@ -100,28 +100,27 @@ async def has_payment_method_by_user_id(user_id: str) -> bool:
return bool(payment_methods.data)
async def migrate_customer(session, user_id: str, org: Org):
result = await session.execute(
select(StripeCustomer).where(StripeCustomer.keycloak_user_id == user_id)
)
stripe_customer = result.scalar_one_or_none()
if stripe_customer is None:
return
stripe_customer.org_id = org.id
# Only include email if available to avoid sending empty strings to Stripe
modify_params: dict = {
'id': stripe_customer.stripe_customer_id,
'metadata': {'user_id': '', 'org_id': str(org.id)},
}
if org.contact_email:
modify_params['email'] = org.contact_email
customer = await stripe.Customer.modify_async(**modify_params)
async def migrate_customer(user_id: str, org: Org):
async with a_session_maker() as session:
result = await session.execute(
select(StripeCustomer).where(StripeCustomer.keycloak_user_id == user_id)
)
stripe_customer = result.scalar_one_or_none()
if stripe_customer is None:
return
stripe_customer.org_id = org.id
customer = await stripe.Customer.modify_async(
id=stripe_customer.stripe_customer_id,
email=org.contact_email,
metadata={'user_id': '', 'org_id': str(org.id)},
)
logger.info(
'migrated_customer',
extra={
'user_id': user_id,
'org_id': str(org.id),
'stripe_customer_id': customer.id,
},
)
logger.info(
'migrated_customer',
extra={
'user_id': user_id,
'org_id': str(org.id),
'stripe_customer_id': customer.id,
},
)
await session.commit()

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
import json
import os
import re
from typing import TYPE_CHECKING
from jinja2 import Environment, FileSystemLoader
from server.constants import WEB_HOST
@@ -19,6 +20,12 @@ from openhands.events.event_filter import EventFilter
from openhands.events.event_store_abc import EventStoreABC
from openhands.events.observation.agent import AgentStateChangedObservation
from openhands.integrations.service_types import Repository
from openhands.storage.data_models.conversation_status import ConversationStatus
if TYPE_CHECKING:
from openhands.server.conversation_manager.conversation_manager import (
ConversationManager,
)
# ---- DO NOT REMOVE ----
# WARNING: Langfuse depends on the WEB_HOST environment variable being set to track events.
@@ -356,6 +363,43 @@ def extract_summary_from_event_store(
return summary_event.final_thought
async def get_event_store_from_conversation_manager(
conversation_manager: ConversationManager, conversation_id: str
) -> EventStoreABC:
agent_loop_infos = await conversation_manager.get_agent_loop_info(
filter_to_sids={conversation_id}
)
if not agent_loop_infos or agent_loop_infos[0].status != ConversationStatus.RUNNING:
raise RuntimeError(f'conversation_not_running:{conversation_id}')
event_store = agent_loop_infos[0].event_store
if not event_store:
raise RuntimeError(f'event_store_missing:{conversation_id}')
return event_store
async def get_last_user_msg_from_conversation_manager(
conversation_manager: ConversationManager, conversation_id: str
):
event_store = await get_event_store_from_conversation_manager(
conversation_manager, conversation_id
)
return get_last_user_msg(event_store)
async def extract_summary_from_conversation_manager(
conversation_manager: ConversationManager, conversation_id: str
) -> str:
"""
Get agent summary or alternative message depending on current AgentState
"""
event_store = await get_event_store_from_conversation_manager(
conversation_manager, conversation_id
)
summary = extract_summary_from_event_store(event_store, conversation_id)
return append_conversation_footer(summary, conversation_id)
def append_conversation_footer(message: str, conversation_id: str) -> str:
"""
Append a small footer with the conversation URL to a message.
@@ -392,13 +436,12 @@ def infer_repo_from_message(user_msg: str) -> list[str]:
r'(?=\s|$|}}|[\]\)\'",.:`])' # right boundary
)
# Use dict to preserve ordering
matches: dict[str, bool] = {}
matches: list[str] = []
# Git URLs first (highest priority)
for owner, repo in re.findall(git_url_pattern, normalized_msg):
repo = re.sub(r'\.git$', '', repo)
matches[f'{owner}/{repo}'] = True
matches.append(f'{owner}/{repo}')
# Direct mentions
for owner, repo in re.findall(direct_pattern, normalized_msg):
@@ -414,10 +457,9 @@ def infer_repo_from_message(user_msg: str) -> list[str]:
continue
if full_match not in matches:
matches[full_match] = True
matches.append(full_match)
result = list(matches)
return result
return matches
def filter_potential_repos_by_user_msg(
@@ -553,18 +595,3 @@ def markdown_to_jira_markup(markdown_text: str) -> str:
# Log the error but don't raise it - return original text as fallback
print(f'Error converting markdown to Jira markup: {str(e)}')
return markdown_text or ''
def format_jira_comment_body(message: str) -> dict:
"""Format a message as a Jira API v2 comment body.
This helper ensures consistent comment formatting across all Jira integrations.
Converts markdown to Jira Wiki Markup and wraps in the expected API structure.
Args:
message: The message content to send (may contain markdown)
Returns:
dict: The comment body in Jira API v2 format {'body': ...}
"""
return {'body': markdown_to_jira_markup(message)}

View File

@@ -6,15 +6,9 @@ from logging.config import fileConfig
# These plugin setup messages would otherwise appear before logging is configured
logging.getLogger('alembic.runtime.plugins').setLevel(logging.WARNING)
# Prevent SQLAlchemy engine from logging SQL results at DEBUG level, which can
# leak sensitive column data (e.g. API keys, tokens) into log aggregators.
# This is set before any engine is created so it takes effect immediately.
logging.getLogger('sqlalchemy.engine').setLevel(logging.WARNING)
logging.getLogger('sqlalchemy.engine.Engine').setLevel(logging.WARNING)
from alembic import context # noqa: E402
from google.cloud.sql.connector import Connector # noqa: E402
from sqlalchemy import create_engine, text # noqa: E402
from sqlalchemy import create_engine # noqa: E402
from storage.base import Base # noqa: E402
target_metadata = Base.metadata
@@ -76,12 +70,6 @@ config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Re-apply SQLAlchemy engine log suppression after fileConfig, which may override
# our earlier settings from alembic.ini. This ensures DEBUG-level SQL result logging
# is always suppressed, preventing sensitive data from leaking into log aggregators.
logging.getLogger('sqlalchemy.engine').setLevel(logging.WARNING)
logging.getLogger('sqlalchemy.engine.Engine').setLevel(logging.WARNING)
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
@@ -121,10 +109,6 @@ def run_migrations_online() -> None:
version_table_schema=target_metadata.schema,
)
# Lock number must be unique — md5 hash of 'openhands_enterprise_migrations'
# Lock is released when the connection context manager exits
connection.execute(text('SELECT pg_advisory_lock(3617572382373537863)'))
with context.begin_transaction():
context.run_migrations()

View File

@@ -1,28 +0,0 @@
"""Add disabled_skills to user_settings.
Revision ID: 102
Revises: 101
Create Date: 2026-02-25
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '102'
down_revision: Union[str, None] = '101'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
'user_settings', sa.Column('disabled_skills', sa.JSON(), nullable=True)
)
def downgrade() -> None:
op.drop_column('user_settings', 'disabled_skills')

View File

@@ -1,41 +0,0 @@
"""Add mcp_config to org_member for user-specific MCP settings.
Revision ID: 103
Revises: 102
Create Date: 2026-03-26
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '103'
down_revision: Union[str, None] = '102'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('org_member', sa.Column('mcp_config', sa.JSON(), nullable=True))
# Migrate existing org-level MCP configs to all members in each org.
# This preserves existing configurations while transitioning to user-specific settings.
# Uses server-side SQL to avoid pulling sensitive config data into the Python process.
op.execute(
sa.text(
"""
UPDATE org_member
SET mcp_config = org.mcp_config
FROM org
WHERE org_member.org_id = org.id
AND org.mcp_config IS NOT NULL
"""
)
)
def downgrade() -> None:
op.drop_column('org_member', 'mcp_config')

View File

@@ -1,29 +0,0 @@
"""Add disabled_skills column to user table.
Migration 102 added disabled_skills to the legacy user_settings table,
but the active SaaS flow (SaasSettingsStore) reads from/writes to the
user table. This migration adds the column where it is actually needed.
Revision ID: 104
Revises: 103
Create Date: 2026-03-31
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '104'
down_revision: Union[str, None] = '103'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('user', sa.Column('disabled_skills', sa.JSON(), nullable=True))
def downgrade() -> None:
op.drop_column('user', 'disabled_skills')

View File

@@ -1,37 +0,0 @@
"""Create org_git_claim table for tracking Git organization claims.
Revision ID: 105
Revises: 104
Create Date: 2026-04-01
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '105'
down_revision: Union[str, None] = '104'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'org_git_claim',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('org_id', sa.UUID(), nullable=False),
sa.Column('provider', sa.String(), nullable=False),
sa.Column('git_organization', sa.String(), nullable=False),
sa.Column('claimed_by', sa.UUID(), nullable=False),
sa.Column('claimed_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['org_id'], ['org.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['claimed_by'], ['user.id']),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('provider', 'git_organization', name='uq_provider_git_org'),
)
def downgrade() -> None:
op.drop_table('org_git_claim')

View File

@@ -1,32 +0,0 @@
"""Add tags column to conversation_metadata table.
Tags store key-value pairs for automation context (trigger type, automation_id),
skills used, and other metadata. This enables querying conversations by
automation source and associating SDK-provided context with conversations.
Revision ID: 106
Revises: 105
Create Date: 2026-03-31
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '106'
down_revision: Union[str, None] = '105'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
'conversation_metadata',
sa.Column('tags', sa.JSON(), nullable=True),
)
def downgrade() -> None:
op.drop_column('conversation_metadata', 'tags')

View File

@@ -1,31 +0,0 @@
"""Add onboarding_completed column to user table.
Tracks whether a user has completed the onboarding flow.
Used to redirect new SaaS users to /onboarding after accepting TOS.
Revision ID: 107
Revises: 106
Create Date: 2026-03-31
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '107'
down_revision: Union[str, None] = '106'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
'user',
sa.Column('onboarding_completed', sa.Boolean(), nullable=True, default=False),
)
def downgrade() -> None:
op.drop_column('user', 'onboarding_completed')

View File

@@ -1,563 +0,0 @@
"""Add agent_settings columns to enterprise settings tables.
Revision ID: 108
Revises: 107
Create Date: 2026-03-22 00:00:00.000000
"""
from collections.abc import Mapping
from typing import Any, Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '108'
down_revision: Union[str, None] = '107'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
_EMPTY_JSON = sa.text("'{}'::json")
def _deep_merge(
base: dict[str, Any], overrides: Mapping[str, Any] | None
) -> dict[str, Any]:
merged = dict(base)
for key, value in (overrides or {}).items():
existing = merged.get(key)
if isinstance(existing, dict) and isinstance(value, Mapping):
merged[key] = _deep_merge(existing, value)
else:
merged[key] = value
return merged
def _strip_none_and_empty(value: Any) -> Any:
if isinstance(value, Mapping):
cleaned: dict[str, Any] = {}
for key, item in value.items():
cleaned_item = _strip_none_and_empty(item)
if cleaned_item is None:
continue
if isinstance(cleaned_item, dict) and not cleaned_item:
continue
cleaned[key] = cleaned_item
return cleaned
return value
def _build_user_agent_settings(row: Mapping[str, Any]) -> dict[str, Any]:
generated = _strip_none_and_empty(
{
'schema_version': 1,
'agent': row['agent'],
'llm': {
'model': row['llm_model'],
'base_url': row['llm_base_url'],
},
'condenser': {
'enabled': row['enable_default_condenser'],
'max_size': row['condenser_max_size'],
},
'mcp_config': row['mcp_config'],
}
)
return _deep_merge(generated, row.get('agent_settings') or {})
def _build_user_conversation_settings(row: Mapping[str, Any]) -> dict[str, Any]:
generated = _strip_none_and_empty(
{
'max_iterations': row['max_iterations'],
'confirmation_mode': row['confirmation_mode'],
'security_analyzer': row['security_analyzer'],
}
)
return _deep_merge(generated, row.get('conversation_settings') or {})
def _build_org_member_agent_settings_diff(row: Mapping[str, Any]) -> dict[str, Any]:
generated = _strip_none_and_empty(
{
'schema_version': 1,
'llm': {
'model': row['llm_model'],
'base_url': row['llm_base_url'],
},
'mcp_config': row['mcp_config'],
}
)
return _deep_merge(generated, row.get('agent_settings_diff') or {})
def _build_org_member_conversation_settings_diff(
row: Mapping[str, Any],
) -> dict[str, Any]:
generated = _strip_none_and_empty({'max_iterations': row['max_iterations']})
return _deep_merge(generated, row.get('conversation_settings_diff') or {})
def _build_org_agent_settings(row: Mapping[str, Any]) -> dict[str, Any]:
generated = _strip_none_and_empty(
{
'schema_version': 1,
'agent': row['agent'],
'llm': {
'model': row['default_llm_model'],
'base_url': row['default_llm_base_url'],
},
'condenser': {
'enabled': row['enable_default_condenser'],
'max_size': row['condenser_max_size'],
},
'mcp_config': row['mcp_config'],
}
)
return _deep_merge(generated, row.get('agent_settings') or {})
def _build_org_conversation_settings(row: Mapping[str, Any]) -> dict[str, Any]:
generated = _strip_none_and_empty(
{
'max_iterations': row['default_max_iterations'],
'confirmation_mode': row['confirmation_mode'],
'security_analyzer': row['security_analyzer'],
}
)
return _deep_merge(generated, row.get('conversation_settings') or {})
def _get_nested_value(data: Mapping[str, Any] | None, *path: str) -> Any:
current: Any = data or {}
for key in path:
if not isinstance(current, Mapping) or key not in current:
return None
current = current[key]
return current
def _legacy_user_settings_values(row: Mapping[str, Any]) -> dict[str, Any]:
agent_settings = row.get('agent_settings') or {}
conversation_settings = row.get('conversation_settings') or {}
condenser_enabled = _get_nested_value(agent_settings, 'condenser', 'enabled')
return {
'agent': _get_nested_value(agent_settings, 'agent'),
'max_iterations': _get_nested_value(conversation_settings, 'max_iterations'),
'security_analyzer': _get_nested_value(
conversation_settings, 'security_analyzer'
),
'confirmation_mode': _get_nested_value(
conversation_settings, 'confirmation_mode'
),
'llm_model': _get_nested_value(agent_settings, 'llm', 'model'),
'llm_base_url': _get_nested_value(agent_settings, 'llm', 'base_url'),
'enable_default_condenser': (
True if condenser_enabled is None else condenser_enabled
),
'condenser_max_size': _get_nested_value(
agent_settings, 'condenser', 'max_size'
),
}
def _legacy_org_member_values(row: Mapping[str, Any]) -> dict[str, Any]:
agent_settings_diff = row.get('agent_settings_diff') or {}
conversation_settings_diff = row.get('conversation_settings_diff') or {}
return {
'llm_model': _get_nested_value(agent_settings_diff, 'llm', 'model'),
'llm_base_url': _get_nested_value(agent_settings_diff, 'llm', 'base_url'),
'max_iterations': _get_nested_value(
conversation_settings_diff, 'max_iterations'
),
'mcp_config': _get_nested_value(agent_settings_diff, 'mcp_config'),
}
def _legacy_org_values(row: Mapping[str, Any]) -> dict[str, Any]:
agent_settings = row.get('agent_settings') or {}
conversation_settings = row.get('conversation_settings') or {}
condenser_enabled = _get_nested_value(agent_settings, 'condenser', 'enabled')
return {
'agent': _get_nested_value(agent_settings, 'agent'),
'default_max_iterations': _get_nested_value(
conversation_settings, 'max_iterations'
),
'security_analyzer': _get_nested_value(
conversation_settings, 'security_analyzer'
),
'confirmation_mode': _get_nested_value(
conversation_settings, 'confirmation_mode'
),
'default_llm_model': _get_nested_value(agent_settings, 'llm', 'model'),
'default_llm_base_url': _get_nested_value(agent_settings, 'llm', 'base_url'),
'enable_default_condenser': (
True if condenser_enabled is None else condenser_enabled
),
'mcp_config': _get_nested_value(agent_settings, 'mcp_config'),
'condenser_max_size': _get_nested_value(
agent_settings, 'condenser', 'max_size'
),
}
def upgrade() -> None:
op.add_column(
'user_settings',
sa.Column(
'agent_settings', sa.JSON(), nullable=False, server_default=_EMPTY_JSON
),
)
op.add_column(
'user_settings',
sa.Column(
'conversation_settings',
sa.JSON(),
nullable=False,
server_default=_EMPTY_JSON,
),
)
op.add_column(
'org_member',
sa.Column(
'agent_settings_diff',
sa.JSON(),
nullable=False,
server_default=_EMPTY_JSON,
),
)
op.add_column(
'org_member',
sa.Column(
'conversation_settings_diff',
sa.JSON(),
nullable=False,
server_default=_EMPTY_JSON,
),
)
op.add_column(
'org',
sa.Column(
'agent_settings', sa.JSON(), nullable=False, server_default=_EMPTY_JSON
),
)
op.add_column(
'org',
sa.Column(
'conversation_settings',
sa.JSON(),
nullable=False,
server_default=_EMPTY_JSON,
),
)
op.add_column('org', sa.Column('_llm_api_key', sa.String(), nullable=True))
op.add_column(
'org_member',
sa.Column(
'has_custom_llm_api_key',
sa.Boolean(),
nullable=False,
server_default=sa.false(),
),
)
bind = op.get_bind()
user_settings_table = sa.table(
'user_settings',
sa.column('id', sa.Integer()),
sa.column('agent', sa.String()),
sa.column('max_iterations', sa.Integer()),
sa.column('security_analyzer', sa.String()),
sa.column('confirmation_mode', sa.Boolean()),
sa.column('llm_model', sa.String()),
sa.column('llm_base_url', sa.String()),
sa.column('enable_default_condenser', sa.Boolean()),
sa.column('condenser_max_size', sa.Integer()),
sa.column('mcp_config', sa.JSON()),
sa.column('agent_settings', sa.JSON()),
sa.column('conversation_settings', sa.JSON()),
)
user_settings_rows = bind.execute(
sa.select(
user_settings_table.c.id,
user_settings_table.c.agent,
user_settings_table.c.max_iterations,
user_settings_table.c.security_analyzer,
user_settings_table.c.confirmation_mode,
user_settings_table.c.llm_model,
user_settings_table.c.llm_base_url,
user_settings_table.c.enable_default_condenser,
user_settings_table.c.condenser_max_size,
user_settings_table.c.mcp_config,
user_settings_table.c.agent_settings,
user_settings_table.c.conversation_settings,
)
).mappings()
for row in user_settings_rows:
bind.execute(
user_settings_table.update()
.where(user_settings_table.c.id == row['id'])
.values(
agent_settings=_build_user_agent_settings(row),
conversation_settings=_build_user_conversation_settings(row),
)
)
org_member_table = sa.table(
'org_member',
sa.column('org_id', sa.Uuid()),
sa.column('user_id', sa.Uuid()),
sa.column('max_iterations', sa.Integer()),
sa.column('llm_model', sa.String()),
sa.column('llm_base_url', sa.String()),
sa.column('mcp_config', sa.JSON()),
sa.column('agent_settings_diff', sa.JSON()),
sa.column('conversation_settings_diff', sa.JSON()),
)
org_member_rows = bind.execute(
sa.select(
org_member_table.c.org_id,
org_member_table.c.user_id,
org_member_table.c.max_iterations,
org_member_table.c.llm_model,
org_member_table.c.llm_base_url,
org_member_table.c.mcp_config,
org_member_table.c.agent_settings_diff,
org_member_table.c.conversation_settings_diff,
)
).mappings()
for row in org_member_rows:
bind.execute(
org_member_table.update()
.where(org_member_table.c.org_id == row['org_id'])
.where(org_member_table.c.user_id == row['user_id'])
.values(
agent_settings_diff=_build_org_member_agent_settings_diff(row),
conversation_settings_diff=_build_org_member_conversation_settings_diff(
row
),
)
)
org_table = sa.table(
'org',
sa.column('id', sa.Uuid()),
sa.column('agent', sa.String()),
sa.column('default_max_iterations', sa.Integer()),
sa.column('security_analyzer', sa.String()),
sa.column('confirmation_mode', sa.Boolean()),
sa.column('default_llm_model', sa.String()),
sa.column('default_llm_base_url', sa.String()),
sa.column('enable_default_condenser', sa.Boolean()),
sa.column('mcp_config', sa.JSON()),
sa.column('condenser_max_size', sa.Integer()),
sa.column('agent_settings', sa.JSON()),
sa.column('conversation_settings', sa.JSON()),
)
org_rows = bind.execute(
sa.select(
org_table.c.id,
org_table.c.agent,
org_table.c.default_max_iterations,
org_table.c.security_analyzer,
org_table.c.confirmation_mode,
org_table.c.default_llm_model,
org_table.c.default_llm_base_url,
org_table.c.enable_default_condenser,
org_table.c.mcp_config,
org_table.c.condenser_max_size,
org_table.c.agent_settings,
org_table.c.conversation_settings,
)
).mappings()
for row in org_rows:
bind.execute(
org_table.update()
.where(org_table.c.id == row['id'])
.values(
agent_settings=_build_org_agent_settings(row),
conversation_settings=_build_org_conversation_settings(row),
)
)
op.alter_column('user_settings', 'agent_settings', server_default=None)
op.alter_column('user_settings', 'conversation_settings', server_default=None)
op.alter_column('org_member', 'agent_settings_diff', server_default=None)
op.alter_column('org_member', 'conversation_settings_diff', server_default=None)
op.alter_column('org', 'agent_settings', server_default=None)
op.alter_column('org', 'conversation_settings', server_default=None)
op.alter_column('org_member', 'has_custom_llm_api_key', server_default=None)
op.drop_column('user_settings', 'agent')
op.drop_column('user_settings', 'max_iterations')
op.drop_column('user_settings', 'security_analyzer')
op.drop_column('user_settings', 'confirmation_mode')
op.drop_column('user_settings', 'llm_model')
op.drop_column('user_settings', 'llm_base_url')
op.drop_column('user_settings', 'enable_default_condenser')
op.drop_column('user_settings', 'condenser_max_size')
op.drop_column('org_member', 'max_iterations')
op.drop_column('org_member', 'llm_model')
op.drop_column('org_member', 'llm_base_url')
op.drop_column('org_member', 'mcp_config')
op.drop_column('org', 'agent')
op.drop_column('org', 'default_max_iterations')
op.drop_column('org', 'security_analyzer')
op.drop_column('org', 'confirmation_mode')
op.drop_column('org', 'default_llm_model')
op.drop_column('org', 'default_llm_base_url')
op.drop_column('org', 'enable_default_condenser')
op.drop_column('org', 'mcp_config')
op.drop_column('org', 'condenser_max_size')
def downgrade() -> None:
op.add_column('user_settings', sa.Column('agent', sa.String(), nullable=True))
op.add_column(
'user_settings', sa.Column('max_iterations', sa.Integer(), nullable=True)
)
op.add_column(
'user_settings', sa.Column('security_analyzer', sa.String(), nullable=True)
)
op.add_column(
'user_settings', sa.Column('confirmation_mode', sa.Boolean(), nullable=True)
)
op.add_column('user_settings', sa.Column('llm_model', sa.String(), nullable=True))
op.add_column(
'user_settings', sa.Column('llm_base_url', sa.String(), nullable=True)
)
op.add_column(
'user_settings',
sa.Column(
'enable_default_condenser',
sa.Boolean(),
nullable=False,
server_default=sa.true(),
),
)
op.add_column(
'user_settings', sa.Column('condenser_max_size', sa.Integer(), nullable=True)
)
op.add_column('org_member', sa.Column('llm_base_url', sa.String(), nullable=True))
op.add_column('org_member', sa.Column('llm_model', sa.String(), nullable=True))
op.add_column(
'org_member', sa.Column('max_iterations', sa.Integer(), nullable=True)
)
op.add_column('org_member', sa.Column('mcp_config', sa.JSON(), nullable=True))
op.add_column('org', sa.Column('agent', sa.String(), nullable=True))
op.add_column(
'org', sa.Column('default_max_iterations', sa.Integer(), nullable=True)
)
op.add_column('org', sa.Column('security_analyzer', sa.String(), nullable=True))
op.add_column('org', sa.Column('confirmation_mode', sa.Boolean(), nullable=True))
op.add_column('org', sa.Column('default_llm_model', sa.String(), nullable=True))
op.add_column('org', sa.Column('default_llm_base_url', sa.String(), nullable=True))
op.add_column(
'org',
sa.Column(
'enable_default_condenser',
sa.Boolean(),
nullable=False,
server_default=sa.true(),
),
)
op.add_column('org', sa.Column('mcp_config', sa.JSON(), nullable=True))
op.add_column('org', sa.Column('condenser_max_size', sa.Integer(), nullable=True))
bind = op.get_bind()
user_settings_table = sa.table(
'user_settings',
sa.column('id', sa.Integer()),
sa.column('agent_settings', sa.JSON()),
sa.column('conversation_settings', sa.JSON()),
sa.column('agent', sa.String()),
sa.column('max_iterations', sa.Integer()),
sa.column('security_analyzer', sa.String()),
sa.column('confirmation_mode', sa.Boolean()),
sa.column('llm_model', sa.String()),
sa.column('llm_base_url', sa.String()),
sa.column('enable_default_condenser', sa.Boolean()),
sa.column('condenser_max_size', sa.Integer()),
)
user_settings_rows = bind.execute(
sa.select(
user_settings_table.c.id,
user_settings_table.c.agent_settings,
user_settings_table.c.conversation_settings,
)
).mappings()
for row in user_settings_rows:
bind.execute(
user_settings_table.update()
.where(user_settings_table.c.id == row['id'])
.values(**_legacy_user_settings_values(row))
)
org_member_table = sa.table(
'org_member',
sa.column('org_id', sa.Uuid()),
sa.column('user_id', sa.Uuid()),
sa.column('agent_settings_diff', sa.JSON()),
sa.column('conversation_settings_diff', sa.JSON()),
sa.column('llm_model', sa.String()),
sa.column('llm_base_url', sa.String()),
sa.column('max_iterations', sa.Integer()),
sa.column('mcp_config', sa.JSON()),
)
org_member_rows = bind.execute(
sa.select(
org_member_table.c.org_id,
org_member_table.c.user_id,
org_member_table.c.agent_settings_diff,
org_member_table.c.conversation_settings_diff,
)
).mappings()
for row in org_member_rows:
bind.execute(
org_member_table.update()
.where(org_member_table.c.org_id == row['org_id'])
.where(org_member_table.c.user_id == row['user_id'])
.values(**_legacy_org_member_values(row))
)
org_table = sa.table(
'org',
sa.column('id', sa.Uuid()),
sa.column('agent_settings', sa.JSON()),
sa.column('conversation_settings', sa.JSON()),
sa.column('agent', sa.String()),
sa.column('default_max_iterations', sa.Integer()),
sa.column('security_analyzer', sa.String()),
sa.column('confirmation_mode', sa.Boolean()),
sa.column('default_llm_model', sa.String()),
sa.column('default_llm_base_url', sa.String()),
sa.column('enable_default_condenser', sa.Boolean()),
sa.column('mcp_config', sa.JSON()),
sa.column('condenser_max_size', sa.Integer()),
)
org_rows = bind.execute(
sa.select(
org_table.c.id,
org_table.c.agent_settings,
org_table.c.conversation_settings,
)
).mappings()
for row in org_rows:
bind.execute(
org_table.update()
.where(org_table.c.id == row['id'])
.values(**_legacy_org_values(row))
)
op.drop_column('org', 'agent_settings')
op.drop_column('org', 'conversation_settings')
op.drop_column('org', '_llm_api_key')
op.drop_column('org_member', 'agent_settings_diff')
op.drop_column('org_member', 'conversation_settings_diff')
op.drop_column('org_member', 'has_custom_llm_api_key')
op.drop_column('user_settings', 'agent_settings')
op.drop_column('user_settings', 'conversation_settings')

4860
enterprise/poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -64,7 +64,6 @@ pytest-asyncio = "*"
pytest-forked = "*"
pytest-xdist = "*"
flake8 = "*"
freezegun = "^1.5.1"
openai = "*"
opencv-python = "*"
pandas = "*"

View File

@@ -17,6 +17,7 @@ from server.auth.constants import ( # noqa: E402
BITBUCKET_DATA_CENTER_HOST,
ENABLE_JIRA,
ENABLE_JIRA_DC,
ENABLE_LINEAR,
GITHUB_APP_CLIENT_ID,
GITLAB_APP_CLIENT_ID,
)
@@ -28,10 +29,12 @@ from server.routes.api_keys import api_router as api_keys_router # noqa: E402
from server.routes.auth import api_router, oauth_router # noqa: E402
from server.routes.billing import billing_router # noqa: E402
from server.routes.email import api_router as email_router # noqa: E402
from server.routes.event_webhook import event_webhook_router # noqa: E402
from server.routes.feedback import router as feedback_router # noqa: E402
from server.routes.github_proxy import add_github_proxy_routes # noqa: E402
from server.routes.integration.jira import jira_integration_router # noqa: E402
from server.routes.integration.jira_dc import jira_dc_integration_router # noqa: E402
from server.routes.integration.linear import linear_integration_router # noqa: E402
from server.routes.integration.slack import slack_router # noqa: E402
from server.routes.mcp_patch import patch_mcp_server # noqa: E402
from server.routes.oauth_device import oauth_device_router # noqa: E402
@@ -43,11 +46,8 @@ from server.routes.org_invitations import ( # noqa: E402
)
from server.routes.orgs import org_router # noqa: E402
from server.routes.readiness import readiness_router # noqa: E402
from server.routes.service import service_router # noqa: E402
from server.routes.user import saas_user_router # noqa: E402
from server.routes.user_app_settings import user_app_settings_router # noqa: E402
from server.routes.users_v1 import ( # noqa: E402
override_users_me_endpoint,
)
from server.sharing.shared_conversation_router import ( # noqa: E402
router as shared_conversation_router,
)
@@ -82,6 +82,7 @@ base_app.include_router(readiness_router) # Add routes for readiness checks
base_app.include_router(api_router) # Add additional route for github auth
base_app.include_router(oauth_router) # Add additional route for oauth callback
base_app.include_router(oauth_device_router) # Add OAuth 2.0 Device Flow routes
base_app.include_router(saas_user_router) # Add additional route SAAS user calls
base_app.include_router(user_app_settings_router) # Add routes for user app settings
base_app.include_router(
billing_router
@@ -106,19 +107,11 @@ if GITHUB_APP_CLIENT_ID:
# Add GitLab integration router only if GITLAB_APP_CLIENT_ID is set
if GITLAB_APP_CLIENT_ID:
# Make sure that the callback processor is loaded here so we don't get an error when deserializing
from integrations.gitlab.gitlab_v1_callback_processor import ( # noqa: E402
GitlabV1CallbackProcessor,
)
from server.routes.integration.gitlab import gitlab_integration_router # noqa: E402
# Bludgeon mypy into not deleting my import
logger.debug(f'Loaded {GitlabV1CallbackProcessor.__name__}')
base_app.include_router(gitlab_integration_router)
base_app.include_router(api_keys_router) # Add routes for API key management
base_app.include_router(service_router) # Add routes for internal service API
base_app.include_router(org_router) # Add routes for organization management
base_app.include_router(
verified_models_router
@@ -128,10 +121,6 @@ base_app.include_router(
# This must happen after all routers are included
override_llm_models_dependency(base_app)
# Override the /api/v1/users/me endpoint to include organization info
# This replaces the OSS endpoint with a SAAS version that adds org_id, org_name, role, permissions
override_users_me_endpoint(base_app)
base_app.include_router(invitation_router) # Add routes for org invitation management
base_app.include_router(invitation_accept_router) # Add route for accepting invitations
add_github_proxy_routes(base_app)
@@ -140,6 +129,8 @@ if ENABLE_JIRA:
base_app.include_router(jira_integration_router)
if ENABLE_JIRA_DC:
base_app.include_router(jira_dc_integration_router)
if ENABLE_LINEAR:
base_app.include_router(linear_integration_router)
if BITBUCKET_DATA_CENTER_HOST:
from server.routes.bitbucket_dc_proxy import (
router as bitbucket_dc_proxy_router, # noqa: E402
@@ -148,6 +139,9 @@ if BITBUCKET_DATA_CENTER_HOST:
base_app.include_router(bitbucket_dc_proxy_router)
base_app.include_router(email_router) # Add routes for email management
base_app.include_router(feedback_router) # Add routes for conversation feedback
base_app.include_router(
event_webhook_router
) # Add routes for Events in nested runtimes
base_app.add_middleware(

View File

@@ -35,13 +35,13 @@ Usage:
from enum import Enum
from uuid import UUID
from fastapi import Depends, HTTPException, Request, status
from fastapi import Depends, HTTPException, status
from storage.org_member_store import OrgMemberStore
from storage.role import Role
from storage.role_store import RoleStore
from openhands.core.logger import openhands_logger as logger
from openhands.server.user_auth import get_user_auth, get_user_id
from openhands.server.user_auth import get_user_id
class Permission(str, Enum):
@@ -84,12 +84,6 @@ class Permission(str, Enum):
# Temporary permissions until we finish the API updates.
EDIT_ORG_SETTINGS = 'edit_org_settings'
# Git organization claims
MANAGE_ORG_CLAIMS = 'manage_org_claims'
# Manage Automations
MANAGE_AUTOMATIONS = 'manage_automations'
class RoleName(str, Enum):
"""Role names used in the system."""
@@ -124,10 +118,6 @@ ROLE_PERMISSIONS: dict[RoleName, frozenset[Permission]] = {
# Organization Management (Owner only)
Permission.CHANGE_ORGANIZATION_NAME,
Permission.DELETE_ORGANIZATION,
# Git organization claims
Permission.MANAGE_ORG_CLAIMS,
# Manage Automations
Permission.MANAGE_AUTOMATIONS,
]
),
RoleName.ADMIN: frozenset(
@@ -149,10 +139,6 @@ ROLE_PERMISSIONS: dict[RoleName, frozenset[Permission]] = {
# Organization Management
Permission.VIEW_ORG_SETTINGS,
Permission.EDIT_ORG_SETTINGS,
# Git organization claims
Permission.MANAGE_ORG_CLAIMS,
# Manage Automations
Permission.MANAGE_AUTOMATIONS,
]
),
RoleName.MEMBER: frozenset(
@@ -166,8 +152,6 @@ ROLE_PERMISSIONS: dict[RoleName, frozenset[Permission]] = {
# Settings (View only)
Permission.VIEW_ORG_SETTINGS,
Permission.VIEW_LLM_SETTINGS,
# Manage Automations
Permission.MANAGE_AUTOMATIONS,
]
),
}
@@ -230,19 +214,6 @@ def has_permission(user_role: Role, permission: Permission) -> bool:
return permission in permissions
async def get_api_key_org_id_from_request(request: Request) -> UUID | None:
"""Get the org_id bound to the API key used for authentication.
Returns None if:
- Not authenticated via API key (cookie auth)
- API key is a legacy key without org binding
"""
user_auth = getattr(request.state, 'user_auth', None)
if user_auth and hasattr(user_auth, 'get_api_key_org_id'):
return user_auth.get_api_key_org_id()
return None
def require_permission(permission: Permission):
"""
Factory function that creates a dependency to require a specific permission.
@@ -250,9 +221,8 @@ def require_permission(permission: Permission):
This creates a FastAPI dependency that:
1. Extracts org_id from the path parameter
2. Gets the authenticated user_id
3. Validates API key org binding (if using API key auth)
4. Checks if the user has the required permission in the organization
5. Returns the user_id if authorized, raises HTTPException otherwise
3. Checks if the user has the required permission in the organization
4. Returns the user_id if authorized, raises HTTPException otherwise
Usage:
@router.get('/{org_id}/settings')
@@ -270,7 +240,6 @@ def require_permission(permission: Permission):
"""
async def permission_checker(
request: Request,
org_id: UUID | None = None,
user_id: str | None = Depends(get_user_id),
) -> str:
@@ -280,23 +249,6 @@ def require_permission(permission: Permission):
detail='User not authenticated',
)
# Validate API key organization binding
api_key_org_id = await get_api_key_org_id_from_request(request)
if api_key_org_id is not None and org_id is not None:
if api_key_org_id != org_id:
logger.warning(
'API key organization mismatch',
extra={
'user_id': user_id,
'api_key_org_id': str(api_key_org_id),
'target_org_id': str(org_id),
},
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='API key is not authorized for this organization',
)
user_role = await get_user_org_role(user_id, org_id)
if not user_role:
@@ -327,96 +279,3 @@ def require_permission(permission: Permission):
return user_id
return permission_checker
async def require_financial_data_access(
request: Request,
org_id: UUID,
user_id: str | None = Depends(get_user_id),
) -> str:
"""
Authorization dependency for accessing organization financial data.
Allows access if ANY of these conditions are met:
1. User has Admin or Owner role in the organization
2. User has @openhands.dev email domain
This is used for the organization members financial data endpoint.
Args:
request: FastAPI request object
org_id: Organization UUID from path parameter
user_id: User ID from authentication
Returns:
str: User ID if authorized
Raises:
HTTPException: 401 if not authenticated, 403 if not authorized
"""
if not user_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='User not authenticated',
)
# Validate API key organization binding
api_key_org_id = await get_api_key_org_id_from_request(request)
if api_key_org_id is not None:
if api_key_org_id != org_id:
logger.warning(
'API key organization mismatch for financial data access',
extra={
'user_id': user_id,
'api_key_org_id': str(api_key_org_id),
'target_org_id': str(org_id),
},
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='API key is not authorized for this organization',
)
# Check if user has @openhands.dev email
user_auth = await get_user_auth(request)
user_email = await user_auth.get_user_email()
if user_email and user_email.endswith('@openhands.dev'):
logger.debug(
'Financial data access granted via @openhands.dev email',
extra={'user_id': user_id, 'org_id': str(org_id)},
)
return user_id
# Check if user has Admin or Owner role in the organization
user_role = await get_user_org_role(user_id, org_id)
if not user_role:
logger.warning(
'Financial data access denied - user not a member of organization',
extra={'user_id': user_id, 'org_id': str(org_id)},
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='User is not a member of this organization',
)
if user_role.name not in (RoleName.OWNER.value, RoleName.ADMIN.value):
logger.warning(
'Financial data access denied - insufficient role',
extra={
'user_id': user_id,
'org_id': str(org_id),
'user_role': user_role.name,
},
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Access restricted to organization admins, owners, or OpenHands members',
)
logger.debug(
'Financial data access granted via admin/owner role',
extra={'user_id': user_id, 'org_id': str(org_id), 'role': user_role.name},
)
return user_id

View File

@@ -6,6 +6,7 @@ GITHUB_APP_WEBHOOK_SECRET = os.getenv('GITHUB_APP_WEBHOOK_SECRET', '')
GITHUB_APP_PRIVATE_KEY = os.getenv('GITHUB_APP_PRIVATE_KEY', '').replace('\\n', '\n')
KEYCLOAK_SERVER_URL = os.getenv('KEYCLOAK_SERVER_URL', '').rstrip('/')
KEYCLOAK_REALM_NAME = os.getenv('KEYCLOAK_REALM_NAME', '')
KEYCLOAK_PROVIDER_NAME = os.getenv('KEYCLOAK_PROVIDER_NAME', '')
KEYCLOAK_CLIENT_ID = os.getenv('KEYCLOAK_CLIENT_ID', '')
KEYCLOAK_CLIENT_SECRET = os.getenv('KEYCLOAK_CLIENT_SECRET', '')
KEYCLOAK_SERVER_URL_EXT = os.getenv(
@@ -56,23 +57,6 @@ RECAPTCHA_SITE_KEY = os.getenv('RECAPTCHA_SITE_KEY', '').strip()
RECAPTCHA_HMAC_SECRET = os.getenv('RECAPTCHA_HMAC_SECRET', '').strip()
RECAPTCHA_BLOCK_THRESHOLD = float(os.getenv('RECAPTCHA_BLOCK_THRESHOLD', '0.3'))
# Automation Service
AUTOMATION_SERVICE_URL = os.getenv('AUTOMATION_SERVICE_URL', '').strip()
if AUTOMATION_SERVICE_URL and not AUTOMATION_SERVICE_URL.startswith(
('http://', 'https://')
):
raise ValueError(
f'AUTOMATION_SERVICE_URL must start with http:// or https://, '
f'got: {AUTOMATION_SERVICE_URL}'
)
AUTOMATION_EVENT_FORWARDING_ENABLED = os.getenv(
'AUTOMATION_EVENT_FORWARDING_ENABLED', 'false'
) in ('1', 'true')
# Shared secret for signing payloads sent to automation service (separate from GitHub webhook secret)
AUTOMATION_WEBHOOK_SECRET = os.getenv('AUTOMATION_WEBHOOK_SECRET', '').strip()
# Default HTTP timeout for automation service requests (seconds)
AUTOMATION_SERVICE_TIMEOUT = int(os.getenv('AUTOMATION_SERVICE_TIMEOUT', '30'))
# Account Defender labels that indicate suspicious activity
SUSPICIOUS_LABELS = {
'SUSPICIOUS_LOGIN_ACTIVITY',

View File

@@ -4,6 +4,7 @@ from server.auth.constants import (
KEYCLOAK_ADMIN_PASSWORD,
KEYCLOAK_CLIENT_ID,
KEYCLOAK_CLIENT_SECRET,
KEYCLOAK_PROVIDER_NAME,
KEYCLOAK_REALM_NAME,
KEYCLOAK_SERVER_URL,
KEYCLOAK_SERVER_URL_EXT,
@@ -11,7 +12,7 @@ from server.auth.constants import (
from server.logger import logger
logger.debug(
f'KEYCLOAK_SERVER_URL:{KEYCLOAK_SERVER_URL}, KEYCLOAK_SERVER_URL_EXT:{KEYCLOAK_SERVER_URL_EXT}, KEYCLOAK_CLIENT_ID:{KEYCLOAK_CLIENT_ID}'
f'KEYCLOAK_SERVER_URL:{KEYCLOAK_SERVER_URL}, KEYCLOAK_SERVER_URL_EXT:{KEYCLOAK_SERVER_URL_EXT}, KEYCLOAK_PROVIDER_NAME:{KEYCLOAK_PROVIDER_NAME}, KEYCLOAK_CLIENT_ID:{KEYCLOAK_CLIENT_ID}'
)
_keycloak_instances = {}

View File

@@ -1,7 +1,6 @@
import time
from dataclasses import dataclass
from types import MappingProxyType
from uuid import UUID
import jwt
from fastapi import Request
@@ -14,10 +13,6 @@ from server.auth.auth_error import (
ExpiredError,
NoCredentialsError,
)
from server.auth.authorization import (
get_role_permissions,
get_user_org_role,
)
from server.auth.constants import BITBUCKET_DATA_CENTER_HOST
from server.auth.token_manager import TokenManager
from server.config import get_config
@@ -27,12 +22,10 @@ from sqlalchemy import delete, select
from storage.api_key_store import ApiKeyStore
from storage.auth_tokens import AuthTokens
from storage.database import a_session_maker
from storage.org_store import OrgStore
from storage.saas_secrets_store import SaasSecretsStore
from storage.saas_settings_store import SaasSettingsStore
from storage.user_authorization import UserAuthorizationType
from storage.user_authorization_store import UserAuthorizationStore
from storage.user_store import UserStore
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed
from openhands.integrations.provider import (
@@ -66,25 +59,6 @@ class SaasUserAuth(UserAuth):
_secrets: Secrets | None = None
accepted_tos: bool | None = None
auth_type: AuthType = AuthType.COOKIE
# API key context fields - populated when authenticated via API key
api_key_org_id: UUID | None = None # Org bound to the API key used for auth
api_key_id: int | None = None
api_key_name: str | None = None
# Organization context fields - populated lazily via get_org_info()
_org_id: str | None = None
_org_name: str | None = None
_role: str | None = None
_permissions: list[str] | None = None
_org_info_loaded: bool = False
def get_api_key_org_id(self) -> UUID | None:
"""Get the organization ID bound to the API key used for authentication.
Returns:
The org_id if authenticated via API key with org binding, None otherwise
(cookie auth or legacy API keys without org binding).
"""
return self.api_key_org_id
async def get_user_id(self) -> str | None:
return self.user_id
@@ -254,72 +228,6 @@ class SaasUserAuth(UserAuth):
)
return mcp_api_key
async def get_org_info(self) -> dict | None:
"""Get organization info for the current user.
Lazily loads and caches organization data including:
- org_id: Current organization ID
- org_name: Current organization name
- role: User's role in the organization
- permissions: List of permission names for the role
Returns:
dict with org_id, org_name, role, permissions or None if not available
"""
if self._org_info_loaded:
if self._org_id is None:
return None
return {
'org_id': self._org_id,
'org_name': self._org_name,
'role': self._role,
'permissions': self._permissions,
}
# Mark as loaded to avoid repeated attempts on failure
self._org_info_loaded = True
try:
# Get user and their current org
user = await UserStore.get_user_by_id(self.user_id)
if not user:
logger.warning(f'User {self.user_id} not found for org info')
return None
# Get the current org
org = await OrgStore.get_org_by_id(user.current_org_id)
if not org:
logger.warning(
f'Organization {user.current_org_id} not found for user {self.user_id}'
)
return None
# Get user's role in the current org
role = await get_user_org_role(self.user_id, user.current_org_id)
role_name = role.name if role else None
# Get permissions for the role
permissions: list[str] = []
if role_name:
role_permissions = get_role_permissions(role_name)
permissions = [p.value for p in role_permissions]
# Cache the results
self._org_id = str(user.current_org_id)
self._org_name = org.name
self._role = role_name
self._permissions = permissions
return {
'org_id': self._org_id,
'org_name': self._org_name,
'role': self._role,
'permissions': self._permissions,
}
except Exception as e:
logger.error(f'Error fetching org info for user {self.user_id}: {e}')
return None
@classmethod
async def get_instance(cls, request: Request) -> UserAuth:
logger.debug('saas_user_auth_get_instance')
@@ -375,19 +283,14 @@ async def saas_user_auth_from_bearer(request: Request) -> SaasUserAuth | None:
return None
api_key_store = ApiKeyStore.get_instance()
validation_result = await api_key_store.validate_api_key(api_key)
if not validation_result:
user_id = await api_key_store.validate_api_key(api_key)
if not user_id:
return None
offline_token = await token_manager.load_offline_token(
validation_result.user_id
)
offline_token = await token_manager.load_offline_token(user_id)
saas_user_auth = SaasUserAuth(
user_id=validation_result.user_id,
user_id=user_id,
refresh_token=SecretStr(offline_token),
auth_type=AuthType.BEARER,
api_key_org_id=validation_result.org_id,
api_key_id=validation_result.key_id,
api_key_name=validation_result.key_name,
)
await saas_user_auth.refresh()
return saas_user_auth

View File

@@ -0,0 +1,808 @@
import asyncio
import json
import time
from dataclasses import dataclass, field
from uuid import uuid4
import socketio
from server.logger import logger
from server.utils.conversation_callback_utils import invoke_conversation_callbacks
from sqlalchemy import select
from storage.database import a_session_maker
from storage.stored_conversation_metadata_saas import StoredConversationMetadataSaas
from openhands.core.config import LLMConfig
from openhands.core.config.openhands_config import OpenHandsConfig
from openhands.core.config.utils import load_openhands_config
from openhands.core.schema.agent import AgentState
from openhands.events.action import MessageAction
from openhands.events.event_store import EventStore
from openhands.events.event_store_abc import EventStoreABC
from openhands.events.observation import AgentStateChangedObservation
from openhands.events.stream import EventStreamSubscriber
from openhands.llm.llm_registry import LLMRegistry
from openhands.runtime.runtime_status import RuntimeStatus
from openhands.server.config.server_config import ServerConfig
from openhands.server.conversation_manager.conversation_manager import (
ConversationManager,
)
from openhands.server.conversation_manager.standalone_conversation_manager import (
StandaloneConversationManager,
)
from openhands.server.data_models.agent_loop_info import AgentLoopInfo
from openhands.server.monitoring import MonitoringListener
from openhands.server.session.agent_session import WAIT_TIME_BEFORE_CLOSE
from openhands.server.session.session import Session
from openhands.server.settings import Settings
from openhands.storage.files import FileStore
from openhands.utils.async_utils import call_sync_from_async, wait_all
from openhands.utils.shutdown_listener import should_continue
# Time in seconds between cleanup operations for stale conversations
_CLEANUP_INTERVAL_SECONDS = 15
# Time in seconds before a Redis entry is considered expired if not refreshed
_REDIS_ENTRY_TIMEOUT_SECONDS = 15
# Time in seconds between updates to Redis entries
_REDIS_UPDATE_INTERVAL_SECONDS = 5
_REDIS_POLL_TIMEOUT = 0.15
@dataclass
class _LLMResponseRequest:
query_id: str
response: str | None
flag: asyncio.Event
@dataclass
class ClusteredConversationManager(StandaloneConversationManager):
"""Manages conversations in clustered mode (multiple server instances with Redis).
This class extends StandaloneConversationManager to provide distributed conversation
management across multiple server instances using Redis as a communication channel
and state store. It handles:
- Cross-server message passing via Redis pub/sub
- Tracking of conversations and connections across the cluster
- Graceful recovery from server failures
- Enforcement of conversation limits across the cluster
- Cleanup of stale conversations and connections
The Redis communication uses several key patterns:
- ohcnv:{user_id}:{conversation_id} - Marks a conversation as active
- ohcnct:{user_id}:{conversation_id}:{connection_id} - Tracks connections to conversations
"""
_redis_listen_task: asyncio.Task | None = field(default=None)
_redis_update_task: asyncio.Task | None = field(default=None)
_llm_responses: dict[str, _LLMResponseRequest] = field(default_factory=dict)
def __post_init__(self):
# We increment the max_concurrent_conversations by 1 because this class
# marks the conversation as started in Redis before checking the number
# of running conversations. This prevents race conditions where multiple
# servers might simultaneously start new conversations.
self.config.max_concurrent_conversations += 1
async def __aenter__(self):
await super().__aenter__()
self._redis_update_task = asyncio.create_task(
self._update_state_in_redis_task()
)
self._redis_listen_task = asyncio.create_task(self._redis_subscribe())
return self
async def __aexit__(self, exc_type, exc_value, traceback):
if self._redis_update_task:
self._redis_update_task.cancel()
self._redis_update_task = None
if self._redis_listen_task:
self._redis_listen_task.cancel()
self._redis_listen_task = None
await super().__aexit__(exc_type, exc_value, traceback)
async def _redis_subscribe(self):
"""Subscribe to Redis messages for cross-server communication.
This method creates a Redis pub/sub subscription to receive messages from
other server instances. It runs in a continuous loop until cancelled.
"""
logger.debug('_redis_subscribe')
redis_client = self._get_redis_client()
pubsub = redis_client.pubsub()
await pubsub.subscribe('session_msg')
while should_continue():
try:
message = await pubsub.get_message(
ignore_subscribe_messages=True, timeout=5
)
if message:
await self._process_message(message)
except asyncio.CancelledError:
logger.debug('redis_subscribe_cancelled')
return
except Exception as e:
try:
asyncio.get_running_loop()
logger.exception(f'error_reading_from_redis:{str(e)}')
except RuntimeError:
# Loop has been shut down, exit gracefully
return
async def _process_message(self, message: dict):
"""Process messages received from Redis pub/sub.
Handles three types of messages:
- 'event': Forward an event to a local session
- 'close_session': Close a local session
- 'session_closing': Handle remote session closure
Args:
message: The Redis pub/sub message containing the action to perform
"""
data = json.loads(message['data'])
logger.debug(f'got_published_message:{message}')
message_type = data['message_type']
if message_type == 'event':
# Forward an event to a local session if it exists
sid = data['sid']
session = self._local_agent_loops_by_sid.get(sid)
if session:
await session.dispatch(data['data'])
elif message_type == 'close_session':
# Close a local session if it exists
sid = data['sid']
if sid in self._local_agent_loops_by_sid:
await self._close_session(sid)
elif message_type == 'session_closing':
# Handle connections to a session that is closing on another node
# We only get this in the event of graceful shutdown,
# which can't be guaranteed - nodes can simply vanish unexpectedly!
sid = data['sid']
user_id = data['user_id']
logger.debug(f'session_closing:{sid}')
# Create a list of items to process to avoid modifying dict during iteration
items = list(self._local_connection_id_to_session_id.items())
for connection_id, local_sid in items:
if sid == local_sid:
logger.warning(
f'local_connection_to_closing_session:{connection_id}:{sid}'
)
await self._handle_remote_conversation_stopped(
user_id, connection_id
)
elif message_type == 'llm_completion':
# Request extraneous llm completion from session's LLM Registry
sid = data['sid']
service_id = data['service_id']
messages = data['messages']
llm_config = data['llm_config']
query_id = data['query_id']
session = self._local_agent_loops_by_sid.get(sid)
if session:
llm_registry: LLMRegistry = session.llm_registry
response = await call_sync_from_async(
llm_registry.request_extraneous_completion,
service_id,
llm_config,
messages,
)
await self._get_redis_client().publish(
'session_msg',
json.dumps(
{
'query_id': query_id,
'response': response,
'message_type': 'llm_completion_response',
}
),
)
elif message_type == 'llm_completion_response':
query_id = data['query_id']
llm_response = self._llm_responses.get(query_id)
if llm_response:
llm_response.response = data['response']
llm_response.flag.set()
def _get_redis_client(self):
return getattr(self.sio.manager, 'redis', None)
def _get_redis_conversation_key(self, user_id: str | None, conversation_id: str):
return f'ohcnv:{user_id}:{conversation_id}'
def _get_redis_connection_key(
self, user_id: str, conversation_id: str, connection_id: str
):
return f'ohcnct:{user_id}:{conversation_id}:{connection_id}'
async def _get_event_store(self, sid, user_id) -> EventStoreABC | None:
session = self._local_agent_loops_by_sid.get(sid)
if session:
logger.debug('found_local_agent_loop', extra={'sid': sid})
return session.agent_session.event_stream
redis = self._get_redis_client()
key = self._get_redis_conversation_key(user_id, sid)
value = await redis.get(key)
if value:
logger.debug('found_remote_agent_loop', extra={'sid': sid})
return EventStore(sid, self.file_store, user_id)
return None
async def get_running_agent_loops(
self, user_id: str | None = None, filter_to_sids: set[str] | None = None
) -> set[str]:
sids = await self.get_running_agent_loops_locally(user_id, filter_to_sids)
if not filter_to_sids or len(sids) != len(filter_to_sids):
remote_sids = await self._get_running_agent_loops_remotely(
user_id, filter_to_sids
)
sids = sids.union(remote_sids)
return sids
async def get_running_agent_loops_locally(
self, user_id: str | None = None, filter_to_sids: set[str] | None = None
) -> set[str]:
sids = await super().get_running_agent_loops(user_id, filter_to_sids)
return sids
async def _get_running_agent_loops_remotely(
self,
user_id: str | None = None,
filter_to_sids: set[str] | None = None,
) -> set[str]:
"""Get the set of conversation IDs running on remote servers.
Args:
user_id: Optional user ID to filter conversations by
filter_to_sids: Optional set of conversation IDs to filter by
Returns:
A set of conversation IDs running on remote servers
"""
if filter_to_sids is not None and not filter_to_sids:
return set()
if user_id:
pattern = self._get_redis_conversation_key(user_id, '*')
else:
pattern = self._get_redis_conversation_key('*', '*')
redis = self._get_redis_client()
result = set()
async for key in redis.scan_iter(pattern):
conversation_id = key.decode().split(':')[2]
if filter_to_sids is None or conversation_id in filter_to_sids:
result.add(conversation_id)
return result
async def get_connections(
self, user_id: str | None = None, filter_to_sids: set[str] | None = None
) -> dict[str, str]:
connections = await super().get_connections(user_id, filter_to_sids)
if not filter_to_sids or len(connections) != len(filter_to_sids):
remote_connections = await self._get_connections_remotely(
user_id, filter_to_sids
)
connections.update(remote_connections)
return connections
async def _get_connections_remotely(
self,
user_id: str | None = None,
filter_to_sids: set[str] | None = None,
) -> dict[str, str]:
if filter_to_sids is not None and not filter_to_sids:
return {}
if user_id:
pattern = self._get_redis_connection_key(user_id, '*', '*')
else:
pattern = self._get_redis_connection_key('*', '*', '*')
redis = self._get_redis_client()
result = {}
async for key in redis.scan_iter(pattern):
parts = key.decode().split(':')
conversation_id = parts[2]
connection_id = parts[3]
if filter_to_sids is None or conversation_id in filter_to_sids:
result[connection_id] = conversation_id
return result
async def send_to_event_stream(self, connection_id: str, data: dict) -> None:
sid = self._local_connection_id_to_session_id.get(connection_id)
if sid:
await self.send_event_to_conversation(sid, data)
async def request_llm_completion(
self,
sid: str,
service_id: str,
llm_config: LLMConfig,
messages: list[dict[str, str]],
) -> str:
session = self._local_agent_loops_by_sid.get(sid)
if session:
llm_registry = session.llm_registry
return llm_registry.request_extraneous_completion(
service_id, llm_config, messages
)
flag = asyncio.Event()
query_id = str(uuid4())
query = _LLMResponseRequest(query_id=query_id, response=None, flag=flag)
self._llm_responses[query_id] = query
try:
redis_client = self._get_redis_client()
await redis_client.publish(
'session_msg',
json.dumps(
{
'message_type': 'llm_completion',
'query_id': query_id,
'sid': sid,
'service_id': service_id,
'llm_config': llm_config,
'message': messages,
}
),
)
async with asyncio.timeout(_REDIS_POLL_TIMEOUT):
await flag.wait()
if query.response:
return query.response
raise Exception('Failed to perform LLM completion')
except TimeoutError:
raise Exception('Timeout occured')
async def send_event_to_conversation(self, sid: str, data: dict):
if not sid:
return
session = self._local_agent_loops_by_sid.get(sid)
if session:
await session.dispatch(data)
else:
# The session is running on another node
redis_client = self._get_redis_client()
await redis_client.publish(
'session_msg',
json.dumps({'message_type': 'event', 'sid': sid, 'data': data}),
)
async def close_session(self, sid: str):
# Send a message to other nodes telling them to close this session if they have the agent loop, and close any connections.
redis_client = self._get_redis_client()
await redis_client.publish(
'session_msg',
json.dumps({'message_type': 'close_session', 'sid': sid}),
)
await self._close_session(sid)
async def maybe_start_agent_loop(
self,
sid: str,
settings: Settings,
user_id: str | None,
initial_user_msg: MessageAction | None = None,
replay_json: str | None = None,
) -> AgentLoopInfo:
# If we can set the key in redis then no other worker is running this conversation
redis = self._get_redis_client()
key = self._get_redis_conversation_key(user_id, sid) # type: ignore
created = await redis.set(key, 1, nx=True, ex=_REDIS_ENTRY_TIMEOUT_SECONDS)
if created:
await self._start_agent_loop(
sid, settings, user_id, initial_user_msg, replay_json
)
event_store = await self._get_event_store(sid, user_id)
if not event_store:
logger.error(
f'No event stream after starting agent loop: {sid}',
extra={'sid': sid},
)
raise RuntimeError(f'no_event_stream:{sid}')
return AgentLoopInfo(
conversation_id=sid,
url=self._get_conversation_url(sid),
session_api_key=None,
event_store=event_store,
)
async def _update_state_in_redis_task(self):
while should_continue():
try:
await self._update_state_in_redis()
await asyncio.sleep(_REDIS_UPDATE_INTERVAL_SECONDS)
except asyncio.CancelledError:
return
except Exception:
try:
asyncio.get_running_loop()
logger.exception('error_reading_from_redis')
except RuntimeError:
return # Loop has been shut down
async def _update_state_in_redis(self):
"""Refresh all entries in Redis to maintain conversation state across the cluster.
This method:
1. Scans Redis for all conversation keys to build a mapping of conversation IDs to user IDs
2. Updates Redis entries for all local conversations to prevent them from expiring
3. Updates Redis entries for all local connections to prevent them from expiring
This is critical for maintaining the distributed state and allowing other servers
to detect when a server has gone down unexpectedly.
"""
redis = self._get_redis_client()
# Build a mapping of conversation_id -> user_id from existing Redis keys
pattern = self._get_redis_conversation_key('*', '*')
conversation_user_ids = {}
async for key in redis.scan_iter(pattern):
parts = key.decode().split(':')
conversation_user_ids[parts[2]] = parts[1]
pipe = redis.pipeline()
# Add multiple commands to the pipeline
# First, update all local agent loops
for sid, session in self._local_agent_loops_by_sid.items():
if sid:
await pipe.set(
self._get_redis_conversation_key(session.user_id, sid),
1,
ex=_REDIS_ENTRY_TIMEOUT_SECONDS,
)
# Then, update all local connections
for (
connection_id,
conversation_id,
) in self._local_connection_id_to_session_id.items():
user_id = conversation_user_ids.get(conversation_id)
if user_id:
await pipe.set(
self._get_redis_connection_key(
user_id, conversation_id, connection_id
),
1,
ex=_REDIS_ENTRY_TIMEOUT_SECONDS,
)
# Execute all commands in the pipeline
await pipe.execute()
async def _disconnect_from_stopped(self):
"""
Handle connections to conversations that have stopped unexpectedly.
This method detects when a local connection is pointing to a conversation
that was running on another server that has crashed or been terminated
without proper cleanup. It:
1. Identifies local connections to remote conversations
2. Checks which remote conversations are still running in Redis
3. Disconnects from conversations that are no longer running
4. Attempts to restart the conversation locally if possible
"""
# Get the remote sessions with local connections
connected_to_remote_sids = set(
self._local_connection_id_to_session_id.values()
) - set(self._local_agent_loops_by_sid.keys())
if not connected_to_remote_sids:
return
# Get the list of sessions which are actually running
redis = self._get_redis_client()
pattern = self._get_redis_conversation_key('*', '*')
running_remote = set()
async for key in redis.scan_iter(pattern):
parts = key.decode().split(':')
running_remote.add(parts[2])
# Get the list of connections locally where the remote agentloop has died.
stopped_conversation_ids = connected_to_remote_sids - running_remote
if not stopped_conversation_ids:
return
# Process each connection to a stopped conversation
items = list(self._local_connection_id_to_session_id.items())
for connection_id, conversation_id in items:
if conversation_id in stopped_conversation_ids:
logger.warning(
f'local_connection_to_stopped_conversation:{connection_id}:{conversation_id}'
)
# Look up the user_id from the database
async with a_session_maker() as session:
result = await session.execute(
select(StoredConversationMetadataSaas).where(
StoredConversationMetadataSaas.conversation_id
== conversation_id
)
)
conversation_metadata_saas = result.scalars().first()
user_id = (
str(conversation_metadata_saas.user_id)
if conversation_metadata_saas
else None
)
# Handle the stopped conversation asynchronously
asyncio.create_task(
self._handle_remote_conversation_stopped(user_id, connection_id) # type: ignore
)
async def _close_disconnected(self):
async with self._conversations_lock:
# Create a list of items to process to avoid modifying dict during iteration
items = list(self._detached_conversations.items())
for sid, (conversation, detach_time) in items:
await conversation.disconnect()
self._detached_conversations.pop(sid, None)
close_threshold = time.time() - self.config.sandbox.close_delay
running_loops = list(self._local_agent_loops_by_sid.items())
running_loops.sort(key=lambda item: item[1].last_active_ts)
sid_to_close: list[str] = []
for sid, session in running_loops:
state = session.agent_session.get_state()
if session.last_active_ts < close_threshold and state not in [
AgentState.RUNNING,
None,
]:
sid_to_close.append(sid)
# First we filter out any conversation that has local connections
connections = await super().get_connections(filter_to_sids=set(sid_to_close))
connected_sids = set(connections.values())
sid_to_close = [sid for sid in sid_to_close if sid not in connected_sids]
# Next we filter out any conversation that has remote connections
if sid_to_close:
connections = await self._get_connections_remotely(
filter_to_sids=set(sid_to_close)
)
connected_sids = {sid for _, sid in connections.items()}
sid_to_close = [sid for sid in sid_to_close if sid not in connected_sids]
await wait_all(
(self._close_session(sid) for sid in sid_to_close),
timeout=WAIT_TIME_BEFORE_CLOSE,
)
async def _cleanup_stale(self):
while should_continue():
try:
logger.info(
'conversation_manager',
extra={
'attached': len(self._active_conversations),
'detached': len(self._detached_conversations),
'running': len(self._local_agent_loops_by_sid),
'local_conn': len(self._local_connection_id_to_session_id),
},
)
await self._disconnect_from_stopped()
await self._close_disconnected()
await asyncio.sleep(_CLEANUP_INTERVAL_SECONDS)
except asyncio.CancelledError:
async with self._conversations_lock:
for conversation, _ in self._detached_conversations.values():
await conversation.disconnect()
self._detached_conversations.clear()
await wait_all(
(
self._close_session(sid)
for sid in self._local_agent_loops_by_sid
),
timeout=WAIT_TIME_BEFORE_CLOSE,
)
return
except Exception:
logger.warning('error_cleaning_stale', exc_info=True, stack_info=True)
await asyncio.sleep(_CLEANUP_INTERVAL_SECONDS)
async def _close_session(self, sid: str):
logger.info(f'_close_session:{sid}')
redis = self._get_redis_client()
# Keys to delete from redis
to_delete = []
# Remove connections
connection_ids_to_remove = list(
connection_id
for connection_id, conn_sid in self._local_connection_id_to_session_id.items()
if sid == conn_sid
)
if connection_ids_to_remove:
pattern = self._get_redis_connection_key('*', sid, '*')
async for key in redis.scan_iter(pattern):
parts = key.decode().split(':')
connection_id = parts[3]
if connection_id in connection_ids_to_remove:
to_delete.append(key)
logger.info(f'removing connections: {connection_ids_to_remove}')
for connection_id in connection_ids_to_remove:
await self.sio.disconnect(connection_id)
self._local_connection_id_to_session_id.pop(connection_id, None)
# Delete the conversation key if running locally
session = self._local_agent_loops_by_sid.pop(sid, None)
if not session:
logger.info(f'no_session_to_close:{sid}')
if to_delete:
redis.delete(*to_delete)
return
to_delete.append(self._get_redis_conversation_key(session.user_id, sid))
await redis.delete(*to_delete)
try:
redis_client = self._get_redis_client()
if redis_client:
await redis_client.publish(
'session_msg',
json.dumps(
{
'sid': session.sid,
'message_type': 'session_closing',
'user_id': session.user_id,
}
),
)
except Exception:
logger.info(
'error_publishing_close_session_event', exc_info=True, stack_info=True
)
await session.close()
logger.info(f'closed_session:{session.sid}')
async def get_agent_loop_info(self, user_id=None, filter_to_sids=None):
# conversation_ids = await self.get_running_agent_loops(user_id=user_id, filter_to_sids=filter_to_sids)
redis = self._get_redis_client()
results = []
if user_id:
pattern = self._get_redis_conversation_key(user_id, '*')
else:
pattern = self._get_redis_conversation_key('*', '*')
async for key in redis.scan_iter(pattern):
uid, conversation_id = key.decode().split(':')[1:]
if filter_to_sids is None or conversation_id in filter_to_sids:
results.append(
AgentLoopInfo(
conversation_id,
url=self._get_conversation_url(conversation_id),
session_api_key=None,
event_store=EventStore(conversation_id, self.file_store, uid),
runtime_status=RuntimeStatus.READY,
)
)
return results
@classmethod
def get_instance(
cls,
sio: socketio.AsyncServer,
config: OpenHandsConfig,
file_store: FileStore,
server_config: ServerConfig,
monitoring_listener: MonitoringListener | None,
) -> ConversationManager:
return ClusteredConversationManager(
sio,
config,
file_store,
server_config,
monitoring_listener, # type: ignore[arg-type]
)
async def _handle_remote_conversation_stopped(
self, user_id: str, connection_id: str
):
"""Handle a situation where a remote conversation has stopped unexpectedly.
When a server hosting a conversation crashes or is terminated without proper
cleanup, this method attempts to recover by:
1. Verifying the connection and conversation still exist
2. Checking if we can start a new conversation (within limits)
3. Restarting the conversation locally if possible
4. Disconnecting the client if recovery isn't possible
Args:
user_id: The user ID associated with the conversation
connection_id: The connection ID to handle
"""
conversation_id = self._local_connection_id_to_session_id.get(connection_id)
# Not finding a user_id or a conversation_id indicates we are in some unknown state
# so we disconnect
if not user_id or not conversation_id:
await self.sio.disconnect(connection_id)
return
# Wait a second for connections to stabilize
await asyncio.sleep(1)
# Check if there are too many loops running - if so disconnect
response_ids = await self.get_running_agent_loops(user_id)
if len(response_ids) > self.config.max_concurrent_conversations:
await self.sio.disconnect(connection_id)
return
# Restart the agent loop
from storage.saas_settings_store import SaasSettingsStore
config = load_openhands_config()
settings_store = await SaasSettingsStore.get_instance(config, user_id)
settings = await settings_store.load()
if not settings:
logger.error(f'Failed to load settings for user {user_id}')
return
await self.maybe_start_agent_loop(conversation_id, settings, user_id)
async def _start_agent_loop(
self,
sid: str,
settings: Settings,
user_id: str | None,
initial_user_msg: MessageAction | None = None,
replay_json: str | None = None,
) -> Session:
"""Start an agent loop and add conversation callback subscriber.
This method calls the parent implementation and then adds a subscriber
to the event stream that will invoke conversation callbacks when events occur.
"""
# Call the parent method to start the agent loop
session = await super()._start_agent_loop(
sid, settings, user_id, initial_user_msg, replay_json
)
# Subscribers run in a different thread - if we are going to access socketio, redis or anything else
# bound to the main event loop, we need to pass callbacks back to the main event loop.
loop = asyncio.get_running_loop()
# Add a subscriber for conversation callbacks
def conversation_callback_handler(event):
"""Handle events by invoking conversation callbacks."""
try:
if isinstance(event, AgentStateChangedObservation):
asyncio.run_coroutine_threadsafe(
invoke_conversation_callbacks(sid, event), loop
)
except Exception as e:
logger.error(
f'Error invoking conversation callbacks for {sid}: {str(e)}',
extra={'session_id': sid, 'error': str(e)},
exc_info=True,
)
# Subscribe to the event stream with our callback handler
try:
session.agent_session.event_stream.subscribe(
EventStreamSubscriber.SERVER,
conversation_callback_handler,
'conversation_callbacks',
)
except ValueError:
# Already subscribed - this can happen if the method is called multiple times
pass
return session
def get_local_session(self, sid: str) -> Session:
return self._local_agent_loops_by_sid[sid]

View File

@@ -20,7 +20,6 @@ from server.auth.constants import (
GITLAB_APP_CLIENT_ID,
RECAPTCHA_SITE_KEY,
)
from server.constants import DEPLOYMENT_MODE
from openhands.core.config.utils import load_openhands_config
from openhands.integrations.service_types import ProviderType
@@ -75,6 +74,10 @@ class SaaSServerConfig(ServerConfig):
conversation_store_class: str = (
'storage.saas_conversation_store.SaasConversationStore'
)
conversation_manager_class: str = os.environ.get(
'CONVERSATION_MANAGER_CLASS',
'server.clustered_conversation_manager.ClusteredConversationManager',
)
monitoring_listener_class: str = (
'server.saas_monitoring_listener.SaaSMonitoringListener'
)
@@ -176,7 +179,6 @@ class SaaSServerConfig(ServerConfig):
'ENABLE_JIRA': self.enable_jira,
'ENABLE_JIRA_DC': self.enable_jira_dc,
'ENABLE_LINEAR': self.enable_linear,
'DEPLOYMENT_MODE': DEPLOYMENT_MODE,
},
'PROVIDERS_CONFIGURED': providers_configured,
}

View File

@@ -15,33 +15,6 @@ IS_FEATURE_ENV = (
) # Does not include the staging deployment
IS_LOCAL_ENV = bool(HOST == 'localhost')
# _is_all_hands_managed_domain() can be removed/replaced when a self-hosted specific
# env var is created (e.g is_self_hosted` or `deployment_mode`)
def _is_all_hands_managed_domain(host: str) -> bool:
"""Check if the host is an All-Hands managed domain."""
return (
host == 'app.all-hands.dev'
or host == 'app.openhands.ai'
or host.endswith('.all-hands.dev')
or host.endswith('.openhands.ai')
)
def _get_deployment_mode() -> str:
"""Determine deployment mode based on WEB_HOST.
Returns:
'cloud' for All-Hands managed infrastructure (app.all-hands.dev, etc.)
'self_hosted' for enterprise self-hosted deployments (customer domains)
"""
if _is_all_hands_managed_domain(HOST):
return 'cloud'
return 'self_hosted'
DEPLOYMENT_MODE = _get_deployment_mode()
# Role name constants
ROLE_OWNER = 'owner'
ROLE_ADMIN = 'admin'

Some files were not shown because too many files have changed in this diff Show More