mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
119 Commits
cloud-1.22
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a815ad2c10 | ||
|
|
e86067c15b | ||
|
|
137bede1f5 | ||
|
|
8a1d80ac8f | ||
|
|
77043da280 | ||
|
|
180a35f013 | ||
|
|
18365e0323 | ||
|
|
9a743ff51a | ||
|
|
29577935b4 | ||
|
|
7498353ed5 | ||
|
|
b62bdfd143 | ||
|
|
fb98faf4ac | ||
|
|
a8f62aa30c | ||
|
|
1a7449b03a | ||
|
|
1091901be2 | ||
|
|
15160f6733 | ||
|
|
13dba59bb8 | ||
|
|
478c998f04 | ||
|
|
a9fc93ffbf | ||
|
|
cc100c0d10 | ||
|
|
7bc3300981 | ||
|
|
3e0283796e | ||
|
|
cd0175d83e | ||
|
|
f313cfceb9 | ||
|
|
fb0108f946 | ||
|
|
6b29a82de3 | ||
|
|
033c6202b7 | ||
|
|
d64d0d6bf6 | ||
|
|
b357c0c3bb | ||
|
|
16374dc9c0 | ||
|
|
a8926068ff | ||
|
|
f318792a17 | ||
|
|
505095d50a | ||
|
|
51f9266abb | ||
|
|
439fa8fc30 | ||
|
|
c1ae41acb9 | ||
|
|
270d9b1cce | ||
|
|
3b0e201a4e | ||
|
|
cd24b5838b | ||
|
|
1509018ee2 | ||
|
|
1605e97d80 | ||
|
|
06d0320e5c | ||
|
|
f7dce9c6c0 | ||
|
|
13e9d7584a | ||
|
|
e0a4c35c9c | ||
|
|
701231cbf3 | ||
|
|
f8a43f9937 | ||
|
|
c49ed64b64 | ||
|
|
3b17f27dee | ||
|
|
ae2f13ecba | ||
|
|
6d1850e94b | ||
|
|
cf7e88c8c3 | ||
|
|
6420f1cd7c | ||
|
|
c7de3dfc91 | ||
|
|
393a6bb8f8 | ||
|
|
d8c67a4d3d | ||
|
|
237e9f530e | ||
|
|
93ae8aae43 | ||
|
|
595bb4749d | ||
|
|
b43d9b1929 | ||
|
|
3fa9b84aa4 | ||
|
|
db8ab2715e | ||
|
|
fa0da8f3bd | ||
|
|
0da1f70b91 | ||
|
|
3892ab2b67 | ||
|
|
30dc1655b1 | ||
|
|
71ce61acd2 | ||
|
|
b2df428eff | ||
|
|
7bbef99771 | ||
|
|
fd014e8e23 | ||
|
|
89f3dceeb8 | ||
|
|
dcb6ac3599 | ||
|
|
3b264dd419 | ||
|
|
f212e0e856 | ||
|
|
918b0a8b59 | ||
|
|
119b0c99a8 | ||
|
|
0628679307 | ||
|
|
e8249f00a8 | ||
|
|
1651edf8c9 | ||
|
|
1fd94675d0 | ||
|
|
b841e1acb0 | ||
|
|
1af04f2833 | ||
|
|
b87f08f651 | ||
|
|
e23af62a57 | ||
|
|
9db83a1555 | ||
|
|
8f5b3ceb6c | ||
|
|
5bb9e4a567 | ||
|
|
a5a7a86600 | ||
|
|
5c8d7c4c2d | ||
|
|
2068694ea0 | ||
|
|
385122e260 | ||
|
|
97343ebe9a | ||
|
|
926f25a74b | ||
|
|
52c4d0d9d9 | ||
|
|
f1ff98b2fc | ||
|
|
26c43d1955 | ||
|
|
d81c2bc0a6 | ||
|
|
fdf5c398fd | ||
|
|
c78b923468 | ||
|
|
db78925d77 | ||
|
|
b4da0e1c69 | ||
|
|
d548665bcf | ||
|
|
eb940ea5e7 | ||
|
|
22b91976fd | ||
|
|
dcf044f8c3 | ||
|
|
d58106b29b | ||
|
|
e11faa6dd1 | ||
|
|
b4b77fbc31 | ||
|
|
ef452b6544 | ||
|
|
0eafa9fd15 | ||
|
|
ab64a65f25 | ||
|
|
4cdf88d480 | ||
|
|
eab9d9e3c7 | ||
|
|
58df84e16c | ||
|
|
3cd74d3bac | ||
|
|
20018842a4 | ||
|
|
cce2080ae0 | ||
|
|
a0304b9e4c | ||
|
|
de492b792f |
47
.agents/skills/custom-codereview-guide.md
Normal file
47
.agents/skills/custom-codereview-guide.md
Normal file
@@ -0,0 +1,47 @@
|
||||
---
|
||||
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.
|
||||
@@ -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: CI builds Docker images automatically
|
||||
#### Step 3: Images get tagged automatically
|
||||
|
||||
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`
|
||||
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 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.
|
||||
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.
|
||||
|
||||
## Development: Pin SDK to an Unreleased Commit
|
||||
|
||||
|
||||
@@ -46,39 +46,16 @@ 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
1
.gitattributes
vendored
@@ -4,4 +4,5 @@
|
||||
* text eol=lf
|
||||
# Git incorrectly thinks some media is text
|
||||
*.png -text
|
||||
*.gif -text
|
||||
*.mp4 -text
|
||||
|
||||
51
.github/actions/docker-image-tags/action.yml
vendored
Normal file
51
.github/actions/docker-image-tags/action.yml
vendored
Normal file
@@ -0,0 +1,51 @@
|
||||
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 }}
|
||||
43
.github/actions/docker-merge-manifest/action.yml
vendored
Normal file
43
.github/actions/docker-merge-manifest/action.yml
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
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"
|
||||
1
.github/scripts/update_pr_description.sh
vendored
1
.github/scripts/update_pr_description.sh
vendored
@@ -13,7 +13,6 @@ 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}"
|
||||
|
||||
|
||||
116
.github/workflows/_build-image.yml
vendored
Normal file
116
.github/workflows/_build-image.yml
vendored
Normal file
@@ -0,0 +1,116 @@
|
||||
# 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"
|
||||
228
.github/workflows/e2e-tests.yml
vendored
228
.github/workflows/e2e-tests.yml
vendored
@@ -1,228 +0,0 @@
|
||||
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@v6
|
||||
|
||||
- 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@v7
|
||||
with:
|
||||
name: playwright-report
|
||||
path: tests/e2e/test-results/
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload OpenHands logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
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
|
||||
260
.github/workflows/ghcr-build.yml
vendored
260
.github/workflows/ghcr-build.yml
vendored
@@ -1,17 +1,13 @@
|
||||
# Workflow that builds, tests and then pushes the OpenHands and runtime docker images to the ghcr.io repository
|
||||
# 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.
|
||||
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-*"
|
||||
tags:
|
||||
- "*"
|
||||
- "oss-rel-*"
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
@@ -20,243 +16,37 @@ on:
|
||||
required: true
|
||||
default: ""
|
||||
|
||||
# If triggered by a PR, it will be in the same group. However, each commit on main will be in its own unique group
|
||||
# PR events share a group so pushes supersede each other; each commit on a release branch gets its own group.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ (github.head_ref && github.ref) || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
RELEVANT_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
jobs:
|
||||
define-matrix:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
base_image: ${{ steps.define-base-images.outputs.base_image }}
|
||||
platforms: ${{ steps.define-base-images.outputs.platforms }}
|
||||
steps:
|
||||
- name: Define base images
|
||||
shell: bash
|
||||
id: define-base-images
|
||||
run: |
|
||||
if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then
|
||||
platforms="linux/amd64"
|
||||
json=$(jq -n -c --arg platforms "$platforms" '[
|
||||
{ image: "nikolaik/python-nodejs:python3.12-nodejs22-slim", tag: "nikolaik", platforms: $platforms }
|
||||
]')
|
||||
else
|
||||
platforms="linux/amd64,linux/arm64"
|
||||
json=$(jq -n -c --arg platforms "$platforms" '[
|
||||
{ image: "nikolaik/python-nodejs:python3.12-nodejs22-slim", tag: "nikolaik", platforms: $platforms },
|
||||
{ image: "ubuntu:24.04", tag: "ubuntu", platforms: $platforms }
|
||||
]')
|
||||
fi
|
||||
echo "base_image=$json" >> "$GITHUB_OUTPUT"
|
||||
echo "platforms=$platforms" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Builds the OpenHands Docker images
|
||||
ghcr_build_app:
|
||||
name: Build App Image
|
||||
runs-on: ubuntu-22.04
|
||||
if: "!(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/ext-v'))"
|
||||
needs: define-matrix
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
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@v4
|
||||
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 -p ${{ needs.define-matrix.outputs.platforms }}
|
||||
|
||||
# Builds the runtime Docker images
|
||||
ghcr_build_runtime:
|
||||
name: Build Runtime Image
|
||||
runs-on: ubuntu-22.04
|
||||
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@v6
|
||||
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@v4
|
||||
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: actions/setup-python@v5
|
||||
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 -p ${{ matrix.base_image.platforms }}
|
||||
|
||||
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: docker/build-push-action@v6
|
||||
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: docker/build-push-action@v6
|
||||
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@v7
|
||||
with:
|
||||
name: runtime-src-${{ matrix.base_image.tag }}
|
||||
path: containers/runtime
|
||||
|
||||
ghcr_build_enterprise:
|
||||
name: Push Enterprise Image
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
needs: [define-matrix, ghcr_build_app]
|
||||
# Do not build enterprise in forks
|
||||
build_app:
|
||||
name: App
|
||||
if: github.event.pull_request.head.repo.fork != true
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
uses: ./.github/workflows/_build-image.yml
|
||||
with:
|
||||
image: ghcr.io/openhands/openhands
|
||||
dockerfile: containers/app/Dockerfile
|
||||
|
||||
# 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@v4
|
||||
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@v6
|
||||
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}}
|
||||
type=match,pattern=cloud-\d+\.\d+\.\d+
|
||||
flavor: |
|
||||
latest=auto
|
||||
prefix=
|
||||
suffix=
|
||||
env:
|
||||
DOCKER_METADATA_PR_HEAD_SHA: true
|
||||
- name: Determine app image tag
|
||||
shell: bash
|
||||
run: |
|
||||
# Use the commit SHA to pin the exact app image built by ghcr_build_app,
|
||||
# rather than a mutable branch tag like "main" which can serve stale cached layers.
|
||||
echo "OPENHANDS_DOCKER_TAG=${RELEVANT_SHA}" >> $GITHUB_ENV
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
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: ubuntu-22.04
|
||||
steps:
|
||||
- name: All tests passed
|
||||
run: echo "All runtime tests have passed successfully!"
|
||||
build_enterprise:
|
||||
name: Enterprise
|
||||
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
|
||||
|
||||
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: [ghcr_build_runtime]
|
||||
needs: build_app
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -264,7 +54,7 @@ jobs:
|
||||
|
||||
- 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:
|
||||
@@ -275,4 +65,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"
|
||||
|
||||
2
.github/workflows/lint-fix.yml
vendored
2
.github/workflows/lint-fix.yml
vendored
@@ -72,7 +72,7 @@ jobs:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: 3.12
|
||||
cache: "pip"
|
||||
|
||||
4
.github/workflows/lint.yml
vendored
4
.github/workflows/lint.yml
vendored
@@ -47,7 +47,7 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: 3.12
|
||||
cache: "pip"
|
||||
@@ -64,7 +64,7 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: 3.12
|
||||
cache: "pip"
|
||||
|
||||
433
.github/workflows/openhands-resolver.yml
vendored
433
.github/workflows/openhands-resolver.yml
vendored
@@ -1,433 +0,0 @@
|
||||
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@v6
|
||||
|
||||
- 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@v7
|
||||
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.`
|
||||
});
|
||||
6
.github/workflows/pr-artifacts.yml
vendored
6
.github/workflows/pr-artifacts.yml
vendored
@@ -35,7 +35,7 @@ jobs:
|
||||
if: steps.check-fork.outputs.is_fork == 'false'
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
token: ${{ secrets.ALLHANDS_BOT_GITHUB_PAT }}
|
||||
token: ${{ secrets.OPENHANDS_BOT_GITHUB_PAT_PUBLIC }}
|
||||
|
||||
- name: Remove .pr/ directory
|
||||
id: remove
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
|
||||
- name: Update PR comment after cleanup
|
||||
if: steps.check-fork.outputs.is_fork == 'false' && steps.remove.outputs.removed == 'true'
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const marker = '<!-- pr-artifacts-notice -->';
|
||||
@@ -107,7 +107,7 @@ jobs:
|
||||
|
||||
- name: Post or update PR comment
|
||||
if: steps.check.outputs.exists == 'true'
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const marker = '<!-- pr-artifacts-notice -->';
|
||||
|
||||
2
.github/workflows/pr-review-by-openhands.yml
vendored
2
.github/workflows/pr-review-by-openhands.yml
vendored
@@ -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.ALLHANDS_BOT_GITHUB_PAT }}
|
||||
github-token: ${{ secrets.OPENHANDS_BOT_GITHUB_PAT_PUBLIC }}
|
||||
lmnr-api-key: ${{ secrets.LMNR_SKILLS_API_KEY }}
|
||||
|
||||
10
.github/workflows/py-tests.yml
vendored
10
.github/workflows/py-tests.yml
vendored
@@ -45,7 +45,7 @@ jobs:
|
||||
- name: Install poetry via pipx
|
||||
run: pipx install poetry
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: "poetry"
|
||||
@@ -60,10 +60,6 @@ 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
|
||||
with:
|
||||
@@ -84,7 +80,7 @@ jobs:
|
||||
- name: Install poetry via pipx
|
||||
run: pipx install poetry
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: "poetry"
|
||||
@@ -115,7 +111,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/download-artifact@v7
|
||||
- uses: actions/download-artifact@v8
|
||||
id: download
|
||||
with:
|
||||
pattern: coverage-*
|
||||
|
||||
2
.github/workflows/pypi-release.yml
vendored
2
.github/workflows/pypi-release.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
||||
|| (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && !contains(github.ref, '-cli') && !startsWith(github.ref, 'refs/tags/cloud-'))
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: 3.12
|
||||
- name: Install Poetry
|
||||
|
||||
59
.github/workflows/tag-image.yml
vendored
Normal file
59
.github/workflows/tag-image.yml
vendored
Normal file
@@ -0,0 +1,59 @@
|
||||
# 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"
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
steps:
|
||||
- name: Check if welcome comment already exists
|
||||
id: check_comment
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@v9
|
||||
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@v7
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const repoUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}`;
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -254,10 +254,6 @@ run_instance_logs
|
||||
|
||||
runtime_*.tar
|
||||
|
||||
# docker build
|
||||
containers/runtime/Dockerfile
|
||||
containers/runtime/project.tar.gz
|
||||
containers/runtime/code
|
||||
**/node_modules/
|
||||
|
||||
# test results
|
||||
|
||||
11
AGENTS.md
11
AGENTS.md
@@ -13,6 +13,14 @@ 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.
|
||||
@@ -138,6 +146,8 @@ 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
|
||||
@@ -226,6 +236,7 @@ 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:**
|
||||
|
||||
|
||||
@@ -36,8 +36,6 @@ 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?
|
||||
|
||||
@@ -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 in [`agentskills utilities`](https://github.com/OpenHands/OpenHands/tree/main/openhands/runtime/plugins/agent_skills/utils/aider)
|
||||
- Description: AI pair programming tool. OpenHands has adapted and integrated its linter module for code-related tasks.
|
||||
|
||||
#### [BrowserGym](https://github.com/ServiceNow/BrowserGym)
|
||||
- License: Apache License 2.0
|
||||
|
||||
@@ -309,16 +309,6 @@ 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
|
||||
@@ -339,4 +329,3 @@ 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
|
||||
|
||||
29
Makefile
29
Makefile
@@ -11,7 +11,15 @@ DEFAULT_WORKSPACE_DIR = "./workspace"
|
||||
DEFAULT_MODEL = "gpt-4o"
|
||||
CONFIG_FILE = config.toml
|
||||
PRE_COMMIT_CONFIG_PATH = "./dev_config/python/.pre-commit-config.yaml"
|
||||
PYTHON_VERSION = 3.12
|
||||
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)
|
||||
KIND_CLUSTER_NAME = "local-hands"
|
||||
|
||||
# ANSI color codes
|
||||
@@ -63,10 +71,10 @@ check-system:
|
||||
|
||||
check-python:
|
||||
@echo "$(YELLOW)Checking Python installation...$(RESET)"
|
||||
@if command -v python$(PYTHON_VERSION) > /dev/null; then \
|
||||
echo "$(BLUE)$(shell python$(PYTHON_VERSION) --version) is already installed.$(RESET)"; \
|
||||
@if [ -n "$(PYTHON)" ]; then \
|
||||
echo "$(BLUE)$$($(PYTHON) --version) is already installed (using $(PYTHON)).$(RESET)"; \
|
||||
else \
|
||||
echo "$(RED)Python $(PYTHON_VERSION) is not installed. Please install Python $(PYTHON_VERSION) to continue.$(RESET)"; \
|
||||
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; \
|
||||
fi
|
||||
|
||||
@@ -118,31 +126,34 @@ check-tmux:
|
||||
|
||||
check-poetry:
|
||||
@echo "$(YELLOW)Checking Poetry installation...$(RESET)"
|
||||
@if command -v poetry > /dev/null; then \
|
||||
@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 \
|
||||
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$(PYTHON_VERSION) -$(RESET)"; \
|
||||
echo "$(RED) curl -sSL https://install.python-poetry.org | $(PYTHON) -$(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$(PYTHON_VERSION) -$(RESET)"; \
|
||||
echo "$(RED) curl -sSL https://install.python-poetry.org | $(PYTHON) -$(RESET)"; \
|
||||
echo "$(RED)More detail here: https://python-poetry.org/docs/#installing-with-the-official-installer$(RESET)"; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
install-python-dependencies:
|
||||
install-python-dependencies: check-python
|
||||
@echo "$(GREEN)Installing Python dependencies...$(RESET)"
|
||||
@if [ -z "${TZ}" ]; then \
|
||||
echo "Defaulting TZ (timezone) to UTC"; \
|
||||
export TZ="UTC"; \
|
||||
fi
|
||||
poetry env use python$(PYTHON_VERSION)
|
||||
poetry env use $(PYTHON)
|
||||
@if [ "$(shell uname)" = "Darwin" ]; then \
|
||||
echo "$(BLUE)Installing chroma-hnswlib...$(RESET)"; \
|
||||
export HNSWLIB_NO_NATIVE=1; \
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
ARG OPENHANDS_BUILD_VERSION=dev
|
||||
FROM node:25.8-trixie-slim AS frontend-builder
|
||||
FROM node:25.9-trixie-slim AS frontend-builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -88,11 +88,8 @@ 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 {} +
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
DOCKER_REGISTRY=ghcr.io
|
||||
DOCKER_ORG=openhands
|
||||
DOCKER_IMAGE=openhands
|
||||
DOCKER_BASE_DIR="."
|
||||
@@ -23,18 +23,6 @@ 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
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
#!/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
|
||||
platform_override=""
|
||||
|
||||
# Function to display usage information
|
||||
usage() {
|
||||
echo "Usage: $0 -i <image_name> [-o <org_name>] [--push] [--load] [-t <tag_suffix>] [-p <platform>] [--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 " -p: Platform(s) to build for (e.g. linux/amd64 or linux/amd64,linux/arm64)"
|
||||
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 ;;
|
||||
-p) platform_override="$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"
|
||||
|
||||
# Determine the platform(s) to build for
|
||||
if [[ -n "$platform_override" ]]; then
|
||||
platform="$platform_override"
|
||||
elif [[ $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
|
||||
@@ -1,12 +0,0 @@
|
||||
# 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
|
||||
```
|
||||
@@ -1,7 +0,0 @@
|
||||
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=
|
||||
@@ -3,9 +3,9 @@ repos:
|
||||
rev: v5.0.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
exclude: ^(docs/|modules/|python/|openhands-ui/|third_party/|enterprise/)
|
||||
exclude: ^(docs/|modules/|python/|openhands-ui/|enterprise/)
|
||||
- id: end-of-file-fixer
|
||||
exclude: ^(docs/|modules/|python/|openhands-ui/|third_party/|enterprise/)
|
||||
exclude: ^(docs/|modules/|python/|openhands-ui/|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: ^(third_party/|enterprise/)
|
||||
exclude: ^(enterprise/)
|
||||
# Run the formatter.
|
||||
- id: ruff-format
|
||||
entry: ruff format --config dev_config/python/ruff.toml
|
||||
types_or: [python, pyi, jupyter]
|
||||
exclude: ^(third_party/|enterprise/)
|
||||
exclude: ^(enterprise/)
|
||||
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: v1.15.0
|
||||
@@ -58,8 +58,9 @@ repos:
|
||||
types-Markdown,
|
||||
pydantic,
|
||||
lxml,
|
||||
"openhands-sdk==1.14",
|
||||
"openhands-tools==1.14",
|
||||
"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/
|
||||
|
||||
@@ -10,10 +10,7 @@ strict_optional = True
|
||||
disable_error_code = type-abstract
|
||||
|
||||
# Exclude third-party runtime directory from type checking
|
||||
exclude = (third_party/|enterprise/)
|
||||
|
||||
[mypy-openhands.memory.condenser.impl.*]
|
||||
disable_error_code = override
|
||||
exclude = (enterprise/)
|
||||
|
||||
[mypy-openai.*]
|
||||
follow_imports = skip
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Exclude third-party runtime directory from linting
|
||||
exclude = ["third_party/", "enterprise/"]
|
||||
exclude = ["enterprise/"]
|
||||
|
||||
[lint]
|
||||
select = [
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# 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
|
||||
|
||||
@@ -59,7 +59,7 @@ handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = DEBUG
|
||||
level = WARNING
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
|
||||
@@ -724,12 +724,14 @@
|
||||
"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://laminar.$WEB_HOST/api/auth/callback/keycloak",
|
||||
"https://analytics.$WEB_HOST/api/auth/callback/keycloak"
|
||||
],
|
||||
"webOrigins": [
|
||||
"https://$WEB_HOST",
|
||||
"https://$AUTH_WEB_HOST",
|
||||
"https://laminar.$WEB_HOST"
|
||||
"https://laminar.$WEB_HOST",
|
||||
"https://analytics.$WEB_HOST"
|
||||
],
|
||||
"notBefore": 0,
|
||||
"bearerOnly": false,
|
||||
|
||||
@@ -50,6 +50,7 @@ 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
|
||||
|
||||
@@ -61,13 +61,6 @@ 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.
|
||||
|
||||
```
|
||||
@@ -203,7 +196,6 @@ 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",
|
||||
@@ -237,7 +229,6 @@ 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",
|
||||
|
||||
@@ -429,6 +429,11 @@ 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
|
||||
|
||||
|
||||
@@ -32,7 +32,6 @@ 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
|
||||
@@ -318,17 +317,12 @@ class GithubManager(Manager[GithubViewType]):
|
||||
return
|
||||
|
||||
async def start_job(self, github_view: GithubViewType) -> None:
|
||||
"""Kick off a job with openhands agent.
|
||||
"""Kick off a job with openhands agent using V1 app conversation system.
|
||||
|
||||
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 = ''
|
||||
|
||||
@@ -402,19 +396,7 @@ class GithubManager(Manager[GithubViewType]):
|
||||
f'[GitHub] Created conversation {conversation_id} for user {user_info.username}'
|
||||
)
|
||||
|
||||
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}'
|
||||
)
|
||||
# V1 callback processors are registered by the view during conversation creation
|
||||
|
||||
# Send message with conversation link
|
||||
conversation_link = CONVERSATION_URL.format(conversation_id)
|
||||
|
||||
@@ -106,16 +106,18 @@ async def summarize_issue_solvability(
|
||||
f'Solvability analysis disabled for user {github_view.user_info.user_id}'
|
||||
)
|
||||
|
||||
if user_settings.llm_api_key is None:
|
||||
agent_settings = user_settings.agent_settings
|
||||
llm_settings = agent_settings.llm
|
||||
if llm_settings.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=user_settings.llm_model,
|
||||
api_key=user_settings.llm_api_key.get_secret_value(),
|
||||
base_url=user_settings.llm_base_url,
|
||||
model=llm_settings.model,
|
||||
api_key=llm_settings.api_key.get_secret_value(),
|
||||
base_url=llm_settings.base_url,
|
||||
)
|
||||
except ValidationError as e:
|
||||
raise ValueError(
|
||||
|
||||
@@ -43,7 +43,6 @@ 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 start_conversation
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
from openhands.storage.data_models.conversation_metadata import (
|
||||
ConversationMetadata,
|
||||
@@ -209,43 +208,9 @@ class GithubIssue(ResolverViewInterface):
|
||||
conversation_metadata: ConversationMetadata,
|
||||
saas_user_auth: UserAuth,
|
||||
):
|
||||
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,
|
||||
# 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
|
||||
)
|
||||
|
||||
async def _get_v1_initial_user_message(self, jinja_env: Environment) -> str:
|
||||
@@ -258,7 +223,6 @@ 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
|
||||
)
|
||||
|
||||
@@ -24,7 +24,6 @@ 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
|
||||
@@ -171,17 +170,11 @@ class GitlabManager(Manager[GitlabViewType]):
|
||||
)
|
||||
|
||||
async def start_job(self, gitlab_view: GitlabViewType) -> None:
|
||||
"""
|
||||
Start a job for the GitLab view.
|
||||
"""Start a job for the GitLab view using V1 app conversation system.
|
||||
|
||||
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
|
||||
@@ -235,19 +228,7 @@ class GitlabManager(Manager[GitlabViewType]):
|
||||
f'[GitLab] Created conversation {conversation_id} for user {user_info.username}'
|
||||
)
|
||||
|
||||
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}'
|
||||
)
|
||||
# V1 callback processors are registered by the view during conversation creation
|
||||
|
||||
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})"
|
||||
|
||||
@@ -31,7 +31,6 @@ 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 start_conversation
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
from openhands.storage.data_models.conversation_metadata import (
|
||||
ConversationMetadata,
|
||||
@@ -167,41 +166,9 @@ class GitlabIssue(ResolverViewInterface):
|
||||
conversation_metadata: ConversationMetadata,
|
||||
saas_user_auth: UserAuth,
|
||||
):
|
||||
# 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,
|
||||
# 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
|
||||
)
|
||||
|
||||
async def _create_v1_conversation(
|
||||
|
||||
@@ -24,20 +24,20 @@ from integrations.jira.jira_types import (
|
||||
RepositoryNotFoundError,
|
||||
StartingConvoException,
|
||||
)
|
||||
from integrations.jira.jira_view import JiraFactory, JiraNewConversationView
|
||||
from integrations.jira.jira_view import JiraFactory
|
||||
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,11 +259,6 @@ 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',
|
||||
@@ -285,19 +280,7 @@ class JiraManager(Manager[JiraViewInterface]):
|
||||
},
|
||||
)
|
||||
|
||||
# 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
|
||||
# Create success message
|
||||
msg_info = view.get_response_msg()
|
||||
|
||||
except MissingSettingsError as e:
|
||||
@@ -359,7 +342,7 @@ class JiraManager(Manager[JiraViewInterface]):
|
||||
url = (
|
||||
f'{JIRA_CLOUD_API_URL}/{jira_cloud_id}/rest/api/2/issue/{issue_key}/comment'
|
||||
)
|
||||
data = {'body': message}
|
||||
data = format_jira_comment_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
|
||||
|
||||
@@ -136,11 +136,10 @@ class JiraPayloadParser:
|
||||
items = changelog.get('items', [])
|
||||
|
||||
# Extract labels that were added
|
||||
labels = [
|
||||
item.get('toString', '')
|
||||
for item in items
|
||||
if item.get('field') == 'labels' and 'toString' in item
|
||||
]
|
||||
labels = set()
|
||||
for item in items:
|
||||
if item.get('field') == 'labels' and item.get('toString'):
|
||||
labels.update(item['toString'].split())
|
||||
|
||||
if self.oh_label not in labels:
|
||||
return JiraPayloadSkipped(
|
||||
|
||||
238
enterprise/integrations/jira/jira_v1_callback_processor.py
Normal file
238
enterprise/integrations/jira/jira_v1_callback_processor.py
Normal file
@@ -0,0 +1,238 @@
|
||||
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}')
|
||||
@@ -7,7 +7,7 @@ Views are responsible for:
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from uuid import uuid4
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import httpx
|
||||
from integrations.jira.jira_payload import JiraWebhookPayload
|
||||
@@ -16,25 +16,37 @@ 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 server.config import get_config
|
||||
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 storage.saas_conversation_store import SaasConversationStore
|
||||
|
||||
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
|
||||
from openhands.server.services.conversation_service import start_conversation
|
||||
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 (
|
||||
ConversationMetadata,
|
||||
ConversationTrigger,
|
||||
)
|
||||
from openhands.utils.conversation_summary import get_default_conversation_title
|
||||
from openhands.utils.http_session import httpx_verify_option
|
||||
|
||||
JIRA_CLOUD_API_URL = 'https://api.atlassian.com/ex/jira'
|
||||
@@ -54,7 +66,7 @@ class JiraNewConversationView(JiraViewInterface):
|
||||
saas_user_auth: UserAuth
|
||||
jira_user: JiraUser
|
||||
jira_workspace: JiraWorkspace
|
||||
selected_repo: str | None = None
|
||||
selected_repo: str = ''
|
||||
conversation_id: str = ''
|
||||
|
||||
# Lazy-loaded issue details (cached after first fetch)
|
||||
@@ -64,6 +76,9 @@ 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).
|
||||
|
||||
@@ -169,107 +184,131 @@ 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()
|
||||
user_secrets = await self.saas_user_auth.get_secrets()
|
||||
instructions, user_msg = await self._get_instructions(jinja_env)
|
||||
if not provider_tokens:
|
||||
return None
|
||||
|
||||
try:
|
||||
user_id = self.jira_user.keycloak_user_id
|
||||
|
||||
# Resolve git provider from repository
|
||||
resolved_git_provider = None
|
||||
if provider_tokens:
|
||||
try:
|
||||
provider_handler = ProviderHandler(provider_tokens)
|
||||
repository = await provider_handler.verify_repo_provider(
|
||||
self.selected_repo
|
||||
)
|
||||
resolved_git_provider = repository.git_provider
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f'[Jira] Failed to resolve git provider for {self.selected_repo}: {e}'
|
||||
)
|
||||
|
||||
# Resolve target org based on claimed git organizations
|
||||
resolved_org_id = None
|
||||
if resolved_git_provider and self.selected_repo:
|
||||
try:
|
||||
resolved_org_id = await resolve_org_for_repo(
|
||||
provider=resolved_git_provider.value,
|
||||
full_repo_name=self.selected_repo,
|
||||
keycloak_user_id=user_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f'[Jira] Failed to resolve org for {self.selected_repo}: {e}'
|
||||
)
|
||||
|
||||
# Create the conversation store with resolver org routing
|
||||
store = await SaasConversationStore.get_resolver_instance(
|
||||
get_config(),
|
||||
user_id,
|
||||
resolved_org_id,
|
||||
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,
|
||||
)
|
||||
|
||||
conversation_id = uuid4().hex
|
||||
conversation_metadata = ConversationMetadata(
|
||||
trigger=ConversationTrigger.JIRA,
|
||||
conversation_id=conversation_id,
|
||||
title=get_default_conversation_title(conversation_id),
|
||||
user_id=user_id,
|
||||
selected_repository=self.selected_repo,
|
||||
selected_branch=None,
|
||||
git_provider=resolved_git_provider,
|
||||
)
|
||||
await store.save_metadata(conversation_metadata)
|
||||
|
||||
await start_conversation(
|
||||
user_id=user_id,
|
||||
git_provider_tokens=provider_tokens,
|
||||
custom_secrets=user_secrets.custom_secrets if user_secrets else None,
|
||||
initial_user_msg=user_msg,
|
||||
image_urls=None,
|
||||
replay_json=None,
|
||||
conversation_id=conversation_id,
|
||||
conversation_metadata=conversation_metadata,
|
||||
conversation_instructions=instructions,
|
||||
)
|
||||
|
||||
self.conversation_id = conversation_id
|
||||
|
||||
logger.info(
|
||||
'[Jira] Created conversation',
|
||||
extra={
|
||||
'conversation_id': self.conversation_id,
|
||||
'issue_key': self.payload.issue_key,
|
||||
'selected_repo': self.selected_repo,
|
||||
'resolved_org_id': str(resolved_org_id)
|
||||
if resolved_org_id
|
||||
else None,
|
||||
},
|
||||
)
|
||||
|
||||
# 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
|
||||
|
||||
return resolved_org_id
|
||||
except Exception as 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,
|
||||
logger.warning(
|
||||
f'[Jira] Failed to resolve org for {self.selected_repo}: {e}'
|
||||
)
|
||||
raise StartingConvoException(f'Failed to create conversation: {str(e)}')
|
||||
return None
|
||||
|
||||
def get_response_msg(self) -> str:
|
||||
"""Get the response message to send back to Jira."""
|
||||
|
||||
@@ -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,12 +354,7 @@ class JiraDcManager(Manager[JiraDcViewInterface]):
|
||||
return False
|
||||
|
||||
async def start_job(self, jira_dc_view: JiraDcViewInterface) -> None:
|
||||
"""Start a Jira DC job/conversation."""
|
||||
# Import here to prevent circular import
|
||||
from server.conversation_callback_processor.jira_dc_callback_processor import (
|
||||
JiraDcCallbackProcessor,
|
||||
)
|
||||
|
||||
"""Start a Jira DC job/conversation using V1 app conversation system."""
|
||||
try:
|
||||
user_info: JiraDcUser = jira_dc_view.jira_dc_user
|
||||
logger.info(
|
||||
@@ -367,7 +362,15 @@ class JiraDcManager(Manager[JiraDcViewInterface]):
|
||||
f'issue {jira_dc_view.job_context.issue_key}',
|
||||
)
|
||||
|
||||
# Create conversation
|
||||
# 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
|
||||
conversation_id = await jira_dc_view.create_or_update_conversation(
|
||||
self.jinja_env
|
||||
)
|
||||
@@ -376,21 +379,6 @@ 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()
|
||||
|
||||
@@ -468,7 +456,8 @@ class JiraDcManager(Manager[JiraDcViewInterface]):
|
||||
"""
|
||||
url = f'{base_api_url}/rest/api/2/issue/{issue_key}/comment'
|
||||
headers = {'Authorization': f'Bearer {svc_acc_api_key}'}
|
||||
data = {'body': message}
|
||||
# Convert standard Markdown to Jira Wiki Markup for proper rendering
|
||||
data = {'body': markdown_to_jira_markup(message)}
|
||||
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
|
||||
response = await client.post(url, headers=headers, json=data)
|
||||
response.raise_for_status()
|
||||
|
||||
243
enterprise/integrations/jira_dc/jira_dc_v1_callback_processor.py
Normal file
243
enterprise/integrations/jira_dc/jira_dc_v1_callback_processor.py
Normal file
@@ -0,0 +1,243 @@
|
||||
"""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}')
|
||||
@@ -1,34 +1,51 @@
|
||||
from dataclasses import dataclass
|
||||
"""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 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.utils import CONVERSATION_URL, get_final_agent_observation
|
||||
from integrations.resolver_context import ResolverUserContext
|
||||
from integrations.resolver_org_router import resolve_org_for_repo
|
||||
from integrations.utils import CONVERSATION_URL
|
||||
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.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.agent_server.models import SendMessageRequest
|
||||
from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
AppConversationStartRequest,
|
||||
AppConversationStartTaskStatus,
|
||||
)
|
||||
from openhands.server.shared import ConversationStoreImpl, config, conversation_manager
|
||||
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.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
|
||||
@@ -36,9 +53,14 @@ class JiraDcNewConversationView(JiraDcViewInterface):
|
||||
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"""
|
||||
# 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_template = jinja_env.get_template('jira_dc_instructions.j2')
|
||||
instructions = instructions_template.render()
|
||||
|
||||
@@ -54,58 +76,148 @@ class JiraDcNewConversationView(JiraDcViewInterface):
|
||||
return instructions, user_msg
|
||||
|
||||
async def create_or_update_conversation(self, jinja_env: Environment) -> str:
|
||||
"""Create a new Jira DC conversation"""
|
||||
"""Create a new Jira DC conversation using V1 app conversation system.
|
||||
|
||||
Returns:
|
||||
The conversation ID
|
||||
|
||||
Raises:
|
||||
StartingConvoException: If conversation creation fails
|
||||
"""
|
||||
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()
|
||||
# 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')
|
||||
|
||||
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:
|
||||
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,
|
||||
)
|
||||
|
||||
self.conversation_id = agent_loop_info.conversation_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,
|
||||
)
|
||||
|
||||
await integration_store.create_conversation(jira_dc_conversation)
|
||||
|
||||
return self.conversation_id
|
||||
provider_handler = ProviderHandler(provider_tokens)
|
||||
repository = await provider_handler.verify_repo_provider(self.selected_repo)
|
||||
return repository.git_provider
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f'[Jira DC] Failed to create conversation: {str(e)}', exc_info=True
|
||||
logger.warning(
|
||||
f'[Jira DC] Failed to determine git provider for {self.selected_repo}: {e}'
|
||||
)
|
||||
raise StartingConvoException(f'Failed to create conversation: {str(e)}')
|
||||
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
|
||||
|
||||
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,
|
||||
)
|
||||
return resolved_org_id
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f'[Jira DC] Failed to resolve org for {self.selected_repo}: {e}'
|
||||
)
|
||||
return None
|
||||
|
||||
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
|
||||
@@ -114,8 +226,7 @@ class JiraDcExistingConversationView(JiraDcViewInterface):
|
||||
conversation_id: str
|
||||
|
||||
async def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
|
||||
"""Instructions passed when conversation is first initialized"""
|
||||
|
||||
"""Instructions passed when conversation is updated."""
|
||||
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,
|
||||
@@ -127,64 +238,107 @@ class JiraDcExistingConversationView(JiraDcViewInterface):
|
||||
return '', user_msg
|
||||
|
||||
async def create_or_update_conversation(self, jinja_env: Environment) -> str:
|
||||
"""Update an existing Jira conversation"""
|
||||
"""Send a message to an existing V1 conversation.
|
||||
|
||||
user_id = self.jira_dc_user.keycloak_user_id
|
||||
Returns:
|
||||
The conversation ID
|
||||
"""
|
||||
await self._send_message_to_v1_conversation(jinja_env)
|
||||
return self.conversation_id
|
||||
|
||||
try:
|
||||
conversation_store = await ConversationStoreImpl.get_instance(
|
||||
config, 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,
|
||||
)
|
||||
|
||||
# 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:
|
||||
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)}')
|
||||
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
|
||||
|
||||
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}]."
|
||||
|
||||
@@ -200,7 +354,6 @@ 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')
|
||||
|
||||
|
||||
@@ -1,536 +0,0 @@
|
||||
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)}'
|
||||
)
|
||||
@@ -1,40 +0,0 @@
|
||||
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
|
||||
@@ -1,288 +0,0 @@
|
||||
from dataclasses import dataclass
|
||||
from uuid import uuid4
|
||||
|
||||
from integrations.linear.linear_types import LinearViewInterface, StartingConvoException
|
||||
from integrations.models import JobContext
|
||||
from integrations.resolver_org_router import resolve_org_for_repo
|
||||
from integrations.utils import CONVERSATION_URL, get_final_agent_observation
|
||||
from jinja2 import Environment
|
||||
from server.config import get_config
|
||||
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 storage.saas_conversation_store import SaasConversationStore
|
||||
|
||||
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.integrations.provider import ProviderHandler
|
||||
from openhands.server.services.conversation_service import (
|
||||
setup_init_conversation_settings,
|
||||
start_conversation,
|
||||
)
|
||||
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 (
|
||||
ConversationMetadata,
|
||||
ConversationTrigger,
|
||||
)
|
||||
from openhands.utils.conversation_summary import get_default_conversation_title
|
||||
|
||||
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:
|
||||
user_id = self.linear_user.keycloak_user_id
|
||||
|
||||
# Resolve git provider from repository
|
||||
resolved_git_provider = None
|
||||
if provider_tokens:
|
||||
try:
|
||||
provider_handler = ProviderHandler(provider_tokens)
|
||||
repository = await provider_handler.verify_repo_provider(
|
||||
self.selected_repo
|
||||
)
|
||||
resolved_git_provider = repository.git_provider
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f'[Linear] Failed to resolve git provider for {self.selected_repo}: {e}'
|
||||
)
|
||||
|
||||
# Resolve target org based on claimed git organizations
|
||||
resolved_org_id = None
|
||||
if resolved_git_provider and self.selected_repo:
|
||||
try:
|
||||
resolved_org_id = await resolve_org_for_repo(
|
||||
provider=resolved_git_provider.value,
|
||||
full_repo_name=self.selected_repo,
|
||||
keycloak_user_id=user_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f'[Linear] Failed to resolve org for {self.selected_repo}: {e}'
|
||||
)
|
||||
|
||||
# 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(),
|
||||
user_id,
|
||||
resolved_org_id,
|
||||
)
|
||||
|
||||
conversation_id = uuid4().hex
|
||||
conversation_metadata = ConversationMetadata(
|
||||
trigger=ConversationTrigger.LINEAR,
|
||||
conversation_id=conversation_id,
|
||||
title=get_default_conversation_title(conversation_id),
|
||||
user_id=user_id,
|
||||
selected_repository=self.selected_repo,
|
||||
selected_branch=None,
|
||||
git_provider=resolved_git_provider,
|
||||
)
|
||||
await store.save_metadata(conversation_metadata)
|
||||
|
||||
await start_conversation(
|
||||
user_id=user_id,
|
||||
git_provider_tokens=provider_tokens,
|
||||
custom_secrets=user_secrets.custom_secrets if user_secrets else None,
|
||||
initial_user_msg=user_msg,
|
||||
image_urls=None,
|
||||
replay_json=None,
|
||||
conversation_id=conversation_id,
|
||||
conversation_metadata=conversation_metadata,
|
||||
conversation_instructions=instructions,
|
||||
)
|
||||
|
||||
self.conversation_id = 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
|
||||
)
|
||||
@@ -16,21 +16,23 @@ 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,
|
||||
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 AND the user
|
||||
is a member of that org, returns the claiming org's ID. Otherwise returns
|
||||
None (caller should fall back to user.current_org_id / personal workspace).
|
||||
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
|
||||
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, else None
|
||||
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()
|
||||
|
||||
@@ -44,6 +46,14 @@ async def resolve_org_for_repo(
|
||||
)
|
||||
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)
|
||||
)
|
||||
|
||||
@@ -24,7 +24,6 @@ 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
|
||||
@@ -698,11 +697,7 @@ class SlackManager(Manager[SlackViewInterface]):
|
||||
return False
|
||||
|
||||
async def start_job(self, slack_view: SlackViewInterface) -> None:
|
||||
# Importing here prevents circular import
|
||||
from server.conversation_callback_processor.slack_callback_processor import (
|
||||
SlackCallbackProcessor,
|
||||
)
|
||||
|
||||
"""Start a Slack job using V1 app conversation system."""
|
||||
try:
|
||||
msg_info = None
|
||||
user_info = slack_view.slack_to_openhands_user
|
||||
@@ -719,37 +714,7 @@ class SlackManager(Manager[SlackViewInterface]):
|
||||
f'[Slack] Created conversation {conversation_id} for user {user_info.slack_display_name}'
|
||||
)
|
||||
|
||||
# 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}'
|
||||
)
|
||||
# V1 callback processors are registered by the view during conversation creation
|
||||
|
||||
msg_info = slack_view.get_response_msg()
|
||||
|
||||
|
||||
@@ -14,13 +14,10 @@ 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
|
||||
from server.config import get_config
|
||||
from slack_sdk import WebClient
|
||||
from storage.saas_conversation_store import SaasConversationStore
|
||||
from storage.slack_conversation import SlackConversation
|
||||
from storage.slack_conversation_store import SlackConversationStore
|
||||
from storage.slack_team_store import SlackTeamStore
|
||||
@@ -36,23 +33,13 @@ 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.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
|
||||
from openhands.sdk import TextContent
|
||||
from openhands.server.services.conversation_service import (
|
||||
setup_init_conversation_settings,
|
||||
start_conversation,
|
||||
)
|
||||
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 (
|
||||
ConversationMetadata,
|
||||
ConversationTrigger,
|
||||
)
|
||||
from openhands.utils.async_utils import GENERAL_TIMEOUT
|
||||
from openhands.utils.conversation_summary import get_default_conversation_title
|
||||
|
||||
# =================================================
|
||||
# SECTION: Slack view types
|
||||
@@ -205,7 +192,6 @@ 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
|
||||
@@ -223,68 +209,9 @@ class SlackNewConversationView(SlackViewInterface):
|
||||
keycloak_user_id=self.slack_to_openhands_user.keycloak_user_id,
|
||||
)
|
||||
|
||||
# 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
|
||||
)
|
||||
|
||||
user_id = self.slack_to_openhands_user.keycloak_user_id
|
||||
|
||||
# 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(),
|
||||
user_id,
|
||||
self.resolved_org_id,
|
||||
)
|
||||
|
||||
conversation_id = uuid4().hex
|
||||
conversation_metadata = ConversationMetadata(
|
||||
trigger=ConversationTrigger.SLACK,
|
||||
conversation_id=conversation_id,
|
||||
title=get_default_conversation_title(conversation_id),
|
||||
user_id=user_id,
|
||||
selected_repository=self.selected_repo,
|
||||
selected_branch=None,
|
||||
git_provider=self._resolved_git_provider,
|
||||
)
|
||||
await store.save_metadata(conversation_metadata)
|
||||
|
||||
await start_conversation(
|
||||
user_id=user_id,
|
||||
git_provider_tokens=provider_tokens,
|
||||
custom_secrets=user_secrets.custom_secrets if user_secrets else None,
|
||||
initial_user_msg=user_instructions,
|
||||
image_urls=None,
|
||||
replay_json=None,
|
||||
conversation_id=conversation_id,
|
||||
conversation_metadata=conversation_metadata,
|
||||
conversation_instructions=(
|
||||
conversation_instructions if conversation_instructions else None
|
||||
),
|
||||
)
|
||||
|
||||
self.conversation_id = conversation_id
|
||||
logger.info(f'[Slack]: Created V0 conversation: {self.conversation_id}')
|
||||
await self.save_slack_convo(v1_enabled=False)
|
||||
# V0 conversation path has been removed - all conversations use V1 app conversation service
|
||||
await self._create_v1_conversation(jinja)
|
||||
return self.conversation_id
|
||||
|
||||
async def _create_v1_conversation(self, jinja: Environment) -> None:
|
||||
"""Create conversation using the new V1 app conversation system."""
|
||||
@@ -378,53 +305,6 @@ 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
|
||||
@@ -519,7 +399,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 converation"""
|
||||
"""Send new user message to conversation."""
|
||||
user_info: SlackUser = self.slack_to_openhands_user
|
||||
|
||||
user_id = user_info.keycloak_user_id
|
||||
@@ -531,10 +411,8 @@ class SlackUpdateExistingConversationView(SlackNewConversationView):
|
||||
f'{user_info.slack_display_name} is not authorized to send messages to this conversation.'
|
||||
)
|
||||
|
||||
if self.slack_conversation.v1_enabled:
|
||||
await self.send_message_to_v1_conversation(jinja)
|
||||
else:
|
||||
await self.send_message_to_v0_conversation(jinja)
|
||||
# All conversations use V1 app conversation system
|
||||
await self.send_message_to_v1_conversation(jinja)
|
||||
|
||||
return self.conversation_id
|
||||
|
||||
|
||||
@@ -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
|
||||
customer = await stripe.Customer.create_async(
|
||||
email=org.contact_email,
|
||||
metadata={'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)
|
||||
|
||||
# Save the stripe customer in the local db
|
||||
async with a_session_maker() as session:
|
||||
@@ -108,11 +108,14 @@ async def migrate_customer(session, user_id: str, org: Org):
|
||||
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)},
|
||||
)
|
||||
# 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)
|
||||
|
||||
logger.info(
|
||||
'migrated_customer',
|
||||
|
||||
@@ -3,7 +3,6 @@ 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
|
||||
@@ -20,12 +19,6 @@ 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.
|
||||
@@ -363,43 +356,6 @@ 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.
|
||||
@@ -436,12 +392,13 @@ def infer_repo_from_message(user_msg: str) -> list[str]:
|
||||
r'(?=\s|$|}}|[\]\)\'",.:`])' # right boundary
|
||||
)
|
||||
|
||||
matches: list[str] = []
|
||||
# Use dict to preserve ordering
|
||||
matches: dict[str, bool] = {}
|
||||
|
||||
# Git URLs first (highest priority)
|
||||
for owner, repo in re.findall(git_url_pattern, normalized_msg):
|
||||
repo = re.sub(r'\.git$', '', repo)
|
||||
matches.append(f'{owner}/{repo}')
|
||||
matches[f'{owner}/{repo}'] = True
|
||||
|
||||
# Direct mentions
|
||||
for owner, repo in re.findall(direct_pattern, normalized_msg):
|
||||
@@ -457,9 +414,10 @@ def infer_repo_from_message(user_msg: str) -> list[str]:
|
||||
continue
|
||||
|
||||
if full_match not in matches:
|
||||
matches.append(full_match)
|
||||
matches[full_match] = True
|
||||
|
||||
return matches
|
||||
result = list(matches)
|
||||
return result
|
||||
|
||||
|
||||
def filter_potential_repos_by_user_msg(
|
||||
@@ -595,3 +553,18 @@ 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)}
|
||||
|
||||
@@ -6,6 +6,12 @@ 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
|
||||
@@ -70,6 +76,12 @@ 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.
|
||||
|
||||
@@ -6,7 +6,6 @@ Create Date: 2026-03-26
|
||||
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
@@ -24,18 +23,18 @@ def upgrade() -> None:
|
||||
|
||||
# Migrate existing org-level MCP configs to all members in each org.
|
||||
# This preserves existing configurations while transitioning to user-specific settings.
|
||||
conn = op.get_bind()
|
||||
orgs_with_config = conn.execute(
|
||||
sa.text('SELECT id, mcp_config FROM org WHERE mcp_config IS NOT NULL')
|
||||
).fetchall()
|
||||
|
||||
for org_id, mcp_config in orgs_with_config:
|
||||
conn.execute(
|
||||
sa.text(
|
||||
'UPDATE org_member SET mcp_config = :config WHERE org_id = :org_id'
|
||||
),
|
||||
{'config': json.dumps(mcp_config), 'org_id': str(org_id)},
|
||||
# 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:
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""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')
|
||||
@@ -0,0 +1,563 @@
|
||||
"""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')
|
||||
178
enterprise/poetry.lock
generated
178
enterprise/poetry.lock
generated
@@ -1,4 +1,4 @@
|
||||
# This file is automatically @generated by Poetry 2.3.3 and should not be changed by hand.
|
||||
# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "agent-client-protocol"
|
||||
@@ -1708,61 +1708,61 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "46.0.6"
|
||||
version = "46.0.7"
|
||||
description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
|
||||
optional = false
|
||||
python-versions = "!=3.9.0,!=3.9.1,>=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "cryptography-46.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8"},
|
||||
{file = "cryptography-46.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30"},
|
||||
{file = "cryptography-46.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9a693028b9cbe51b5a1136232ee8f2bc242e4e19d456ded3fa7c86e43c713b4a"},
|
||||
{file = "cryptography-46.0.6-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:67177e8a9f421aa2d3a170c3e56eca4e0128883cf52a071a7cbf53297f18b175"},
|
||||
{file = "cryptography-46.0.6-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d9528b535a6c4f8ff37847144b8986a9a143585f0540fbcb1a98115b543aa463"},
|
||||
{file = "cryptography-46.0.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:22259338084d6ae497a19bae5d4c66b7ca1387d3264d1c2c0e72d9e9b6a77b97"},
|
||||
{file = "cryptography-46.0.6-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:760997a4b950ff00d418398ad73fbc91aa2894b5c1db7ccb45b4f68b42a63b3c"},
|
||||
{file = "cryptography-46.0.6-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3dfa6567f2e9e4c5dceb8ccb5a708158a2a871052fa75c8b78cb0977063f1507"},
|
||||
{file = "cryptography-46.0.6-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:cdcd3edcbc5d55757e5f5f3d330dd00007ae463a7e7aa5bf132d1f22a4b62b19"},
|
||||
{file = "cryptography-46.0.6-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:d4e4aadb7fc1f88687f47ca20bb7227981b03afaae69287029da08096853b738"},
|
||||
{file = "cryptography-46.0.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2b417edbe8877cda9022dde3a008e2deb50be9c407eef034aeeb3a8b11d9db3c"},
|
||||
{file = "cryptography-46.0.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:380343e0653b1c9d7e1f55b52aaa2dbb2fdf2730088d48c43ca1c7c0abb7cc2f"},
|
||||
{file = "cryptography-46.0.6-cp311-abi3-win32.whl", hash = "sha256:bcb87663e1f7b075e48c3be3ecb5f0b46c8fc50b50a97cf264e7f60242dca3f2"},
|
||||
{file = "cryptography-46.0.6-cp311-abi3-win_amd64.whl", hash = "sha256:6739d56300662c468fddb0e5e291f9b4d084bead381667b9e654c7dd81705124"},
|
||||
{file = "cryptography-46.0.6-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:2ef9e69886cbb137c2aef9772c2e7138dc581fad4fcbcf13cc181eb5a3ab6275"},
|
||||
{file = "cryptography-46.0.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7f417f034f91dcec1cb6c5c35b07cdbb2ef262557f701b4ecd803ee8cefed4f4"},
|
||||
{file = "cryptography-46.0.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d24c13369e856b94892a89ddf70b332e0b70ad4a5c43cf3e9cb71d6d7ffa1f7b"},
|
||||
{file = "cryptography-46.0.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:aad75154a7ac9039936d50cf431719a2f8d4ed3d3c277ac03f3339ded1a5e707"},
|
||||
{file = "cryptography-46.0.6-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3c21d92ed15e9cfc6eb64c1f5a0326db22ca9c2566ca46d845119b45b4400361"},
|
||||
{file = "cryptography-46.0.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:4668298aef7cddeaf5c6ecc244c2302a2b8e40f384255505c22875eebb47888b"},
|
||||
{file = "cryptography-46.0.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8ce35b77aaf02f3b59c90b2c8a05c73bac12cea5b4e8f3fbece1f5fddea5f0ca"},
|
||||
{file = "cryptography-46.0.6-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:c89eb37fae9216985d8734c1afd172ba4927f5a05cfd9bf0e4863c6d5465b013"},
|
||||
{file = "cryptography-46.0.6-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:ed418c37d095aeddf5336898a132fba01091f0ac5844e3e8018506f014b6d2c4"},
|
||||
{file = "cryptography-46.0.6-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:69cf0056d6947edc6e6760e5f17afe4bea06b56a9ac8a06de9d2bd6b532d4f3a"},
|
||||
{file = "cryptography-46.0.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e7304c4f4e9490e11efe56af6713983460ee0780f16c63f219984dab3af9d2d"},
|
||||
{file = "cryptography-46.0.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b928a3ca837c77a10e81a814a693f2295200adb3352395fad024559b7be7a736"},
|
||||
{file = "cryptography-46.0.6-cp314-cp314t-win32.whl", hash = "sha256:97c8115b27e19e592a05c45d0dd89c57f81f841cc9880e353e0d3bf25b2139ed"},
|
||||
{file = "cryptography-46.0.6-cp314-cp314t-win_amd64.whl", hash = "sha256:c797e2517cb7880f8297e2c0f43bb910e91381339336f75d2c1c2cbf811b70b4"},
|
||||
{file = "cryptography-46.0.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:12cae594e9473bca1a7aceb90536060643128bb274fcea0fc459ab90f7d1ae7a"},
|
||||
{file = "cryptography-46.0.6-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:639301950939d844a9e1c4464d7e07f902fe9a7f6b215bb0d4f28584729935d8"},
|
||||
{file = "cryptography-46.0.6-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ed3775295fb91f70b4027aeba878d79b3e55c0b3e97eaa4de71f8f23a9f2eb77"},
|
||||
{file = "cryptography-46.0.6-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:8927ccfbe967c7df312ade694f987e7e9e22b2425976ddbf28271d7e58845290"},
|
||||
{file = "cryptography-46.0.6-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b12c6b1e1651e42ab5de8b1e00dc3b6354fdfd778e7fa60541ddacc27cd21410"},
|
||||
{file = "cryptography-46.0.6-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:063b67749f338ca9c5a0b7fe438a52c25f9526b851e24e6c9310e7195aad3b4d"},
|
||||
{file = "cryptography-46.0.6-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:02fad249cb0e090b574e30b276a3da6a149e04ee2f049725b1f69e7b8351ec70"},
|
||||
{file = "cryptography-46.0.6-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e6142674f2a9291463e5e150090b95a8519b2fb6e6aaec8917dd8d094ce750d"},
|
||||
{file = "cryptography-46.0.6-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:456b3215172aeefb9284550b162801d62f5f264a081049a3e94307fe20792cfa"},
|
||||
{file = "cryptography-46.0.6-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:341359d6c9e68834e204ceaf25936dffeafea3829ab80e9503860dcc4f4dac58"},
|
||||
{file = "cryptography-46.0.6-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9a9c42a2723999a710445bc0d974e345c32adfd8d2fac6d8a251fa829ad31cfb"},
|
||||
{file = "cryptography-46.0.6-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6617f67b1606dfd9fe4dbfa354a9508d4a6d37afe30306fe6c101b7ce3274b72"},
|
||||
{file = "cryptography-46.0.6-cp38-abi3-win32.whl", hash = "sha256:7f6690b6c55e9c5332c0b59b9c8a3fb232ebf059094c17f9019a51e9827df91c"},
|
||||
{file = "cryptography-46.0.6-cp38-abi3-win_amd64.whl", hash = "sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f"},
|
||||
{file = "cryptography-46.0.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:2ea0f37e9a9cf0df2952893ad145fd9627d326a59daec9b0802480fa3bcd2ead"},
|
||||
{file = "cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a3e84d5ec9ba01f8fd03802b2147ba77f0c8f2617b2aff254cedd551844209c8"},
|
||||
{file = "cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:12f0fa16cc247b13c43d56d7b35287ff1569b5b1f4c5e87e92cc4fcc00cd10c0"},
|
||||
{file = "cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:50575a76e2951fe7dbd1f56d181f8c5ceeeb075e9ff88e7ad997d2f42af06e7b"},
|
||||
{file = "cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:90e5f0a7b3be5f40c3a0a0eafb32c681d8d2c181fc2a1bdabe9b3f611d9f6b1a"},
|
||||
{file = "cryptography-46.0.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6728c49e3b2c180ef26f8e9f0a883a2c585638db64cf265b49c9ba10652d430e"},
|
||||
{file = "cryptography-46.0.6.tar.gz", hash = "sha256:27550628a518c5c6c903d84f637fbecf287f6cb9ced3804838a1295dc1fd0759"},
|
||||
{file = "cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4"},
|
||||
{file = "cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325"},
|
||||
{file = "cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308"},
|
||||
{file = "cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77"},
|
||||
{file = "cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1"},
|
||||
{file = "cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef"},
|
||||
{file = "cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de"},
|
||||
{file = "cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83"},
|
||||
{file = "cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb"},
|
||||
{file = "cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b"},
|
||||
{file = "cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85"},
|
||||
{file = "cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e"},
|
||||
{file = "cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457"},
|
||||
{file = "cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b"},
|
||||
{file = "cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842"},
|
||||
{file = "cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c"},
|
||||
{file = "cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902"},
|
||||
{file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d"},
|
||||
{file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022"},
|
||||
{file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce"},
|
||||
{file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f"},
|
||||
{file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99"},
|
||||
{file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1"},
|
||||
{file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2"},
|
||||
{file = "cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e"},
|
||||
{file = "cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee"},
|
||||
{file = "cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298"},
|
||||
{file = "cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb"},
|
||||
{file = "cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4"},
|
||||
{file = "cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7"},
|
||||
{file = "cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832"},
|
||||
{file = "cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163"},
|
||||
{file = "cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2"},
|
||||
{file = "cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067"},
|
||||
{file = "cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0"},
|
||||
{file = "cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba"},
|
||||
{file = "cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006"},
|
||||
{file = "cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0"},
|
||||
{file = "cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85"},
|
||||
{file = "cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e"},
|
||||
{file = "cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246"},
|
||||
{file = "cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3"},
|
||||
{file = "cryptography-46.0.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc9ab8856ae6cf7c9358430e49b368f3108f050031442eaeb6b9d87e4dcf4e4f"},
|
||||
{file = "cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15"},
|
||||
{file = "cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455"},
|
||||
{file = "cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65"},
|
||||
{file = "cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968"},
|
||||
{file = "cryptography-46.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4"},
|
||||
{file = "cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -1775,7 +1775,7 @@ nox = ["nox[uv] (>=2024.4.15)"]
|
||||
pep8test = ["check-sdist", "click (>=8.0.1)", "mypy (>=1.14)", "ruff (>=0.11.11)"]
|
||||
sdist = ["build (>=1.0.0)"]
|
||||
ssh = ["bcrypt (>=3.1.5)"]
|
||||
test = ["certifi (>=2024)", "cryptography-vectors (==46.0.6)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"]
|
||||
test = ["certifi (>=2024)", "cryptography-vectors (==46.0.7)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"]
|
||||
test-randomorder = ["pytest-randomly"]
|
||||
|
||||
[[package]]
|
||||
@@ -3585,7 +3585,7 @@ files = [
|
||||
[package.dependencies]
|
||||
googleapis-common-protos = ">=1.5.5"
|
||||
grpcio = ">=1.67.1"
|
||||
protobuf = ">=5.26.1,<6.0.dev0"
|
||||
protobuf = ">=5.26.1,<6.0dev"
|
||||
|
||||
[[package]]
|
||||
name = "gspread"
|
||||
@@ -3906,7 +3906,7 @@ pfzy = ">=0.3.1,<0.4.0"
|
||||
prompt-toolkit = ">=3.0.1,<4.0.0"
|
||||
|
||||
[package.extras]
|
||||
docs = ["Sphinx (>=4.1.2,<5.0.0)", "furo (>=2021.8.17b43,<2022.0.0)", "myst-parser (>=0.15.1,<0.16.0)", "sphinx-autobuild (>=2021.3.14,<2022.0.0)", "sphinx-copybutton (>=0.4.0,<0.5.0)"]
|
||||
docs = ["Sphinx (>=4.1.2,<5.0.0)", "furo (>=2021.8.17-beta.43,<2022.0.0)", "myst-parser (>=0.15.1,<0.16.0)", "sphinx-autobuild (>=2021.3.14,<2022.0.0)", "sphinx-copybutton (>=0.4.0,<0.5.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "installer"
|
||||
@@ -4348,7 +4348,7 @@ fqdn = {version = "*", optional = true, markers = "extra == \"format-nongpl\""}
|
||||
idna = {version = "*", optional = true, markers = "extra == \"format-nongpl\""}
|
||||
isoduration = {version = "*", optional = true, markers = "extra == \"format-nongpl\""}
|
||||
jsonpointer = {version = ">1.13", optional = true, markers = "extra == \"format-nongpl\""}
|
||||
jsonschema-specifications = ">=2023.3.6"
|
||||
jsonschema-specifications = ">=2023.03.6"
|
||||
referencing = ">=0.28.4"
|
||||
rfc3339-validator = {version = "*", optional = true, markers = "extra == \"format-nongpl\""}
|
||||
rfc3986-validator = {version = ">0.1.0", optional = true, markers = "extra == \"format-nongpl\""}
|
||||
@@ -4756,7 +4756,7 @@ files = [
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
certifi = ">=14.5.14"
|
||||
certifi = ">=14.05.14"
|
||||
durationpy = ">=0.7"
|
||||
python-dateutil = ">=2.5.3"
|
||||
pyyaml = ">=5.4.1"
|
||||
@@ -4890,25 +4890,24 @@ valkey = ["valkey (>=6)"]
|
||||
|
||||
[[package]]
|
||||
name = "litellm"
|
||||
version = "1.80.10"
|
||||
version = "1.83.0"
|
||||
description = "Library to easily interface with LLM API providers"
|
||||
optional = false
|
||||
python-versions = "<4.0,>=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "litellm-1.80.10-py3-none-any.whl", hash = "sha256:9b3e561efaba0eb1291cb1555d3dcb7283cf7f3cb65aadbcdb42e2a8765898c8"},
|
||||
{file = "litellm-1.80.10.tar.gz", hash = "sha256:4a4aff7558945c2f7e5c6523e67c1b5525a46b10b0e1ad6b8f847cb13b16779e"},
|
||||
{file = "litellm-1.83.0-py3-none-any.whl", hash = "sha256:88c536d339248f3987571493015784671ba3f193a328e1ea6780dbebaa2094a8"},
|
||||
{file = "litellm-1.83.0.tar.gz", hash = "sha256:860bebc76c4bb27b4cf90b4a77acd66dba25aced37e3db98750de8a1766bfb7a"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
aiohttp = ">=3.10"
|
||||
click = "*"
|
||||
fastuuid = ">=0.13.0"
|
||||
grpcio = {version = ">=1.62.3,<1.68.0", markers = "python_version < \"3.14\""}
|
||||
httpx = ">=0.23.0"
|
||||
importlib-metadata = ">=6.8.0"
|
||||
jinja2 = ">=3.1.2,<4.0.0"
|
||||
jsonschema = ">=4.22.0,<5.0.0"
|
||||
jsonschema = ">=4.23.0,<5.0.0"
|
||||
openai = ">=2.8.0"
|
||||
pydantic = ">=2.5.0,<3.0.0"
|
||||
python-dotenv = ">=0.2.0"
|
||||
@@ -4917,9 +4916,11 @@ tokenizers = "*"
|
||||
|
||||
[package.extras]
|
||||
caching = ["diskcache (>=5.6.1,<6.0.0)"]
|
||||
extra-proxy = ["azure-identity (>=1.15.0,<2.0.0) ; python_version >= \"3.9\"", "azure-keyvault-secrets (>=4.8.0,<5.0.0)", "google-cloud-iam (>=2.19.1,<3.0.0)", "google-cloud-kms (>=2.21.3,<3.0.0)", "prisma (==0.11.0)", "redisvl (>=0.4.1,<0.5.0) ; python_version >= \"3.9\" and python_version < \"3.14\"", "resend (>=0.8.0)"]
|
||||
extra-proxy = ["a2a-sdk (>=0.3.22,<0.4.0) ; python_version >= \"3.10\"", "azure-identity (>=1.15.0,<2.0.0) ; python_version >= \"3.9\"", "azure-keyvault-secrets (>=4.8.0,<5.0.0)", "google-cloud-iam (>=2.19.1,<3.0.0)", "google-cloud-kms (>=2.21.3,<3.0.0)", "prisma (>=0.11.0,<0.12.0)", "redisvl (>=0.4.1,<0.5.0) ; python_version >= \"3.9\" and python_version < \"3.14\"", "resend (>=0.8.0)"]
|
||||
google = ["google-cloud-aiplatform (>=1.38.0)"]
|
||||
grpc = ["grpcio (>=1.62.3,<1.68.dev0 || >1.71.0,!=1.71.1,!=1.72.0,!=1.72.1,!=1.73.0) ; python_version < \"3.14\"", "grpcio (>=1.75.0) ; python_version >= \"3.14\""]
|
||||
mlflow = ["mlflow (>3.1.4) ; python_version >= \"3.10\""]
|
||||
proxy = ["PyJWT (>=2.10.1,<3.0.0) ; python_version >= \"3.9\"", "apscheduler (>=3.10.4,<4.0.0)", "azure-identity (>=1.15.0,<2.0.0) ; python_version >= \"3.9\"", "azure-storage-blob (>=12.25.1,<13.0.0)", "backoff", "boto3 (==1.36.0)", "cryptography", "fastapi (>=0.120.1)", "fastapi-sso (>=0.16.0,<0.17.0)", "gunicorn (>=23.0.0,<24.0.0)", "litellm-enterprise (==0.1.25)", "litellm-proxy-extras (==0.4.14)", "mcp (>=1.21.2,<2.0.0) ; python_version >= \"3.10\"", "orjson (>=3.9.7,<4.0.0)", "polars (>=1.31.0,<2.0.0) ; python_version >= \"3.10\"", "pynacl (>=1.5.0,<2.0.0)", "python-multipart (>=0.0.18,<0.0.19)", "pyyaml (>=6.0.1,<7.0.0)", "rich (==13.7.1)", "rq", "soundfile (>=0.12.1,<0.13.0)", "uvicorn (>=0.31.1,<0.32.0)", "uvloop (>=0.21.0,<0.22.0) ; sys_platform != \"win32\"", "websockets (>=15.0.1,<16.0.0)"]
|
||||
proxy = ["PyJWT (>=2.12.0,<3.0.0) ; python_version >= \"3.9\"", "apscheduler (>=3.10.4,<4.0.0)", "azure-identity (>=1.15.0,<2.0.0) ; python_version >= \"3.9\"", "azure-storage-blob (>=12.25.1,<13.0.0)", "backoff", "boto3 (>=1.40.76,<2.0.0)", "cryptography", "fastapi (>=0.120.1)", "fastapi-sso (>=0.16.0,<0.17.0)", "gunicorn (>=23.0.0,<24.0.0)", "litellm-enterprise (==0.1.35)", "litellm-proxy-extras (>=0.4.62,<0.5.0)", "mcp (>=1.25.0,<2.0.0) ; python_version >= \"3.10\"", "orjson (>=3.9.7,<4.0.0)", "polars (>=1.31.0,<2.0.0) ; python_version >= \"3.10\"", "pynacl (>=1.5.0,<2.0.0)", "pyroscope-io (>=0.8,<0.9) ; sys_platform != \"win32\"", "python-multipart (>=0.0.20)", "pyyaml (>=6.0.1,<7.0.0)", "rich (>=13.7.1,<14.0.0)", "rq", "soundfile (>=0.12.1,<0.13.0)", "uvicorn (>=0.32.1,<1.0.0)", "uvloop (>=0.21.0,<0.22.0) ; sys_platform != \"win32\"", "websockets (>=15.0.1,<16.0.0)"]
|
||||
semantic-router = ["semantic-router (>=0.1.12) ; python_version >= \"3.9\" and python_version < \"3.14\""]
|
||||
utils = ["numpydoc"]
|
||||
|
||||
@@ -6453,14 +6454,14 @@ llama = ["llama-index (>=0.12.29,<0.13.0)", "llama-index-core (>=0.12.29,<0.13.0
|
||||
|
||||
[[package]]
|
||||
name = "openhands-agent-server"
|
||||
version = "1.16.1"
|
||||
version = "1.17.0"
|
||||
description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "openhands_agent_server-1.16.1-py3-none-any.whl", hash = "sha256:015983b300510c9c329c8eace49fbd4117d31d0895a125e419c31a9964be4155"},
|
||||
{file = "openhands_agent_server-1.16.1.tar.gz", hash = "sha256:489151d35250a424dede8646396bef7b7095adb25e5c973ca8bc6dcbd19cdf07"},
|
||||
{file = "openhands_agent_server-1.17.0-py3-none-any.whl", hash = "sha256:44336cad001c31caeb516481a5a7aea6dd9b5ab4798461f147b5231668d8fb74"},
|
||||
{file = "openhands_agent_server-1.17.0.tar.gz", hash = "sha256:3a88449a3b9ded653dcd2a8c518810c75602873cf9f7d4e8f9b90fd8fd225652"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -6522,9 +6523,9 @@ memory-profiler = ">=0.61"
|
||||
numpy = "*"
|
||||
openai = "2.8"
|
||||
openhands-aci = "0.3.3"
|
||||
openhands-agent-server = "1.16.1"
|
||||
openhands-sdk = "1.16.1"
|
||||
openhands-tools = "1.16.1"
|
||||
openhands-agent-server = "1.17"
|
||||
openhands-sdk = "1.17"
|
||||
openhands-tools = "1.17"
|
||||
opentelemetry-api = ">=1.33.1"
|
||||
opentelemetry-exporter-otlp-proto-grpc = ">=1.33.1"
|
||||
orjson = ">=3.11.6"
|
||||
@@ -6546,7 +6547,7 @@ python-docx = "*"
|
||||
python-dotenv = "*"
|
||||
python-frontmatter = ">=1.1"
|
||||
python-json-logger = ">=3.2.1"
|
||||
python-multipart = ">=0.0.22"
|
||||
python-multipart = ">=0.0.26"
|
||||
python-pptx = "*"
|
||||
python-socketio = "5.14"
|
||||
pythonnet = {version = "*", markers = "sys_platform == \"win32\""}
|
||||
@@ -6570,23 +6571,20 @@ uvicorn = "*"
|
||||
whatthepatch = ">=1.0.6"
|
||||
zope-interface = "7.2"
|
||||
|
||||
[package.extras]
|
||||
third-party-runtimes = ["daytona (==0.24.2)", "e2b-code-interpreter (>=2)", "modal (>=0.66.26,<1.2)", "runloop-api-client (==0.50)"]
|
||||
|
||||
[package.source]
|
||||
type = "directory"
|
||||
url = ".."
|
||||
|
||||
[[package]]
|
||||
name = "openhands-sdk"
|
||||
version = "1.16.1"
|
||||
version = "1.17.0"
|
||||
description = "OpenHands SDK - Core functionality for building AI agents"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "openhands_sdk-1.16.1-py3-none-any.whl", hash = "sha256:0b487929e03e8c87ac6d99f37ff5314df3db6af70a06b516b0858327f9744f2b"},
|
||||
{file = "openhands_sdk-1.16.1.tar.gz", hash = "sha256:12f203c3766800bdf5d9dd4dd0a7988b88e13ff4954b0c208903778111e29567"},
|
||||
{file = "openhands_sdk-1.17.0-py3-none-any.whl", hash = "sha256:3b771e72209453871c3036a562cf33e9ad9642a54bd48edb44f89915ac54709d"},
|
||||
{file = "openhands_sdk-1.17.0.tar.gz", hash = "sha256:3c69df6590f023a514137272d413658848e0d5bc9aecf941b946c8662862779a"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -6596,7 +6594,7 @@ fakeredis = {version = ">=2.32.1", extras = ["lua"]}
|
||||
fastmcp = ">=3.0.0"
|
||||
filelock = ">=3.20.1"
|
||||
httpx = {version = ">=0.27.0", extras = ["socks"]}
|
||||
litellm = "1.80.10"
|
||||
litellm = ">=1.82.6,<1.82.7 || >1.82.7,<1.82.8 || >1.82.8"
|
||||
lmnr = ">=0.7.24"
|
||||
pydantic = ">=2.12.5"
|
||||
python-frontmatter = ">=1.1.0"
|
||||
@@ -6609,14 +6607,14 @@ boto3 = ["boto3 (>=1.35.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "openhands-tools"
|
||||
version = "1.16.1"
|
||||
version = "1.17.0"
|
||||
description = "OpenHands Tools - Runtime tools for AI agents"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "openhands_tools-1.16.1-py3-none-any.whl", hash = "sha256:f7fd1eb205571d02ee480ad71e96cac0c34c57c0938c4074fe135a579a7538d7"},
|
||||
{file = "openhands_tools-1.16.1.tar.gz", hash = "sha256:64488f2d7705ff90f4bfb7dfd1a2f1fbb4f379059d96e0073677c168d97135e7"},
|
||||
{file = "openhands_tools-1.17.0-py3-none-any.whl", hash = "sha256:76cd30fcc153627444f18638bcd926c9190989f80a3492381e84a181c021d815"},
|
||||
{file = "openhands_tools-1.17.0.tar.gz", hash = "sha256:4a9d6c1aec00d366d0feb1ac2e9ee9988ad9806a0ef89f7dbe4655644e639d4a"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -7140,7 +7138,7 @@ files = [
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
docs = ["Sphinx (>=4.1.2,<5.0.0)", "furo (>=2021.8.17b43,<2022.0.0)", "myst-parser (>=0.15.1,<0.16.0)", "sphinx-autobuild (>=2021.3.14,<2022.0.0)", "sphinx-copybutton (>=0.4.0,<0.5.0)"]
|
||||
docs = ["Sphinx (>=4.1.2,<5.0.0)", "furo (>=2021.8.17-beta.43,<2022.0.0)", "myst-parser (>=0.15.1,<0.16.0)", "sphinx-autobuild (>=2021.3.14,<2022.0.0)", "sphinx-copybutton (>=0.4.0,<0.5.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "pg8000"
|
||||
@@ -11889,14 +11887,14 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.2"
|
||||
version = "9.0.3"
|
||||
description = "pytest: simple powerful testing with Python"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["test"]
|
||||
files = [
|
||||
{file = "pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b"},
|
||||
{file = "pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11"},
|
||||
{file = "pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9"},
|
||||
{file = "pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -12130,14 +12128,14 @@ requests-toolbelt = ">=0.6.0"
|
||||
|
||||
[[package]]
|
||||
name = "python-multipart"
|
||||
version = "0.0.22"
|
||||
version = "0.0.26"
|
||||
description = "A streaming multipart parser for Python"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155"},
|
||||
{file = "python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58"},
|
||||
{file = "python_multipart-0.0.26-py3-none-any.whl", hash = "sha256:c0b169f8c4484c13b0dcf2ef0ec3a4adb255c4b7d18d8e420477d2b1dd03f185"},
|
||||
{file = "python_multipart-0.0.26.tar.gz", hash = "sha256:08fadc45918cd615e26846437f50c5d6d23304da32c341f289a617127b081f17"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -13111,10 +13109,10 @@ files = [
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
botocore = ">=1.37.4,<2.0a0"
|
||||
botocore = ">=1.37.4,<2.0a.0"
|
||||
|
||||
[package.extras]
|
||||
crt = ["botocore[crt] (>=1.37.4,<2.0a0)"]
|
||||
crt = ["botocore[crt] (>=1.37.4,<2.0a.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "scantree"
|
||||
@@ -15258,7 +15256,7 @@ files = [
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and python_version < \"3.14\"", "cffi (>=2.0.0b0) ; platform_python_implementation != \"PyPy\" and python_version >= \"3.14\""]
|
||||
cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and python_version < \"3.14\"", "cffi (>=2.0.0b) ; platform_python_implementation != \"PyPy\" and python_version >= \"3.14\""]
|
||||
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
|
||||
@@ -17,7 +17,6 @@ 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,
|
||||
)
|
||||
@@ -29,12 +28,10 @@ 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
|
||||
@@ -47,7 +44,6 @@ 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,
|
||||
@@ -86,7 +82,6 @@ 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
|
||||
@@ -111,8 +106,15 @@ 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
|
||||
@@ -138,8 +140,6 @@ 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,9 +148,6 @@ 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(
|
||||
|
||||
@@ -87,6 +87,9 @@ class Permission(str, Enum):
|
||||
# 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."""
|
||||
@@ -123,6 +126,8 @@ ROLE_PERMISSIONS: dict[RoleName, frozenset[Permission]] = {
|
||||
Permission.DELETE_ORGANIZATION,
|
||||
# Git organization claims
|
||||
Permission.MANAGE_ORG_CLAIMS,
|
||||
# Manage Automations
|
||||
Permission.MANAGE_AUTOMATIONS,
|
||||
]
|
||||
),
|
||||
RoleName.ADMIN: frozenset(
|
||||
@@ -146,6 +151,8 @@ ROLE_PERMISSIONS: dict[RoleName, frozenset[Permission]] = {
|
||||
Permission.EDIT_ORG_SETTINGS,
|
||||
# Git organization claims
|
||||
Permission.MANAGE_ORG_CLAIMS,
|
||||
# Manage Automations
|
||||
Permission.MANAGE_AUTOMATIONS,
|
||||
]
|
||||
),
|
||||
RoleName.MEMBER: frozenset(
|
||||
@@ -159,6 +166,8 @@ ROLE_PERMISSIONS: dict[RoleName, frozenset[Permission]] = {
|
||||
# Settings (View only)
|
||||
Permission.VIEW_ORG_SETTINGS,
|
||||
Permission.VIEW_LLM_SETTINGS,
|
||||
# Manage Automations
|
||||
Permission.MANAGE_AUTOMATIONS,
|
||||
]
|
||||
),
|
||||
}
|
||||
|
||||
@@ -56,6 +56,23 @@ 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',
|
||||
|
||||
@@ -1,808 +0,0 @@
|
||||
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]
|
||||
@@ -20,6 +20,7 @@ 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
|
||||
@@ -74,10 +75,6 @@ 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'
|
||||
)
|
||||
@@ -179,6 +176,7 @@ 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,
|
||||
}
|
||||
|
||||
@@ -15,6 +15,33 @@ 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'
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
# Conversation Callback Processor
|
||||
|
||||
This module provides a framework for processing conversation events and sending summaries or notifications to external platforms like Slack and GitLab.
|
||||
|
||||
## Overview
|
||||
|
||||
The conversation callback processor system consists of two main components:
|
||||
|
||||
1. **ConversationCallback**: A database model that stores information about callbacks to be executed when specific conversation events occur.
|
||||
2. **ConversationCallbackProcessor**: An abstract base class that defines the interface for processors that handle conversation events.
|
||||
|
||||
## How It Works
|
||||
|
||||
### ConversationCallback
|
||||
|
||||
The `ConversationCallback` class is a database model that stores:
|
||||
|
||||
- A reference to a conversation (`conversation_id`)
|
||||
- The current status of the callback (`ACTIVE`, `COMPLETED`, or `ERROR`)
|
||||
- The type of processor to use (`processor_type`)
|
||||
- Serialized processor configuration (`processor_json`)
|
||||
- Timestamps for creation and updates
|
||||
|
||||
This model provides methods to:
|
||||
- `get_processor()`: Dynamically instantiate the processor from the stored type and JSON data
|
||||
- `set_processor()`: Store a processor instance by serializing its type and data
|
||||
|
||||
### ConversationCallbackProcessor
|
||||
|
||||
The `ConversationCallbackProcessor` is an abstract base class that defines the interface for all callback processors. It:
|
||||
|
||||
- Is a Pydantic model that can be serialized to/from JSON
|
||||
- Requires implementing the `__call__` method to process conversation events
|
||||
- Receives the callback instance and an `AgentStateChangedObservation` when called
|
||||
|
||||
## Implemented Processors
|
||||
|
||||
### SlackCallbackProcessor
|
||||
|
||||
The `SlackCallbackProcessor` sends conversation summaries to Slack channels when specific agent state changes occur. It:
|
||||
|
||||
1. Monitors for agent state changes to `AWAITING_USER_INPUT` or `FINISHED`
|
||||
2. Sends a summary instruction to the conversation if needed
|
||||
3. Extracts a summary from the conversation
|
||||
4. Sends the summary to the appropriate Slack channel
|
||||
5. Marks the callback as completed
|
||||
|
||||
### GithubCallbackProcessor and GitlabCallbackProcessor
|
||||
|
||||
The `GithubCallbackProcessor` and `GitlabCallbackProcessor` send conversation summaries to GitHub / GitLab issues when specific agent state changes occur. They:
|
||||
|
||||
1. Monitors for agent state changes to `AWAITING_USER_INPUT` or `FINISHED`
|
||||
2. Sends a summary instruction to the conversation if needed
|
||||
3. Extracts a summary from the conversation
|
||||
4. Sends the summary to the appropriate Github or GitLab issue
|
||||
5. Marks the callback as completed
|
||||
@@ -1 +0,0 @@
|
||||
# This file makes the conversation_callback_processor directory a Python package
|
||||
@@ -1,135 +0,0 @@
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
|
||||
from integrations.github.github_manager import GithubManager
|
||||
from integrations.github.github_view import GithubViewType
|
||||
from integrations.utils import (
|
||||
extract_summary_from_conversation_manager,
|
||||
get_summary_instruction,
|
||||
)
|
||||
from server.auth.token_manager import TokenManager
|
||||
from storage.conversation_callback import (
|
||||
CallbackStatus,
|
||||
ConversationCallback,
|
||||
ConversationCallbackProcessor,
|
||||
)
|
||||
|
||||
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.observation.agent import AgentStateChangedObservation
|
||||
from openhands.events.serialization.event import event_to_dict
|
||||
from openhands.server.shared import conversation_manager
|
||||
|
||||
|
||||
class GithubCallbackProcessor(ConversationCallbackProcessor):
|
||||
"""
|
||||
Processor for sending conversation summaries to GitHub.
|
||||
|
||||
This processor is used to send summaries of conversations to GitHub issues/PRs
|
||||
when agent state changes occur.
|
||||
"""
|
||||
|
||||
github_view: GithubViewType
|
||||
send_summary_instruction: bool = True
|
||||
|
||||
async def _send_message_to_github(self, message: str) -> None:
|
||||
"""Send a message to GitHub.
|
||||
|
||||
Args:
|
||||
message: The message content to send to GitHub
|
||||
"""
|
||||
try:
|
||||
# Get the token manager
|
||||
token_manager = TokenManager()
|
||||
|
||||
# Create GitHub manager
|
||||
from integrations.github.data_collector import GitHubDataCollector
|
||||
|
||||
github_manager = GithubManager(token_manager, GitHubDataCollector())
|
||||
|
||||
# Send the message directly as a string
|
||||
await github_manager.send_message(message, self.github_view)
|
||||
|
||||
logger.info(
|
||||
f'[GitHub] Sent summary message to {self.github_view.full_repo_name}#{self.github_view.issue_number}'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(f'[GitHub] Failed to send summary message: {str(e)}')
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
callback: ConversationCallback,
|
||||
observation: AgentStateChangedObservation,
|
||||
) -> None:
|
||||
"""
|
||||
Process a conversation event by sending a summary to GitHub.
|
||||
|
||||
Args:
|
||||
callback: The conversation callback
|
||||
observation: The AgentStateChangedObservation that triggered the callback
|
||||
"""
|
||||
logger.info(f'[GitHub] Callback agent state was {observation.agent_state}')
|
||||
if observation.agent_state not in (
|
||||
AgentState.AWAITING_USER_INPUT,
|
||||
AgentState.FINISHED,
|
||||
):
|
||||
return
|
||||
|
||||
conversation_id = callback.conversation_id
|
||||
try:
|
||||
# If we need to send a summary instruction first
|
||||
if self.send_summary_instruction:
|
||||
logger.info(
|
||||
f'[GitHub] Sending summary instruction for conversation {conversation_id}'
|
||||
)
|
||||
|
||||
# Get the summary instruction
|
||||
summary_instruction = get_summary_instruction()
|
||||
summary_event = event_to_dict(
|
||||
MessageAction(content=summary_instruction)
|
||||
)
|
||||
|
||||
# Add the summary instruction to the event stream
|
||||
logger.info(
|
||||
f'[GitHub] Sending summary instruction to conversation {conversation_id} {summary_event}'
|
||||
)
|
||||
await conversation_manager.send_event_to_conversation(
|
||||
conversation_id, summary_event
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f'[GitHub] Sent summary instruction to conversation {conversation_id} {summary_event}'
|
||||
)
|
||||
|
||||
# Update the processor state - the outer session will commit this
|
||||
self.send_summary_instruction = False
|
||||
callback.set_processor(self)
|
||||
callback.updated_at = datetime.now()
|
||||
return
|
||||
|
||||
# Extract the summary from the event store
|
||||
logger.info(
|
||||
f'[GitHub] Extracting summary for conversation {conversation_id}'
|
||||
)
|
||||
summary = await extract_summary_from_conversation_manager(
|
||||
conversation_manager, conversation_id
|
||||
)
|
||||
|
||||
# Send the summary to GitHub
|
||||
asyncio.create_task(self._send_message_to_github(summary))
|
||||
|
||||
logger.info(f'[GitHub] Summary sent for conversation {conversation_id}')
|
||||
|
||||
# Mark callback as completed status - the outer session will commit this
|
||||
callback.status = CallbackStatus.COMPLETED
|
||||
callback.updated_at = datetime.now()
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
f'[GitHub] Error processing conversation callback: {str(e)}'
|
||||
)
|
||||
# Mark callback as error to prevent infinite re-invocation
|
||||
# The outer session will commit this
|
||||
callback.status = CallbackStatus.ERROR
|
||||
callback.updated_at = datetime.now()
|
||||
@@ -1,136 +0,0 @@
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
|
||||
from integrations.gitlab.gitlab_manager import GitlabManager
|
||||
from integrations.gitlab.gitlab_view import GitlabViewType
|
||||
from integrations.utils import (
|
||||
extract_summary_from_conversation_manager,
|
||||
get_summary_instruction,
|
||||
)
|
||||
from server.auth.token_manager import TokenManager
|
||||
from storage.conversation_callback import (
|
||||
CallbackStatus,
|
||||
ConversationCallback,
|
||||
ConversationCallbackProcessor,
|
||||
)
|
||||
from storage.database import a_session_maker
|
||||
|
||||
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.observation.agent import AgentStateChangedObservation
|
||||
from openhands.events.serialization.event import event_to_dict
|
||||
from openhands.server.shared import conversation_manager
|
||||
|
||||
token_manager = TokenManager()
|
||||
gitlab_manager = GitlabManager(token_manager)
|
||||
|
||||
|
||||
class GitlabCallbackProcessor(ConversationCallbackProcessor):
|
||||
"""Processor for sending conversation summaries to GitLab.
|
||||
|
||||
This processor is used to send summaries of conversations to GitLab
|
||||
when agent state changes occur.
|
||||
"""
|
||||
|
||||
gitlab_view: GitlabViewType
|
||||
send_summary_instruction: bool = True
|
||||
|
||||
async def _send_message_to_gitlab(self, message: str) -> None:
|
||||
"""Send a message to GitLab.
|
||||
|
||||
Args:
|
||||
message: The message content to send to GitLab
|
||||
"""
|
||||
try:
|
||||
# Get the token manager
|
||||
token_manager = TokenManager()
|
||||
gitlab_manager = GitlabManager(token_manager)
|
||||
|
||||
# Send the message directly as a string
|
||||
await gitlab_manager.send_message(message, self.gitlab_view)
|
||||
|
||||
logger.info(
|
||||
f'[GitLab] Sent summary message to {self.gitlab_view.full_repo_name}#{self.gitlab_view.issue_number}'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(f'[GitLab] Failed to send summary message: {str(e)}')
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
callback: ConversationCallback,
|
||||
observation: AgentStateChangedObservation,
|
||||
) -> None:
|
||||
"""
|
||||
Process a conversation event by sending a summary to GitLab.
|
||||
|
||||
Args:
|
||||
callback: The conversation callback
|
||||
observation: The AgentStateChangedObservation that triggered the callback
|
||||
"""
|
||||
logger.info(f'[GitLab] Callback agent state was {observation.agent_state}')
|
||||
if observation.agent_state not in (
|
||||
AgentState.AWAITING_USER_INPUT,
|
||||
AgentState.FINISHED,
|
||||
):
|
||||
return
|
||||
|
||||
conversation_id = callback.conversation_id
|
||||
try:
|
||||
# If we need to send a summary instruction first
|
||||
if self.send_summary_instruction:
|
||||
logger.info(
|
||||
f'[GitLab] Sending summary instruction for conversation {conversation_id}'
|
||||
)
|
||||
|
||||
# Get the summary instruction
|
||||
summary_instruction = get_summary_instruction()
|
||||
summary_event = event_to_dict(
|
||||
MessageAction(content=summary_instruction)
|
||||
)
|
||||
|
||||
# Add the summary instruction to the event stream
|
||||
logger.info(
|
||||
f'[GitLab] Sending summary instruction to conversation {conversation_id} {summary_event}'
|
||||
)
|
||||
await conversation_manager.send_event_to_conversation(
|
||||
conversation_id, summary_event
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f'[GitLab] Sent summary instruction to conversation {conversation_id} {summary_event}'
|
||||
)
|
||||
|
||||
# Update the processor state
|
||||
self.send_summary_instruction = False
|
||||
callback.set_processor(self)
|
||||
callback.updated_at = datetime.now()
|
||||
async with a_session_maker() as session:
|
||||
session.merge(callback)
|
||||
await session.commit()
|
||||
return
|
||||
|
||||
# Extract the summary from the event store
|
||||
logger.info(
|
||||
f'[GitLab] Extracting summary for conversation {conversation_id}'
|
||||
)
|
||||
summary = await extract_summary_from_conversation_manager(
|
||||
conversation_manager, conversation_id
|
||||
)
|
||||
|
||||
# Send the summary to GitLab
|
||||
asyncio.create_task(self._send_message_to_gitlab(summary))
|
||||
|
||||
logger.info(f'[GitLab] Summary sent for conversation {conversation_id}')
|
||||
|
||||
# Mark callback as completed status
|
||||
callback.status = CallbackStatus.COMPLETED
|
||||
callback.updated_at = datetime.now()
|
||||
async with a_session_maker() as session:
|
||||
session.merge(callback)
|
||||
await session.commit()
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
f'[GitLab] Error processing conversation callback: {str(e)}'
|
||||
)
|
||||
@@ -1,154 +0,0 @@
|
||||
import asyncio
|
||||
|
||||
from integrations.jira.jira_manager import JiraManager
|
||||
from integrations.utils import (
|
||||
extract_summary_from_conversation_manager,
|
||||
get_last_user_msg_from_conversation_manager,
|
||||
get_summary_instruction,
|
||||
markdown_to_jira_markup,
|
||||
)
|
||||
from server.auth.token_manager import TokenManager
|
||||
from storage.conversation_callback import (
|
||||
ConversationCallback,
|
||||
ConversationCallbackProcessor,
|
||||
)
|
||||
|
||||
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.observation.agent import AgentStateChangedObservation
|
||||
from openhands.events.serialization.event import event_to_dict
|
||||
from openhands.server.shared import conversation_manager
|
||||
|
||||
token_manager = TokenManager()
|
||||
jira_manager = JiraManager(token_manager)
|
||||
integration_store = jira_manager.integration_store
|
||||
|
||||
|
||||
class JiraCallbackProcessor(ConversationCallbackProcessor):
|
||||
"""
|
||||
Processor for sending conversation summaries to Jira.
|
||||
|
||||
This processor is used to send summaries of conversations to Jira issues
|
||||
when agent state changes occur.
|
||||
"""
|
||||
|
||||
issue_key: str
|
||||
workspace_name: str
|
||||
|
||||
async def _send_comment_to_jira(self, message: str) -> None:
|
||||
"""Send a comment to Jira issue.
|
||||
|
||||
Args:
|
||||
message: The message content to send to Jira
|
||||
"""
|
||||
try:
|
||||
# Get workspace details to retrieve API credentials
|
||||
workspace = await jira_manager.integration_store.get_workspace_by_name(
|
||||
self.workspace_name
|
||||
)
|
||||
if not workspace:
|
||||
logger.error(f'[Jira] Workspace {self.workspace_name} not found')
|
||||
return
|
||||
|
||||
if workspace.status != 'active':
|
||||
logger.error(f'[Jira] Workspace {workspace.id} is not active')
|
||||
return
|
||||
|
||||
# Decrypt API key
|
||||
api_key = jira_manager.token_manager.decrypt_text(workspace.svc_acc_api_key)
|
||||
|
||||
# Send comment directly as a string
|
||||
await jira_manager.send_message(
|
||||
message,
|
||||
issue_key=self.issue_key,
|
||||
jira_cloud_id=workspace.jira_cloud_id,
|
||||
svc_acc_email=workspace.svc_acc_email,
|
||||
svc_acc_api_key=api_key,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f'[Jira] Sent summary comment to issue {self.issue_key} '
|
||||
f'(workspace {self.workspace_name})'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'[Jira] Failed to send summary comment: {str(e)}')
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
callback: ConversationCallback,
|
||||
observation: AgentStateChangedObservation,
|
||||
) -> None:
|
||||
"""
|
||||
Process a conversation event by sending a summary to Jira.
|
||||
|
||||
Args:
|
||||
callback: The conversation callback
|
||||
observation: The AgentStateChangedObservation that triggered the callback
|
||||
"""
|
||||
logger.info(f'[Jira] Callback agent state was {observation.agent_state}')
|
||||
if observation.agent_state not in (
|
||||
AgentState.AWAITING_USER_INPUT,
|
||||
AgentState.FINISHED,
|
||||
):
|
||||
return
|
||||
|
||||
conversation_id = callback.conversation_id
|
||||
try:
|
||||
logger.info(
|
||||
f'[Jira] Sending summary instruction for conversation {conversation_id}'
|
||||
)
|
||||
|
||||
# Get the summary instruction
|
||||
summary_instruction = get_summary_instruction()
|
||||
summary_event = event_to_dict(MessageAction(content=summary_instruction))
|
||||
|
||||
# Prevent infinite loops for summary callback that always sends instructions when agent stops
|
||||
# We should not request summary if the last message is the summary request
|
||||
last_user_msg = await get_last_user_msg_from_conversation_manager(
|
||||
conversation_manager, conversation_id
|
||||
)
|
||||
logger.info(
|
||||
'last_user_msg',
|
||||
extra={
|
||||
'last_user_msg': [m.content for m in last_user_msg],
|
||||
'summary_instruction': summary_instruction,
|
||||
},
|
||||
)
|
||||
if (
|
||||
len(last_user_msg) > 0
|
||||
and last_user_msg[0].content == summary_instruction
|
||||
):
|
||||
# Extract the summary from the event store
|
||||
logger.info(
|
||||
f'[Jira] Extracting summary for conversation {conversation_id}'
|
||||
)
|
||||
summary_markdown = await extract_summary_from_conversation_manager(
|
||||
conversation_manager, conversation_id
|
||||
)
|
||||
|
||||
summary = markdown_to_jira_markup(summary_markdown)
|
||||
|
||||
asyncio.create_task(self._send_comment_to_jira(summary))
|
||||
|
||||
logger.info(f'[Jira] Summary sent for conversation {conversation_id}')
|
||||
return
|
||||
|
||||
# Add the summary instruction to the event stream
|
||||
logger.info(
|
||||
f'[Jira] Sending summary instruction to conversation {conversation_id} {summary_event}'
|
||||
)
|
||||
await conversation_manager.send_event_to_conversation(
|
||||
conversation_id, summary_event
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f'[Jira] Sent summary instruction to conversation {conversation_id} {summary_event}'
|
||||
)
|
||||
|
||||
except Exception:
|
||||
logger.error(
|
||||
'[Jira] Error processing conversation callback',
|
||||
exc_info=True,
|
||||
stack_info=True,
|
||||
)
|
||||
@@ -1,158 +0,0 @@
|
||||
import asyncio
|
||||
|
||||
from integrations.jira_dc.jira_dc_manager import JiraDcManager
|
||||
from integrations.utils import (
|
||||
extract_summary_from_conversation_manager,
|
||||
get_last_user_msg_from_conversation_manager,
|
||||
get_summary_instruction,
|
||||
markdown_to_jira_markup,
|
||||
)
|
||||
from server.auth.token_manager import TokenManager
|
||||
from storage.conversation_callback import (
|
||||
ConversationCallback,
|
||||
ConversationCallbackProcessor,
|
||||
)
|
||||
|
||||
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.observation.agent import AgentStateChangedObservation
|
||||
from openhands.events.serialization.event import event_to_dict
|
||||
from openhands.server.shared import conversation_manager
|
||||
|
||||
token_manager = TokenManager()
|
||||
jira_dc_manager = JiraDcManager(token_manager)
|
||||
|
||||
|
||||
class JiraDcCallbackProcessor(ConversationCallbackProcessor):
|
||||
"""
|
||||
Processor for sending conversation summaries to Jira DC.
|
||||
|
||||
This processor is used to send summaries of conversations to Jira DC issues
|
||||
when agent state changes occur.
|
||||
"""
|
||||
|
||||
issue_key: str
|
||||
workspace_name: str
|
||||
base_api_url: str
|
||||
|
||||
async def _send_comment_to_jira_dc(self, message: str) -> None:
|
||||
"""Send a comment to Jira DC issue.
|
||||
|
||||
Args:
|
||||
message: The message content to send to Jira DC
|
||||
"""
|
||||
try:
|
||||
# Get workspace details to retrieve API credentials
|
||||
workspace = await jira_dc_manager.integration_store.get_workspace_by_name(
|
||||
self.workspace_name
|
||||
)
|
||||
if not workspace:
|
||||
logger.error(f'[Jira DC] Workspace {self.workspace_name} not found')
|
||||
return
|
||||
|
||||
if workspace.status != 'active':
|
||||
logger.error(f'[Jira DC] Workspace {workspace.id} is not active')
|
||||
return
|
||||
|
||||
# Decrypt API key
|
||||
api_key = jira_dc_manager.token_manager.decrypt_text(
|
||||
workspace.svc_acc_api_key
|
||||
)
|
||||
|
||||
# Send comment directly as a string
|
||||
await jira_dc_manager.send_message(
|
||||
message,
|
||||
issue_key=self.issue_key,
|
||||
base_api_url=self.base_api_url,
|
||||
svc_acc_api_key=api_key,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f'[Jira DC] Sent summary comment to issue {self.issue_key} '
|
||||
f'(workspace {self.workspace_name})'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'[Jira DC] Failed to send summary comment: {str(e)}')
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
callback: ConversationCallback,
|
||||
observation: AgentStateChangedObservation,
|
||||
) -> None:
|
||||
"""
|
||||
Process a conversation event by sending a summary to Jira DC.
|
||||
|
||||
Args:
|
||||
callback: The conversation callback
|
||||
observation: The AgentStateChangedObservation that triggered the callback
|
||||
"""
|
||||
logger.info(f'[Jira DC] Callback agent state was {observation.agent_state}')
|
||||
if observation.agent_state not in (
|
||||
AgentState.AWAITING_USER_INPUT,
|
||||
AgentState.FINISHED,
|
||||
):
|
||||
return
|
||||
|
||||
conversation_id = callback.conversation_id
|
||||
try:
|
||||
logger.info(
|
||||
f'[Jira DC] Sending summary instruction for conversation {conversation_id}'
|
||||
)
|
||||
|
||||
# Get the summary instruction
|
||||
summary_instruction = get_summary_instruction()
|
||||
summary_event = event_to_dict(MessageAction(content=summary_instruction))
|
||||
|
||||
# Prevent infinite loops for summary callback that always sends instructions when agent stops
|
||||
# We should not request summary if the last message is the summary request
|
||||
last_user_msg = await get_last_user_msg_from_conversation_manager(
|
||||
conversation_manager, conversation_id
|
||||
)
|
||||
logger.info(
|
||||
'last_user_msg',
|
||||
extra={
|
||||
'last_user_msg': [m.content for m in last_user_msg],
|
||||
'summary_instruction': summary_instruction,
|
||||
},
|
||||
)
|
||||
if (
|
||||
len(last_user_msg) > 0
|
||||
and last_user_msg[0].content == summary_instruction
|
||||
):
|
||||
# Extract the summary from the event store
|
||||
logger.info(
|
||||
f'[Jira DC] Extracting summary for conversation {conversation_id}'
|
||||
)
|
||||
|
||||
summary_markdown = await extract_summary_from_conversation_manager(
|
||||
conversation_manager, conversation_id
|
||||
)
|
||||
|
||||
summary = markdown_to_jira_markup(summary_markdown)
|
||||
|
||||
asyncio.create_task(self._send_comment_to_jira_dc(summary))
|
||||
|
||||
logger.info(
|
||||
f'[Jira DC] Summary sent for conversation {conversation_id}'
|
||||
)
|
||||
return
|
||||
|
||||
# Add the summary instruction to the event stream
|
||||
logger.info(
|
||||
f'[Jira DC] Sending summary instruction to conversation {conversation_id} {summary_event}'
|
||||
)
|
||||
await conversation_manager.send_event_to_conversation(
|
||||
conversation_id, summary_event
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f'[Jira DC] Sent summary instruction to conversation {conversation_id} {summary_event}'
|
||||
)
|
||||
|
||||
except Exception:
|
||||
logger.error(
|
||||
'[Jira DC] Error processing conversation callback',
|
||||
exc_info=True,
|
||||
stack_info=True,
|
||||
)
|
||||
@@ -1,152 +0,0 @@
|
||||
import asyncio
|
||||
|
||||
from integrations.linear.linear_manager import LinearManager
|
||||
from integrations.utils import (
|
||||
extract_summary_from_conversation_manager,
|
||||
get_last_user_msg_from_conversation_manager,
|
||||
get_summary_instruction,
|
||||
)
|
||||
from server.auth.token_manager import TokenManager
|
||||
from storage.conversation_callback import (
|
||||
ConversationCallback,
|
||||
ConversationCallbackProcessor,
|
||||
)
|
||||
|
||||
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.observation.agent import AgentStateChangedObservation
|
||||
from openhands.events.serialization.event import event_to_dict
|
||||
from openhands.server.shared import conversation_manager
|
||||
|
||||
token_manager = TokenManager()
|
||||
linear_manager = LinearManager(token_manager)
|
||||
|
||||
|
||||
class LinearCallbackProcessor(ConversationCallbackProcessor):
|
||||
"""
|
||||
Processor for sending conversation summaries to Linear.
|
||||
|
||||
This processor is used to send summaries of conversations to Linear issues
|
||||
when agent state changes occur.
|
||||
"""
|
||||
|
||||
issue_id: str
|
||||
issue_key: str
|
||||
workspace_name: str
|
||||
|
||||
async def _send_comment_to_linear(self, message: str) -> None:
|
||||
"""Send a comment to Linear issue.
|
||||
|
||||
Args:
|
||||
message: The message content to send to Linear
|
||||
"""
|
||||
try:
|
||||
# Get workspace details to retrieve API key
|
||||
workspace = await linear_manager.integration_store.get_workspace_by_name(
|
||||
self.workspace_name
|
||||
)
|
||||
if not workspace:
|
||||
logger.error(f'[Linear] Workspace {self.workspace_name} not found')
|
||||
return
|
||||
|
||||
if workspace.status != 'active':
|
||||
logger.error(f'[Linear] Workspace {workspace.id} is not active')
|
||||
return
|
||||
|
||||
# Decrypt API key
|
||||
api_key = linear_manager.token_manager.decrypt_text(
|
||||
workspace.svc_acc_api_key
|
||||
)
|
||||
|
||||
# Send comment directly as a string
|
||||
await linear_manager.send_message(
|
||||
message,
|
||||
self.issue_id,
|
||||
api_key,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f'[Linear] Sent summary comment to issue {self.issue_key} '
|
||||
f'(workspace {self.workspace_name})'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'[Linear] Failed to send summary comment: {str(e)}')
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
callback: ConversationCallback,
|
||||
observation: AgentStateChangedObservation,
|
||||
) -> None:
|
||||
"""
|
||||
Process a conversation event by sending a summary to Linear.
|
||||
|
||||
Args:
|
||||
callback: The conversation callback
|
||||
observation: The AgentStateChangedObservation that triggered the callback
|
||||
"""
|
||||
logger.info(f'[Linear] Callback agent state was {observation.agent_state}')
|
||||
if observation.agent_state not in (
|
||||
AgentState.AWAITING_USER_INPUT,
|
||||
AgentState.FINISHED,
|
||||
):
|
||||
return
|
||||
|
||||
conversation_id = callback.conversation_id
|
||||
try:
|
||||
logger.info(
|
||||
f'[Linear] Sending summary instruction for conversation {conversation_id}'
|
||||
)
|
||||
|
||||
# Get the summary instruction
|
||||
summary_instruction = get_summary_instruction()
|
||||
summary_event = event_to_dict(MessageAction(content=summary_instruction))
|
||||
|
||||
# Prevent infinite loops for summary callback that always sends instructions when agent stops
|
||||
# We should not request summary if the last message is the summary request
|
||||
last_user_msg = await get_last_user_msg_from_conversation_manager(
|
||||
conversation_manager, conversation_id
|
||||
)
|
||||
logger.info(
|
||||
'last_user_msg',
|
||||
extra={
|
||||
'last_user_msg': [m.content for m in last_user_msg],
|
||||
'summary_instruction': summary_instruction,
|
||||
},
|
||||
)
|
||||
if (
|
||||
len(last_user_msg) > 0
|
||||
and last_user_msg[0].content == summary_instruction
|
||||
):
|
||||
# Extract the summary from the event store
|
||||
logger.info(
|
||||
f'[Linear] Extracting summary for conversation {conversation_id}'
|
||||
)
|
||||
summary = await extract_summary_from_conversation_manager(
|
||||
conversation_manager, conversation_id
|
||||
)
|
||||
|
||||
# Send the summary to Linear
|
||||
asyncio.create_task(self._send_comment_to_linear(summary))
|
||||
|
||||
logger.info(f'[Linear] Summary sent for conversation {conversation_id}')
|
||||
return
|
||||
|
||||
# Add the summary instruction to the event stream
|
||||
logger.info(
|
||||
f'[Linear] Sending summary instruction to conversation {conversation_id} {summary_event}'
|
||||
)
|
||||
await conversation_manager.send_event_to_conversation(
|
||||
conversation_id, summary_event
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f'[Linear] Sent summary instruction to conversation {conversation_id} {summary_event}'
|
||||
)
|
||||
|
||||
except Exception:
|
||||
logger.error(
|
||||
'[Linear] Error processing conversation callback',
|
||||
exc_info=True,
|
||||
stack_info=True,
|
||||
)
|
||||
@@ -1,179 +0,0 @@
|
||||
import asyncio
|
||||
|
||||
from integrations.models import Message, SourceType
|
||||
from integrations.slack.slack_manager import SlackManager
|
||||
from integrations.slack.slack_view import SlackFactory
|
||||
from integrations.utils import (
|
||||
extract_summary_from_conversation_manager,
|
||||
get_last_user_msg_from_conversation_manager,
|
||||
get_summary_instruction,
|
||||
)
|
||||
from server.auth.token_manager import TokenManager
|
||||
from storage.conversation_callback import (
|
||||
ConversationCallback,
|
||||
ConversationCallbackProcessor,
|
||||
)
|
||||
|
||||
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.observation.agent import AgentStateChangedObservation
|
||||
from openhands.events.serialization.event import event_to_dict
|
||||
from openhands.server.shared import conversation_manager
|
||||
|
||||
token_manager = TokenManager()
|
||||
slack_manager = SlackManager(token_manager)
|
||||
|
||||
|
||||
class SlackCallbackProcessor(ConversationCallbackProcessor):
|
||||
"""Processor for sending conversation summaries to Slack.
|
||||
|
||||
This processor is used to send summaries of conversations to Slack channels
|
||||
when agent state changes occur.
|
||||
"""
|
||||
|
||||
slack_user_id: str
|
||||
channel_id: str
|
||||
message_ts: str
|
||||
thread_ts: str | None
|
||||
team_id: str
|
||||
last_user_msg_id: int | None = None
|
||||
|
||||
async def _send_message_to_slack(self, message: str) -> None:
|
||||
"""Send a message to Slack.
|
||||
|
||||
Args:
|
||||
message: The message content to send to Slack
|
||||
"""
|
||||
try:
|
||||
# Create a message object for Slack view creation (incoming message format)
|
||||
message_obj = Message(
|
||||
source=SourceType.SLACK,
|
||||
message={
|
||||
'slack_user_id': self.slack_user_id,
|
||||
'channel_id': self.channel_id,
|
||||
'message_ts': self.message_ts,
|
||||
'thread_ts': self.thread_ts,
|
||||
'team_id': self.team_id,
|
||||
'user_msg': message,
|
||||
},
|
||||
)
|
||||
|
||||
slack_user, saas_user_auth = await slack_manager.authenticate_user(
|
||||
self.slack_user_id
|
||||
)
|
||||
slack_view = await SlackFactory.create_slack_view_from_payload(
|
||||
message_obj, slack_user, saas_user_auth
|
||||
)
|
||||
# Send the message directly as a string
|
||||
await slack_manager.send_message(message, slack_view)
|
||||
|
||||
logger.info(
|
||||
f'[Slack] Sent summary message to channel {self.channel_id} '
|
||||
f'for user {self.slack_user_id}'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'[Slack] Failed to send summary message: {str(e)}')
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
callback: ConversationCallback,
|
||||
observation: AgentStateChangedObservation,
|
||||
) -> None:
|
||||
"""
|
||||
Process a conversation event by sending a summary to Slack.
|
||||
|
||||
Args:
|
||||
conversation_id: The ID of the conversation to process
|
||||
observation: The AgentStateChangedObservation that triggered the callback
|
||||
callback: The conversation callback
|
||||
"""
|
||||
logger.info(f'[Slack] Callback agent state was {observation.agent_state}')
|
||||
if observation.agent_state not in (
|
||||
AgentState.AWAITING_USER_INPUT,
|
||||
AgentState.FINISHED,
|
||||
):
|
||||
return
|
||||
|
||||
conversation_id = callback.conversation_id
|
||||
try:
|
||||
logger.info(f'[Slack] Processing conversation {conversation_id}')
|
||||
|
||||
# Get the summary instruction
|
||||
summary_instruction = get_summary_instruction()
|
||||
summary_event = event_to_dict(MessageAction(content=summary_instruction))
|
||||
|
||||
# Prevent infinite loops for summary callback that always sends instructions when agent stops
|
||||
# We should not request summary if the last message is the summary request
|
||||
last_user_msg = await get_last_user_msg_from_conversation_manager(
|
||||
conversation_manager, conversation_id
|
||||
)
|
||||
|
||||
# Check if we have any messages
|
||||
if len(last_user_msg) == 0:
|
||||
logger.info(
|
||||
f'[Slack] No messages found for conversation {conversation_id}'
|
||||
)
|
||||
return
|
||||
|
||||
# Get the ID of the last user message
|
||||
current_msg_id = last_user_msg[0].id if last_user_msg else None
|
||||
|
||||
logger.info(
|
||||
'last_user_msg',
|
||||
extra={
|
||||
'last_user_msg': [m.content for m in last_user_msg],
|
||||
'summary_instruction': summary_instruction,
|
||||
'current_msg_id': current_msg_id,
|
||||
'last_user_msg_id': self.last_user_msg_id,
|
||||
},
|
||||
)
|
||||
|
||||
# Check if the message ID has changed
|
||||
if current_msg_id == self.last_user_msg_id:
|
||||
logger.info(
|
||||
f'[Slack] Skipping processing as message ID has not changed: {current_msg_id}'
|
||||
)
|
||||
return
|
||||
|
||||
# Update the last user message ID
|
||||
self.last_user_msg_id = current_msg_id
|
||||
|
||||
# Update the processor in the callback and save to database
|
||||
callback.set_processor(self)
|
||||
|
||||
logger.info(f'[Slack] Updated last_user_msg_id to {self.last_user_msg_id}')
|
||||
|
||||
if last_user_msg[0].content == summary_instruction:
|
||||
# Extract the summary from the event store
|
||||
logger.info(
|
||||
f'[Slack] Extracting summary for conversation {conversation_id}'
|
||||
)
|
||||
summary = await extract_summary_from_conversation_manager(
|
||||
conversation_manager, conversation_id
|
||||
)
|
||||
|
||||
# Send the summary to Slack
|
||||
asyncio.create_task(self._send_message_to_slack(summary))
|
||||
|
||||
logger.info(f'[Slack] Summary sent for conversation {conversation_id}')
|
||||
return
|
||||
|
||||
# Add the summary instruction to the event stream
|
||||
logger.info(
|
||||
f'[Slack] Sending summary instruction to conversation {conversation_id} {summary_event}'
|
||||
)
|
||||
await conversation_manager.send_event_to_conversation(
|
||||
conversation_id, summary_event
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f'[Slack] Sent summary instruction to conversation {conversation_id} {summary_event}'
|
||||
)
|
||||
|
||||
except Exception:
|
||||
logger.error(
|
||||
'[Slack] Error processing conversation callback',
|
||||
exc_info=True,
|
||||
stack_info=True,
|
||||
)
|
||||
@@ -4,9 +4,9 @@ if TYPE_CHECKING:
|
||||
from openhands.core.config.openhands_config import OpenHandsConfig
|
||||
|
||||
from openhands.core.config.mcp_config import (
|
||||
MCPSHTTPServerConfig,
|
||||
MCPStdioServerConfig,
|
||||
OpenHandsMCPConfig,
|
||||
RemoteMCPServer,
|
||||
StdioMCPServer,
|
||||
)
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
@@ -24,16 +24,8 @@ class SaaSOpenHandsMCPConfig(OpenHandsMCPConfig):
|
||||
@staticmethod
|
||||
async def create_default_mcp_server_config(
|
||||
host: str, config: 'OpenHandsConfig', user_id: str | None = None
|
||||
) -> tuple[MCPSHTTPServerConfig | None, list[MCPStdioServerConfig]]:
|
||||
"""
|
||||
Create a default MCP server configuration.
|
||||
|
||||
Args:
|
||||
host: Host string
|
||||
config: OpenHandsConfig
|
||||
Returns:
|
||||
A tuple containing the default SSE server configuration and a list of MCP stdio server configurations
|
||||
"""
|
||||
) -> dict[str, RemoteMCPServer | StdioMCPServer]:
|
||||
"""Return a dict of default MCP server entries for SaaS mode."""
|
||||
from storage.api_key_store import ApiKeyStore
|
||||
|
||||
api_key_store = ApiKeyStore.get_instance()
|
||||
@@ -47,9 +39,14 @@ class SaaSOpenHandsMCPConfig(OpenHandsMCPConfig):
|
||||
|
||||
if not api_key:
|
||||
logger.error(f'Could not provision MCP API Key for user: {user_id}')
|
||||
return None, []
|
||||
return {}
|
||||
|
||||
return MCPSHTTPServerConfig(
|
||||
url=f'https://{host}/mcp/mcp', api_key=api_key
|
||||
), []
|
||||
return None, []
|
||||
return {
|
||||
'openhands': RemoteMCPServer(
|
||||
url=f'https://{host}/mcp/mcp',
|
||||
transport='http',
|
||||
auth=api_key,
|
||||
timeout=60,
|
||||
)
|
||||
}
|
||||
return {}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
"""SAAS-specific user models that extend OSS UserInfo with organization fields."""
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from openhands.app_server.user.user_models import UserInfo
|
||||
from openhands.integrations.service_types import ProviderType
|
||||
|
||||
|
||||
class SaasUserInfo(UserInfo):
|
||||
@@ -14,3 +17,10 @@ class SaasUserInfo(UserInfo):
|
||||
org_name: str | None = None
|
||||
role: str | None = None
|
||||
permissions: list[str] | None = None
|
||||
|
||||
|
||||
class GitOrganizationsResponse(BaseModel):
|
||||
"""Response model for the Git organizations the user belongs to on their active provider."""
|
||||
|
||||
provider: ProviderType
|
||||
organizations: list[str]
|
||||
|
||||
@@ -27,8 +27,10 @@ from server.auth.user.user_authorizer import (
|
||||
depends_user_authorizer,
|
||||
)
|
||||
from server.config import sign_token
|
||||
from server.constants import IS_FEATURE_ENV, IS_LOCAL_ENV
|
||||
from server.routes.event_webhook import _get_session_api_key, _get_user_id
|
||||
from server.constants import (
|
||||
DEPLOYMENT_MODE,
|
||||
IS_FEATURE_ENV,
|
||||
)
|
||||
from server.services.org_invitation_service import (
|
||||
EmailMismatchError,
|
||||
InvitationExpiredError,
|
||||
@@ -36,6 +38,7 @@ from server.services.org_invitation_service import (
|
||||
OrgInvitationService,
|
||||
UserAlreadyMemberError,
|
||||
)
|
||||
from server.utils.conversation_utils import get_session_api_key, get_user_id
|
||||
from server.utils.rate_limit_utils import check_rate_limit_by_user_id
|
||||
from server.utils.url_utils import get_cookie_domain, get_cookie_samesite, get_web_url
|
||||
from sqlalchemy import select
|
||||
@@ -462,8 +465,20 @@ async def keycloak_callback(
|
||||
tos_redirect_url = f'{tos_redirect_url}&invitation_success=true'
|
||||
response = RedirectResponse(tos_redirect_url, status_code=302)
|
||||
else:
|
||||
# User has accepted TOS - check if they need onboarding
|
||||
# Only redirect to onboarding if user has a valid offline token,
|
||||
# otherwise they need to complete the Keycloak offline token flow first
|
||||
if valid_offline_token and await _should_redirect_to_onboarding(user_id, user):
|
||||
redirect_url = f'{web_url}/onboarding'
|
||||
logger.info(
|
||||
'Redirecting returning user to onboarding',
|
||||
extra={'user_id': user_id, 'deployment_mode': DEPLOYMENT_MODE},
|
||||
)
|
||||
if invitation_token:
|
||||
redirect_url = f'{redirect_url}&invitation_success=true'
|
||||
if '?' in redirect_url:
|
||||
redirect_url = f'{redirect_url}&invitation_success=true'
|
||||
else:
|
||||
redirect_url = f'{redirect_url}?invitation_success=true'
|
||||
response = RedirectResponse(redirect_url, status_code=302)
|
||||
|
||||
set_response_cookie(
|
||||
@@ -471,7 +486,7 @@ async def keycloak_callback(
|
||||
response=response,
|
||||
keycloak_access_token=keycloak_access_token,
|
||||
keycloak_refresh_token=keycloak_refresh_token,
|
||||
secure=True if redirect_url.startswith('https') else False,
|
||||
secure=True if web_url.startswith('https') else False,
|
||||
accepted_tos=has_accepted_tos,
|
||||
)
|
||||
|
||||
@@ -512,8 +527,23 @@ async def keycloak_offline_callback(code: str, state: str, request: Request):
|
||||
user_id=user_info.sub, offline_token=keycloak_refresh_token
|
||||
)
|
||||
|
||||
user = await UserStore.get_user_by_id(user_info.sub)
|
||||
has_accepted_tos = user is not None and user.accepted_tos is not None
|
||||
|
||||
redirect_url, _, _ = _extract_oauth_state(state)
|
||||
return RedirectResponse(redirect_url if redirect_url else web_url, status_code=302)
|
||||
default_url = redirect_url if redirect_url else web_url
|
||||
final_url = await _get_post_auth_redirect(user_info.sub, default_url, web_url, user)
|
||||
|
||||
response = RedirectResponse(final_url, status_code=302)
|
||||
set_response_cookie(
|
||||
request=request,
|
||||
response=response,
|
||||
keycloak_access_token=keycloak_access_token,
|
||||
keycloak_refresh_token=keycloak_refresh_token,
|
||||
secure=True if web_url.startswith('https') else False,
|
||||
accepted_tos=has_accepted_tos,
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@oauth_router.get('/github/callback')
|
||||
@@ -549,6 +579,69 @@ async def authenticate(request: Request):
|
||||
return response
|
||||
|
||||
|
||||
async def _should_redirect_to_onboarding(user_id: str, user: User) -> bool:
|
||||
"""Check if user should be redirected to onboarding after TOS acceptance.
|
||||
Backend always redirects applicable users to /onboarding.
|
||||
Returns True if:
|
||||
- User has onboarding_completed explicitly set to False (new users)
|
||||
- Either:
|
||||
- Deployment mode is 'cloud' (all users)
|
||||
- Deployment mode is 'self_hosted' AND user is the super admin
|
||||
(first owner in their current org to accept TOS)
|
||||
|
||||
Returns False if:
|
||||
- User has onboarding_completed=True (already completed)
|
||||
- User has onboarding_completed=None (existing users before this feature)
|
||||
"""
|
||||
# Already completed onboarding
|
||||
if user.onboarding_completed is True:
|
||||
return False
|
||||
|
||||
# Existing user before this feature (NULL in database)
|
||||
if user.onboarding_completed is None:
|
||||
return False
|
||||
|
||||
# Cloud SaaS: all users go to onboarding
|
||||
if DEPLOYMENT_MODE == 'cloud':
|
||||
return True
|
||||
|
||||
# Self-hosted SaaS: only the super admin (first owner to accept TOS in the org)
|
||||
if DEPLOYMENT_MODE == 'self_hosted':
|
||||
first_owner = await UserStore.get_first_owner_in_org(user.current_org_id)
|
||||
if first_owner and str(first_owner.id) == user_id:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
async def _get_post_auth_redirect(
|
||||
user_id: str, default_url: str, web_url: str, user: User | None = None
|
||||
) -> str:
|
||||
"""Determine where to redirect user after authentication completes.
|
||||
|
||||
Called after offline token is stored to determine final redirect destination.
|
||||
Checks for pending user flows (e.g., onboarding) before falling back to default.
|
||||
|
||||
Args:
|
||||
user_id: The user's ID.
|
||||
default_url: The default URL to redirect to if no special flow is needed.
|
||||
web_url: The base web URL for constructing absolute paths.
|
||||
user: Optional user object to avoid refetching.
|
||||
|
||||
Returns:
|
||||
The URL to redirect the user to.
|
||||
"""
|
||||
if not user:
|
||||
user = await UserStore.get_user_by_id(user_id)
|
||||
if user and await _should_redirect_to_onboarding(user_id, user):
|
||||
logger.info(
|
||||
'Redirecting user to onboarding',
|
||||
extra={'user_id': user_id, 'deployment_mode': DEPLOYMENT_MODE},
|
||||
)
|
||||
return f'{web_url}/onboarding'
|
||||
return default_url
|
||||
|
||||
|
||||
@api_router.post('/accept_tos')
|
||||
async def accept_tos(request: Request):
|
||||
user_auth = cast(SaasUserAuth, await get_user_auth(request))
|
||||
@@ -589,6 +682,12 @@ async def accept_tos(request: Request):
|
||||
|
||||
logger.info(f'User {user_id} accepted TOS')
|
||||
|
||||
# Determine final redirect - but don't override if it's the offline token flow
|
||||
# (the offline callback will handle post-auth redirect after storing the token)
|
||||
is_offline_flow = 'offline' in redirect_url
|
||||
if not is_offline_flow:
|
||||
redirect_url = await _get_post_auth_redirect(user_id, redirect_url, web_url)
|
||||
|
||||
response = JSONResponse(
|
||||
status_code=status.HTTP_200_OK, content={'redirect_url': redirect_url}
|
||||
)
|
||||
@@ -598,12 +697,42 @@ async def accept_tos(request: Request):
|
||||
response=response,
|
||||
keycloak_access_token=access_token.get_secret_value(),
|
||||
keycloak_refresh_token=refresh_token.get_secret_value(),
|
||||
secure=not IS_LOCAL_ENV,
|
||||
secure=True if web_url.startswith('https') else False,
|
||||
accepted_tos=True,
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@api_router.post('/complete_onboarding')
|
||||
async def complete_onboarding(request: Request):
|
||||
"""Mark onboarding as completed for the current user."""
|
||||
user_auth = cast(SaasUserAuth, await get_user_auth(request))
|
||||
user_id = await user_auth.get_user_id()
|
||||
|
||||
if not user_id:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
content={'error': 'User is not authenticated'},
|
||||
)
|
||||
|
||||
user = await UserStore.mark_onboarding_completed(user_id)
|
||||
if not user:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
content={'error': 'User not found'},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'User completed onboarding',
|
||||
extra={'user_id': user_id},
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_200_OK,
|
||||
content={'message': 'Onboarding completed'},
|
||||
)
|
||||
|
||||
|
||||
@api_router.post('/logout')
|
||||
async def logout(request: Request):
|
||||
# Always create the response object first to ensure we can return it even if errors occur
|
||||
@@ -641,8 +770,8 @@ async def refresh_tokens(
|
||||
x_session_api_key: Annotated[str | None, Header(alias='X-Session-API-Key')],
|
||||
) -> TokenResponse:
|
||||
"""Return the latest token for a given provider."""
|
||||
user_id = _get_user_id(sid)
|
||||
session_api_key = await _get_session_api_key(user_id, sid)
|
||||
user_id = get_user_id(sid)
|
||||
session_api_key = await get_session_api_key(sid)
|
||||
if session_api_key != x_session_api_key:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail='Forbidden')
|
||||
|
||||
|
||||
@@ -1,247 +0,0 @@
|
||||
import base64
|
||||
import json
|
||||
from enum import Enum
|
||||
from typing import Annotated, Tuple
|
||||
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
BackgroundTasks,
|
||||
Header,
|
||||
HTTPException,
|
||||
Request,
|
||||
Response,
|
||||
status,
|
||||
)
|
||||
from pydantic import BaseModel
|
||||
from server.logger import logger
|
||||
from server.utils.conversation_callback_utils import (
|
||||
process_event,
|
||||
update_agent_state,
|
||||
update_conversation_metadata,
|
||||
update_conversation_stats,
|
||||
)
|
||||
from storage.database import session_maker
|
||||
from storage.stored_conversation_metadata_saas import StoredConversationMetadataSaas
|
||||
|
||||
from openhands.server.shared import conversation_manager
|
||||
|
||||
event_webhook_router = APIRouter(prefix='/event-webhook')
|
||||
|
||||
|
||||
class BatchMethod(Enum):
|
||||
POST = 'POST'
|
||||
DELETE = 'DELETE'
|
||||
|
||||
|
||||
class BatchOperation(BaseModel):
|
||||
method: BatchMethod
|
||||
path: str
|
||||
content: str | None = None
|
||||
encoding: str | None = None
|
||||
|
||||
def get_content(self) -> bytes:
|
||||
if self.content is None:
|
||||
raise ValueError('empty_content_in_batch')
|
||||
if self.encoding == 'base64':
|
||||
return base64.b64decode(self.content.encode('ascii'))
|
||||
return self.content.encode('utf-8')
|
||||
|
||||
def get_content_json(self) -> dict:
|
||||
return json.loads(self.get_content())
|
||||
|
||||
|
||||
async def _process_batch_operations_background(
|
||||
batch_ops: list[BatchOperation],
|
||||
x_session_api_key: str | None,
|
||||
):
|
||||
"""Background task to process batched webhook requests with multiple file operations"""
|
||||
prev_conversation_id = None
|
||||
user_id = None
|
||||
|
||||
for batch_op in batch_ops:
|
||||
try:
|
||||
if batch_op.method != BatchMethod.POST:
|
||||
# Log unhandled methods for future implementation
|
||||
logger.info(
|
||||
'invalid_operation_in_batch_webhook',
|
||||
extra={
|
||||
'method': str(batch_op.method),
|
||||
'path': batch_op.path,
|
||||
},
|
||||
)
|
||||
continue
|
||||
|
||||
# Updates to certain paths in the nested runtime are ignored
|
||||
if batch_op.path in {'settings.json', 'secrets.json'}:
|
||||
continue
|
||||
|
||||
conversation_id, subpath = _parse_conversation_id_and_subpath(batch_op.path)
|
||||
|
||||
# If the conversation id changes, then we must recheck the session_api_key
|
||||
if conversation_id != prev_conversation_id:
|
||||
user_id = _get_user_id(conversation_id)
|
||||
session_api_key = await _get_session_api_key(user_id, conversation_id)
|
||||
prev_conversation_id = conversation_id
|
||||
if session_api_key != x_session_api_key:
|
||||
logger.error(
|
||||
'authentication_failed_in_batch_webhook_background',
|
||||
extra={
|
||||
'conversation_id': conversation_id,
|
||||
'user_id': user_id,
|
||||
'path': batch_op.path,
|
||||
},
|
||||
)
|
||||
continue # Skip this operation but continue with others
|
||||
|
||||
if user_id is None:
|
||||
logger.error(
|
||||
'user_id_not_set_in_batch_webhook',
|
||||
extra={
|
||||
'conversation_id': conversation_id,
|
||||
'path': batch_op.path,
|
||||
},
|
||||
)
|
||||
continue
|
||||
|
||||
if subpath == 'agent_state.pkl':
|
||||
update_agent_state(user_id, conversation_id, batch_op.get_content())
|
||||
continue
|
||||
|
||||
if subpath == 'conversation_stats.pkl':
|
||||
update_conversation_stats(
|
||||
user_id, conversation_id, batch_op.get_content()
|
||||
)
|
||||
continue
|
||||
|
||||
if subpath == 'metadata.json':
|
||||
update_conversation_metadata(
|
||||
conversation_id, batch_op.get_content_json()
|
||||
)
|
||||
continue
|
||||
|
||||
if subpath.startswith('events/'):
|
||||
await process_event(
|
||||
user_id, conversation_id, subpath, batch_op.get_content_json()
|
||||
)
|
||||
continue
|
||||
|
||||
if subpath.startswith('event_cache'):
|
||||
# No action required
|
||||
continue
|
||||
|
||||
# Log unhandled paths for future implementation
|
||||
logger.warning(
|
||||
'unknown_path_in_batch_webhook',
|
||||
extra={
|
||||
'path': subpath,
|
||||
'user_id': user_id,
|
||||
'conversation_id': conversation_id,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f'error_processing_batch_operation: {type(e).__name__}: {e}',
|
||||
extra={
|
||||
'path': batch_op.path,
|
||||
'method': str(batch_op.method),
|
||||
},
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
@event_webhook_router.post('/batch')
|
||||
async def on_batch_write(
|
||||
batch_ops: list[BatchOperation],
|
||||
background_tasks: BackgroundTasks,
|
||||
x_session_api_key: Annotated[str | None, Header()],
|
||||
):
|
||||
"""Handle batched webhook requests with multiple file operations in background"""
|
||||
# Add the batch processing to background tasks
|
||||
background_tasks.add_task(
|
||||
_process_batch_operations_background,
|
||||
batch_ops,
|
||||
x_session_api_key,
|
||||
)
|
||||
|
||||
# Return immediately
|
||||
return Response(status_code=status.HTTP_202_ACCEPTED)
|
||||
|
||||
|
||||
@event_webhook_router.post('/{path:path}')
|
||||
async def on_write(
|
||||
path: str,
|
||||
request: Request,
|
||||
x_session_api_key: Annotated[str | None, Header()],
|
||||
):
|
||||
"""Handle writing conversation events and metadata"""
|
||||
conversation_id, subpath = _parse_conversation_id_and_subpath(path)
|
||||
user_id = _get_user_id(conversation_id)
|
||||
|
||||
# Check the session API key to make sure this is from the correct conversation
|
||||
session_api_key = await _get_session_api_key(user_id, conversation_id)
|
||||
if session_api_key != x_session_api_key:
|
||||
return Response(status_code=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
if subpath == 'agent_state.pkl':
|
||||
content = await request.body()
|
||||
update_agent_state(user_id, conversation_id, content)
|
||||
return Response(status_code=status.HTTP_200_OK)
|
||||
|
||||
try:
|
||||
content = await request.json()
|
||||
except Exception as exc:
|
||||
return Response(status_code=status.HTTP_400_BAD_REQUEST, content=str(exc))
|
||||
|
||||
if subpath == 'metadata.json':
|
||||
update_conversation_metadata(conversation_id, content)
|
||||
return Response(status_code=status.HTTP_200_OK)
|
||||
|
||||
if subpath.startswith('events/'):
|
||||
await process_event(user_id, conversation_id, subpath, content)
|
||||
return Response(status_code=status.HTTP_200_OK)
|
||||
|
||||
if subpath.startswith('event_cache'):
|
||||
# No actionr required
|
||||
return Response(status_code=status.HTTP_200_OK)
|
||||
|
||||
logger.error(
|
||||
'invalid_subpath_in_webhook',
|
||||
extra={
|
||||
'path': path,
|
||||
'user_id': user_id,
|
||||
},
|
||||
)
|
||||
return Response(status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
@event_webhook_router.delete('/{path:path}')
|
||||
async def on_delete(path: str, x_session_api_key: Annotated[str | None, Header()]):
|
||||
return Response(status_code=status.HTTP_200_OK)
|
||||
|
||||
|
||||
def _parse_conversation_id_and_subpath(path: str) -> Tuple[str, str]:
|
||||
try:
|
||||
items = path.split('/')
|
||||
assert items[0] == 'sessions'
|
||||
conversation_id = items[1]
|
||||
subpath = '/'.join(items[2:])
|
||||
return conversation_id, subpath
|
||||
except (AssertionError, IndexError) as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST) from e
|
||||
|
||||
|
||||
def _get_user_id(conversation_id: str) -> str:
|
||||
with session_maker() as session:
|
||||
conversation_metadata_saas = (
|
||||
session.query(StoredConversationMetadataSaas)
|
||||
.filter(StoredConversationMetadataSaas.conversation_id == conversation_id)
|
||||
.first()
|
||||
)
|
||||
return str(conversation_metadata_saas.user_id)
|
||||
|
||||
|
||||
async def _get_session_api_key(user_id: str, conversation_id: str) -> str | None:
|
||||
agent_loop_info = await conversation_manager.get_agent_loop_info(
|
||||
user_id, filter_to_sids={conversation_id}
|
||||
)
|
||||
return agent_loop_info[0].session_api_key
|
||||
@@ -3,15 +3,20 @@ import hashlib
|
||||
import hmac
|
||||
import os
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException, Request
|
||||
from fastapi import APIRouter, BackgroundTasks, Header, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from integrations.github.data_collector import GitHubDataCollector
|
||||
from integrations.github.github_manager import GithubManager
|
||||
from integrations.models import Message, SourceType
|
||||
from server.auth.constants import GITHUB_APP_WEBHOOK_SECRET
|
||||
from server.auth.constants import (
|
||||
AUTOMATION_EVENT_FORWARDING_ENABLED,
|
||||
GITHUB_APP_WEBHOOK_SECRET,
|
||||
)
|
||||
from server.auth.token_manager import TokenManager
|
||||
from server.services.automation_event_service import AutomationEventService
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.provider import ProviderType
|
||||
|
||||
# Environment variable to disable GitHub webhooks
|
||||
GITHUB_WEBHOOKS_ENABLED = os.environ.get('GITHUB_WEBHOOKS_ENABLED', '1') in (
|
||||
@@ -22,6 +27,7 @@ github_integration_router = APIRouter(prefix='/integration')
|
||||
token_manager = TokenManager()
|
||||
data_collector = GitHubDataCollector()
|
||||
github_manager = GithubManager(token_manager, data_collector)
|
||||
automation_event_service = AutomationEventService(token_manager)
|
||||
|
||||
|
||||
def verify_github_signature(payload: bytes, signature: str):
|
||||
@@ -46,13 +52,13 @@ def verify_github_signature(payload: bytes, signature: str):
|
||||
@github_integration_router.post('/github/events')
|
||||
async def github_events(
|
||||
request: Request,
|
||||
background_tasks: BackgroundTasks,
|
||||
x_hub_signature_256: str = Header(None),
|
||||
x_github_event: str = Header(None),
|
||||
):
|
||||
# Check if GitHub webhooks are enabled
|
||||
if not GITHUB_WEBHOOKS_ENABLED:
|
||||
logger.info(
|
||||
'GitHub webhooks are disabled by GITHUB_WEBHOOKS_ENABLED environment variable'
|
||||
)
|
||||
logger.info('GitHub webhooks disabled by GITHUB_WEBHOOKS_ENABLED env variable')
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={'message': 'GitHub webhooks are currently disabled.'},
|
||||
@@ -72,6 +78,16 @@ async def github_events(
|
||||
content={'error': 'Installation ID is missing in the payload.'},
|
||||
)
|
||||
|
||||
# Forward to automation service (fire-and-forget background task)
|
||||
if AUTOMATION_EVENT_FORWARDING_ENABLED:
|
||||
background_tasks.add_task(
|
||||
automation_event_service.forward_event,
|
||||
provider=ProviderType.GITHUB,
|
||||
payload=payload_data,
|
||||
installation_id=installation_id,
|
||||
)
|
||||
|
||||
# Existing resolver bot processing
|
||||
message_payload = {'payload': payload_data, 'installation': installation_id}
|
||||
message = Message(source=SourceType.GITHUB, message=message_payload)
|
||||
await github_manager.receive_message(message)
|
||||
|
||||
@@ -149,7 +149,12 @@ async def verify_jira_signature(body: bytes, signature: str, payload: dict):
|
||||
|
||||
workspace_name = jira_manager.get_workspace_name_from_payload(payload)
|
||||
if workspace_name is None:
|
||||
logger.warning('[Jira] No workspace name found in webhook payload')
|
||||
logger.warning(
|
||||
'[Jira] No workspace name found in webhook payload',
|
||||
extra={
|
||||
'payload': payload,
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=403, detail='Workspace name not found in payload'
|
||||
)
|
||||
|
||||
@@ -1,681 +0,0 @@
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from typing import cast
|
||||
|
||||
import requests
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, status
|
||||
from fastapi.responses import JSONResponse, RedirectResponse
|
||||
from integrations.linear.linear_manager import LinearManager
|
||||
from integrations.models import Message, SourceType
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from server.auth.constants import LINEAR_CLIENT_ID, LINEAR_CLIENT_SECRET
|
||||
from server.auth.saas_user_auth import SaasUserAuth
|
||||
from server.auth.token_manager import TokenManager
|
||||
from server.constants import WEB_HOST
|
||||
from storage.redis import create_redis_client
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.user_auth.user_auth import get_user_auth
|
||||
|
||||
# Environment variable to disable Linear webhooks
|
||||
LINEAR_WEBHOOKS_ENABLED = os.environ.get('LINEAR_WEBHOOKS_ENABLED', '0') in (
|
||||
'1',
|
||||
'true',
|
||||
)
|
||||
LINEAR_REDIRECT_URI = f'https://{WEB_HOST}/integration/linear/callback'
|
||||
LINEAR_SCOPES = 'read'
|
||||
LINEAR_AUTH_URL = 'https://linear.app/oauth/authorize'
|
||||
LINEAR_TOKEN_URL = 'https://api.linear.app/oauth/token'
|
||||
LINEAR_GRAPHQL_URL = 'https://api.linear.app/graphql'
|
||||
|
||||
|
||||
# Request/Response models
|
||||
class LinearWorkspaceCreate(BaseModel):
|
||||
workspace_name: str = Field(..., description='Workspace display name')
|
||||
webhook_secret: str = Field(..., description='Webhook secret for verification')
|
||||
svc_acc_email: str = Field(..., description='Service account email')
|
||||
svc_acc_api_key: str = Field(..., description='Service account API key')
|
||||
is_active: bool = Field(
|
||||
default=False,
|
||||
description='Indicates if the workspace integration is active',
|
||||
)
|
||||
|
||||
@field_validator('workspace_name')
|
||||
@classmethod
|
||||
def validate_workspace_name(cls, v):
|
||||
if not re.match(r'^[a-zA-Z0-9_.-]+$', v):
|
||||
raise ValueError(
|
||||
'workspace_name can only contain alphanumeric characters, hyphens, underscores, and periods'
|
||||
)
|
||||
return v
|
||||
|
||||
@field_validator('svc_acc_email')
|
||||
@classmethod
|
||||
def validate_svc_acc_email(cls, v):
|
||||
email_pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
|
||||
if not re.match(email_pattern, v):
|
||||
raise ValueError('svc_acc_email must be a valid email address')
|
||||
return v
|
||||
|
||||
@field_validator('webhook_secret')
|
||||
@classmethod
|
||||
def validate_webhook_secret(cls, v):
|
||||
if ' ' in v:
|
||||
raise ValueError('webhook_secret cannot contain spaces')
|
||||
return v
|
||||
|
||||
@field_validator('svc_acc_api_key')
|
||||
@classmethod
|
||||
def validate_svc_acc_api_key(cls, v):
|
||||
if ' ' in v:
|
||||
raise ValueError('svc_acc_api_key cannot contain spaces')
|
||||
return v
|
||||
|
||||
|
||||
class LinearLinkCreate(BaseModel):
|
||||
workspace_name: str = Field(
|
||||
..., description='Name of the Linear workspace to link to'
|
||||
)
|
||||
|
||||
@field_validator('workspace_name')
|
||||
@classmethod
|
||||
def validate_workspace(cls, v):
|
||||
if not re.match(r'^[a-zA-Z0-9_.-]+$', v):
|
||||
raise ValueError(
|
||||
'workspace can only contain alphanumeric characters, hyphens, underscores, and periods'
|
||||
)
|
||||
return v
|
||||
|
||||
|
||||
class LinearWorkspaceResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
linear_org_id: str
|
||||
status: str
|
||||
editable: bool
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
class LinearUserResponse(BaseModel):
|
||||
id: int
|
||||
keycloak_user_id: str
|
||||
linear_workspace_id: int
|
||||
status: str
|
||||
created_at: str
|
||||
updated_at: str
|
||||
workspace: LinearWorkspaceResponse
|
||||
|
||||
|
||||
class LinearValidateWorkspaceResponse(BaseModel):
|
||||
name: str
|
||||
status: str
|
||||
message: str
|
||||
|
||||
|
||||
linear_integration_router = APIRouter(prefix='/integration/linear')
|
||||
token_manager = TokenManager()
|
||||
linear_manager = LinearManager(token_manager)
|
||||
redis_client = create_redis_client()
|
||||
|
||||
|
||||
async def _handle_workspace_link_creation(
|
||||
user_id: str, linear_user_id: str, target_workspace: str
|
||||
):
|
||||
"""Handle the creation or reactivation of a workspace link for a user."""
|
||||
# Verify workspace exists and is active
|
||||
workspace = await linear_manager.integration_store.get_workspace_by_name(
|
||||
target_workspace
|
||||
)
|
||||
if not workspace:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f'Workspace "{target_workspace}" not found',
|
||||
)
|
||||
|
||||
if workspace.status.lower() != 'active':
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Workspace "{target_workspace}" is not active',
|
||||
)
|
||||
|
||||
# Check if user currently has an active workspace link
|
||||
existing_user = await linear_manager.integration_store.get_user_by_active_workspace(
|
||||
user_id
|
||||
)
|
||||
|
||||
if existing_user:
|
||||
# User has an active link - check if it's to the same workspace
|
||||
if existing_user.linear_workspace_id == workspace.id:
|
||||
# Already linked to this workspace, nothing to do
|
||||
return
|
||||
else:
|
||||
# User is trying to link to a different workspace while having an active link
|
||||
# This is not allowed - they must unlink first
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='You already have an active workspace link. Please unlink from your current workspace before linking to a different one.',
|
||||
)
|
||||
|
||||
# Check if user had a previous link to this specific workspace
|
||||
existing_link = (
|
||||
await linear_manager.integration_store.get_user_by_keycloak_id_and_workspace(
|
||||
user_id, workspace.id
|
||||
)
|
||||
)
|
||||
|
||||
if existing_link:
|
||||
# Reactivate previous link to this workspace
|
||||
await linear_manager.integration_store.update_user_integration_status(
|
||||
user_id, 'active'
|
||||
)
|
||||
else:
|
||||
# Create new workspace link
|
||||
await linear_manager.integration_store.create_workspace_link(
|
||||
keycloak_user_id=user_id,
|
||||
linear_user_id=linear_user_id,
|
||||
linear_workspace_id=workspace.id,
|
||||
)
|
||||
|
||||
|
||||
async def _validate_workspace_update_permissions(user_id: str, target_workspace: str):
|
||||
"""Validate that user can update the target workspace."""
|
||||
workspace = await linear_manager.integration_store.get_workspace_by_name(
|
||||
target_workspace
|
||||
)
|
||||
if not workspace:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f'Workspace "{target_workspace}" not found',
|
||||
)
|
||||
|
||||
# Check if user is the admin of the workspace
|
||||
if workspace.admin_user_id != user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='You do not have permission to update this workspace',
|
||||
)
|
||||
|
||||
# Check if user's current link matches the workspace
|
||||
current_user_link = (
|
||||
await linear_manager.integration_store.get_user_by_active_workspace(user_id)
|
||||
)
|
||||
if current_user_link and current_user_link.linear_workspace_id != workspace.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='You can only update the workspace you are currently linked to',
|
||||
)
|
||||
|
||||
return workspace
|
||||
|
||||
|
||||
@linear_integration_router.post('/events')
|
||||
async def linear_events(
|
||||
request: Request,
|
||||
background_tasks: BackgroundTasks,
|
||||
):
|
||||
"""Handle Linear webhook events."""
|
||||
# Check if Linear webhooks are enabled
|
||||
if not LINEAR_WEBHOOKS_ENABLED:
|
||||
logger.info(
|
||||
'Linear webhooks are disabled by LINEAR_WEBHOOKS_ENABLED environment variable'
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={'message': 'Linear webhooks are currently disabled.'},
|
||||
)
|
||||
|
||||
try:
|
||||
signature_valid, signature, payload = await linear_manager.validate_request(
|
||||
request
|
||||
)
|
||||
|
||||
if not signature_valid:
|
||||
logger.warning('[Linear] Invalid webhook signature')
|
||||
raise HTTPException(status_code=403, detail='Invalid webhook signature!')
|
||||
|
||||
# Check for duplicate requests using Redis
|
||||
key = f'linear:{signature}'
|
||||
keyExists = redis_client.exists(key)
|
||||
if keyExists:
|
||||
logger.info(f'Received duplicate Linear webhook event: {signature}')
|
||||
return JSONResponse({'success': True})
|
||||
else:
|
||||
redis_client.setex(key, 60, 1)
|
||||
|
||||
# Process the webhook
|
||||
message_payload = {'payload': payload}
|
||||
message = Message(source=SourceType.LINEAR, message=message_payload)
|
||||
|
||||
background_tasks.add_task(linear_manager.receive_message, message)
|
||||
|
||||
return JSONResponse({'success': True})
|
||||
|
||||
except HTTPException:
|
||||
# Re-raise HTTP exceptions (like signature verification failures)
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(f'Error processing Linear webhook: {e}')
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={'error': 'Internal server error processing webhook.'},
|
||||
)
|
||||
|
||||
|
||||
@linear_integration_router.post('/workspaces')
|
||||
async def create_linear_workspace(
|
||||
request: Request, workspace_data: LinearWorkspaceCreate
|
||||
):
|
||||
"""Create a new Linear workspace registration."""
|
||||
try:
|
||||
user_auth = cast(SaasUserAuth, await get_user_auth(request))
|
||||
user_id = await user_auth.get_user_id()
|
||||
user_email = await user_auth.get_user_email()
|
||||
|
||||
state = str(uuid.uuid4())
|
||||
|
||||
integration_session = {
|
||||
'operation_type': 'workspace_integration',
|
||||
'keycloak_user_id': user_id,
|
||||
'user_email': user_email,
|
||||
'target_workspace': workspace_data.workspace_name,
|
||||
'webhook_secret': workspace_data.webhook_secret,
|
||||
'svc_acc_email': workspace_data.svc_acc_email,
|
||||
'svc_acc_api_key': workspace_data.svc_acc_api_key,
|
||||
'is_active': workspace_data.is_active,
|
||||
'state': state,
|
||||
}
|
||||
|
||||
created = redis_client.setex(
|
||||
state,
|
||||
60,
|
||||
json.dumps(integration_session),
|
||||
)
|
||||
|
||||
if not created:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to create integration session',
|
||||
)
|
||||
|
||||
auth_params = {
|
||||
'client_id': LINEAR_CLIENT_ID,
|
||||
'redirect_uri': LINEAR_REDIRECT_URI,
|
||||
'scope': LINEAR_SCOPES,
|
||||
'state': state,
|
||||
'response_type': 'code',
|
||||
}
|
||||
|
||||
auth_url = f"{LINEAR_AUTH_URL}?{'&'.join([f'{k}={v}' for k, v in auth_params.items()])}"
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
'success': True,
|
||||
'redirect': True,
|
||||
'authorizationUrl': auth_url,
|
||||
}
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(f'Error creating Linear workspace: {e}')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to create workspace',
|
||||
)
|
||||
|
||||
|
||||
@linear_integration_router.post('/workspaces/link')
|
||||
async def create_workspace_link(request: Request, link_data: LinearLinkCreate):
|
||||
"""Register a user mapping to a Linear workspace."""
|
||||
try:
|
||||
user_auth = cast(SaasUserAuth, await get_user_auth(request))
|
||||
user_id = await user_auth.get_user_id()
|
||||
user_email = await user_auth.get_user_email()
|
||||
|
||||
state = str(uuid.uuid4())
|
||||
|
||||
integration_session = {
|
||||
'operation_type': 'workspace_link',
|
||||
'keycloak_user_id': user_id,
|
||||
'user_email': user_email,
|
||||
'target_workspace': link_data.workspace_name,
|
||||
'state': state,
|
||||
}
|
||||
|
||||
created = redis_client.setex(
|
||||
state,
|
||||
60,
|
||||
json.dumps(integration_session),
|
||||
)
|
||||
|
||||
if not created:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to create integration session',
|
||||
)
|
||||
|
||||
auth_params = {
|
||||
'client_id': LINEAR_CLIENT_ID,
|
||||
'redirect_uri': LINEAR_REDIRECT_URI,
|
||||
'scope': LINEAR_SCOPES,
|
||||
'state': state,
|
||||
'response_type': 'code',
|
||||
}
|
||||
|
||||
auth_url = f"{LINEAR_AUTH_URL}?{'&'.join([f'{k}={v}' for k, v in auth_params.items()])}"
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
'success': True,
|
||||
'redirect': True,
|
||||
'authorizationUrl': auth_url,
|
||||
}
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(f'Error registering Linear user: {e}')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to register user',
|
||||
)
|
||||
|
||||
|
||||
@linear_integration_router.get('/callback')
|
||||
async def linear_callback(request: Request, code: str, state: str):
|
||||
integration_session_json = redis_client.get(state)
|
||||
if not integration_session_json:
|
||||
raise HTTPException(
|
||||
status_code=400, detail='No active integration session found.'
|
||||
)
|
||||
|
||||
integration_session = json.loads(integration_session_json)
|
||||
|
||||
# Security check: verify the state parameter
|
||||
if integration_session.get('state') != state:
|
||||
raise HTTPException(
|
||||
status_code=400, detail='State mismatch. Possible CSRF attack.'
|
||||
)
|
||||
|
||||
token_payload = {
|
||||
'grant_type': 'authorization_code',
|
||||
'client_id': LINEAR_CLIENT_ID,
|
||||
'client_secret': LINEAR_CLIENT_SECRET,
|
||||
'code': code,
|
||||
'redirect_uri': LINEAR_REDIRECT_URI,
|
||||
}
|
||||
response = requests.post(LINEAR_TOKEN_URL, data=token_payload)
|
||||
if response.status_code != 200:
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f'Error fetching token: {response.text}'
|
||||
)
|
||||
|
||||
token_data = response.json()
|
||||
access_token = token_data['access_token']
|
||||
|
||||
# Query Linear API to get workspace information
|
||||
headers = {'Authorization': f'Bearer {access_token}'}
|
||||
graphql_query = {
|
||||
'query': '{ viewer { id name email organization { id name urlKey } } }'
|
||||
}
|
||||
response = requests.post(LINEAR_GRAPHQL_URL, json=graphql_query, headers=headers)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f'Error fetching workspace: {response.text}'
|
||||
)
|
||||
|
||||
workspace_data = response.json()
|
||||
workspace_info = (
|
||||
workspace_data.get('data', {}).get('viewer', {}).get('organization', {})
|
||||
)
|
||||
workspace_name = workspace_info.get('urlKey', '').lower()
|
||||
linear_org_id = workspace_info.get('id', '')
|
||||
|
||||
target_workspace = integration_session.get('target_workspace')
|
||||
|
||||
# Verify user has access to the target workspace
|
||||
if workspace_name != target_workspace.lower():
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail=f'User is not authorized to access workspace: {target_workspace}',
|
||||
)
|
||||
|
||||
user_id = integration_session['keycloak_user_id']
|
||||
linear_user_id = workspace_data.get('data', {}).get('viewer', {}).get('id')
|
||||
|
||||
if integration_session.get('operation_type') == 'workspace_integration':
|
||||
workspace = await linear_manager.integration_store.get_workspace_by_name(
|
||||
target_workspace
|
||||
)
|
||||
if not workspace:
|
||||
# Create new workspace if it doesn't exist
|
||||
encrypted_webhook_secret = token_manager.encrypt_text(
|
||||
integration_session['webhook_secret']
|
||||
)
|
||||
encrypted_svc_acc_api_key = token_manager.encrypt_text(
|
||||
integration_session['svc_acc_api_key']
|
||||
)
|
||||
|
||||
await linear_manager.integration_store.create_workspace(
|
||||
name=target_workspace,
|
||||
linear_org_id=linear_org_id,
|
||||
admin_user_id=integration_session['keycloak_user_id'],
|
||||
encrypted_webhook_secret=encrypted_webhook_secret,
|
||||
svc_acc_email=integration_session['svc_acc_email'],
|
||||
encrypted_svc_acc_api_key=encrypted_svc_acc_api_key,
|
||||
status='active' if integration_session['is_active'] else 'inactive',
|
||||
)
|
||||
|
||||
# Create a workspace link for the user (admin automatically gets linked)
|
||||
await _handle_workspace_link_creation(
|
||||
user_id, linear_user_id, target_workspace
|
||||
)
|
||||
else:
|
||||
# Workspace exists - validate user can update it
|
||||
await _validate_workspace_update_permissions(user_id, target_workspace)
|
||||
|
||||
encrypted_webhook_secret = token_manager.encrypt_text(
|
||||
integration_session['webhook_secret']
|
||||
)
|
||||
encrypted_svc_acc_api_key = token_manager.encrypt_text(
|
||||
integration_session['svc_acc_api_key']
|
||||
)
|
||||
|
||||
# Update workspace details
|
||||
await linear_manager.integration_store.update_workspace(
|
||||
id=workspace.id,
|
||||
linear_org_id=linear_org_id,
|
||||
encrypted_webhook_secret=encrypted_webhook_secret,
|
||||
svc_acc_email=integration_session['svc_acc_email'],
|
||||
encrypted_svc_acc_api_key=encrypted_svc_acc_api_key,
|
||||
status='active' if integration_session['is_active'] else 'inactive',
|
||||
)
|
||||
|
||||
await _handle_workspace_link_creation(
|
||||
user_id, linear_user_id, target_workspace
|
||||
)
|
||||
|
||||
return RedirectResponse(
|
||||
url='/settings/integrations',
|
||||
status_code=status.HTTP_302_FOUND,
|
||||
)
|
||||
elif integration_session.get('operation_type') == 'workspace_link':
|
||||
await _handle_workspace_link_creation(user_id, linear_user_id, target_workspace)
|
||||
return RedirectResponse(
|
||||
url='/settings/integrations', status_code=status.HTTP_302_FOUND
|
||||
)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail='Invalid operation type')
|
||||
|
||||
|
||||
@linear_integration_router.get(
|
||||
'/workspaces/link',
|
||||
response_model=LinearUserResponse,
|
||||
)
|
||||
async def get_current_workspace_link(request: Request):
|
||||
"""Get current user's Linear integration details."""
|
||||
try:
|
||||
user_auth = cast(SaasUserAuth, await get_user_auth(request))
|
||||
user_id = await user_auth.get_user_id()
|
||||
if not user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail='User not authenticated',
|
||||
)
|
||||
|
||||
user = await linear_manager.integration_store.get_user_by_active_workspace(
|
||||
user_id
|
||||
)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='User is not registered for Linear integration',
|
||||
)
|
||||
|
||||
workspace = await linear_manager.integration_store.get_workspace_by_id(
|
||||
user.linear_workspace_id
|
||||
)
|
||||
if not workspace:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Workspace not found for the user',
|
||||
)
|
||||
|
||||
return LinearUserResponse(
|
||||
id=user.id,
|
||||
keycloak_user_id=user.keycloak_user_id,
|
||||
linear_workspace_id=user.linear_workspace_id,
|
||||
status=user.status,
|
||||
created_at=user.created_at.isoformat(),
|
||||
updated_at=user.updated_at.isoformat(),
|
||||
workspace=LinearWorkspaceResponse(
|
||||
id=workspace.id,
|
||||
name=workspace.name,
|
||||
linear_org_id=workspace.linear_org_id,
|
||||
status=workspace.status,
|
||||
editable=workspace.admin_user_id == user.keycloak_user_id,
|
||||
created_at=workspace.created_at.isoformat(),
|
||||
updated_at=workspace.updated_at.isoformat(),
|
||||
),
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(f'Error retrieving Linear user: {e}')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to retrieve user',
|
||||
)
|
||||
|
||||
|
||||
@linear_integration_router.post('/workspaces/unlink')
|
||||
async def unlink_workspace(request: Request):
|
||||
"""Unlink user from Linear integration by setting status to inactive."""
|
||||
try:
|
||||
user_auth = cast(SaasUserAuth, await get_user_auth(request))
|
||||
user_id = await user_auth.get_user_id()
|
||||
if not user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail='User not authenticated',
|
||||
)
|
||||
|
||||
user = await linear_manager.integration_store.get_user_by_active_workspace(
|
||||
user_id
|
||||
)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='User is not registered for Linear integration',
|
||||
)
|
||||
|
||||
workspace = await linear_manager.integration_store.get_workspace_by_id(
|
||||
user.linear_workspace_id
|
||||
)
|
||||
if not workspace:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Workspace not found for the user',
|
||||
)
|
||||
|
||||
if workspace.admin_user_id == user_id:
|
||||
await linear_manager.integration_store.deactivate_workspace(
|
||||
workspace_id=workspace.id,
|
||||
)
|
||||
else:
|
||||
await linear_manager.integration_store.update_user_integration_status(
|
||||
user_id, 'inactive'
|
||||
)
|
||||
|
||||
return JSONResponse({'success': True})
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(f'Error unlinking Linear user: {e}')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to unlink user',
|
||||
)
|
||||
|
||||
|
||||
@linear_integration_router.get(
|
||||
'/workspaces/validate/{workspace_name}',
|
||||
response_model=LinearValidateWorkspaceResponse,
|
||||
)
|
||||
async def validate_workspace_integration(request: Request, workspace_name: str):
|
||||
"""Validate if the workspace has an active Linear integration."""
|
||||
try:
|
||||
# Validate workspace_name format
|
||||
if not re.match(r'^[a-zA-Z0-9_.-]+$', workspace_name):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='workspace_name can only contain alphanumeric characters, hyphens, underscores, and periods',
|
||||
)
|
||||
|
||||
user_auth = cast(SaasUserAuth, await get_user_auth(request))
|
||||
user_email = await user_auth.get_user_email()
|
||||
if not user_email:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Unable to retrieve user email',
|
||||
)
|
||||
|
||||
# Check if workspace exists
|
||||
workspace = await linear_manager.integration_store.get_workspace_by_name(
|
||||
workspace_name
|
||||
)
|
||||
if not workspace:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Workspace with name '{workspace_name}' not found",
|
||||
)
|
||||
|
||||
# Check if workspace is active
|
||||
if workspace.status.lower() != 'active':
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Workspace '{workspace.name}' is not active",
|
||||
)
|
||||
|
||||
return LinearValidateWorkspaceResponse(
|
||||
name=workspace.name,
|
||||
status=workspace.status,
|
||||
message='Workspace integration is active',
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(f'Error validating Linear workspace: {e}')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to validate workspace',
|
||||
)
|
||||
@@ -180,6 +180,18 @@ async def device_token(device_code: str = Form(...)):
|
||||
)
|
||||
|
||||
if device_code_entry.status == 'authorized':
|
||||
# Verify user_id is set (should always be true for authorized status)
|
||||
if not device_code_entry.keycloak_user_id:
|
||||
logger.error(
|
||||
'Authorized device code missing user_id',
|
||||
extra={'user_code': device_code_entry.user_code},
|
||||
)
|
||||
return _oauth_error(
|
||||
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
'server_error',
|
||||
'User identification missing',
|
||||
)
|
||||
|
||||
# Retrieve the specific API key for this device using the user_code
|
||||
api_key_store = ApiKeyStore.get_instance()
|
||||
device_key_name = f'{API_KEY_NAME} ({device_code_entry.user_code})'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import Annotated
|
||||
from typing import Annotated, Any
|
||||
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
@@ -7,11 +7,16 @@ from pydantic import (
|
||||
SecretStr,
|
||||
StringConstraints,
|
||||
field_validator,
|
||||
model_validator,
|
||||
)
|
||||
from server.constants import LITE_LLM_API_URL
|
||||
from storage.org import Org
|
||||
from storage.org_member import OrgMember
|
||||
from storage.role import Role
|
||||
|
||||
from openhands.sdk.settings import AgentSettings, ConversationSettings
|
||||
from openhands.utils.llm import MASKED_API_KEY, resolve_llm_base_url
|
||||
|
||||
|
||||
class OrgCreationError(Exception):
|
||||
"""Base exception for organization creation errors."""
|
||||
@@ -144,21 +149,16 @@ class OrgResponse(BaseModel):
|
||||
contact_name: str
|
||||
contact_email: str
|
||||
conversation_expiration: int | None = None
|
||||
agent: str | None = None
|
||||
default_max_iterations: int | None = None
|
||||
security_analyzer: str | None = None
|
||||
confirmation_mode: bool | None = None
|
||||
default_llm_model: str | None = None
|
||||
default_llm_api_key_for_byor: str | None = None
|
||||
default_llm_base_url: str | None = None
|
||||
remote_runtime_resource_factor: int | None = None
|
||||
enable_default_condenser: bool = True
|
||||
billing_margin: float | None = None
|
||||
enable_proactive_conversation_starters: bool = True
|
||||
sandbox_base_container_image: str | None = None
|
||||
sandbox_runtime_container_image: str | None = None
|
||||
org_version: int = 0
|
||||
mcp_config: dict | None = None
|
||||
agent_settings: AgentSettings = Field(default_factory=AgentSettings)
|
||||
conversation_settings: ConversationSettings = Field(
|
||||
default_factory=ConversationSettings
|
||||
)
|
||||
search_api_key: str | None = None
|
||||
sandbox_api_key: str | None = None
|
||||
max_budget_per_task: float | None = None
|
||||
@@ -171,33 +171,14 @@ class OrgResponse(BaseModel):
|
||||
def from_org(
|
||||
cls, org: Org, credits: float | None = None, user_id: str | None = None
|
||||
) -> 'OrgResponse':
|
||||
"""Create an OrgResponse from an Org entity.
|
||||
|
||||
Args:
|
||||
org: The organization entity to convert
|
||||
credits: Optional credits value (defaults to None)
|
||||
user_id: Optional user ID to determine if org is personal (defaults to None)
|
||||
|
||||
Returns:
|
||||
OrgResponse: The response model instance
|
||||
"""
|
||||
"""Create an OrgResponse from an Org entity."""
|
||||
return cls(
|
||||
id=str(org.id),
|
||||
name=org.name,
|
||||
contact_name=org.contact_name,
|
||||
contact_email=org.contact_email,
|
||||
conversation_expiration=org.conversation_expiration,
|
||||
agent=org.agent,
|
||||
default_max_iterations=org.default_max_iterations,
|
||||
security_analyzer=org.security_analyzer,
|
||||
confirmation_mode=org.confirmation_mode,
|
||||
default_llm_model=org.default_llm_model,
|
||||
default_llm_api_key_for_byor=None,
|
||||
default_llm_base_url=org.default_llm_base_url,
|
||||
remote_runtime_resource_factor=org.remote_runtime_resource_factor,
|
||||
enable_default_condenser=org.enable_default_condenser
|
||||
if org.enable_default_condenser is not None
|
||||
else True,
|
||||
billing_margin=org.billing_margin,
|
||||
enable_proactive_conversation_starters=org.enable_proactive_conversation_starters
|
||||
if org.enable_proactive_conversation_starters is not None
|
||||
@@ -205,7 +186,12 @@ class OrgResponse(BaseModel):
|
||||
sandbox_base_container_image=org.sandbox_base_container_image,
|
||||
sandbox_runtime_container_image=org.sandbox_runtime_container_image,
|
||||
org_version=org.org_version if org.org_version is not None else 0,
|
||||
mcp_config=org.mcp_config,
|
||||
agent_settings=AgentSettings.model_validate(
|
||||
dict(org.agent_settings) if org.agent_settings else {}
|
||||
),
|
||||
conversation_settings=ConversationSettings.model_validate(
|
||||
dict(org.conversation_settings) if org.conversation_settings else {}
|
||||
),
|
||||
search_api_key=None,
|
||||
sandbox_api_key=None,
|
||||
max_budget_per_task=org.max_budget_per_task,
|
||||
@@ -225,9 +211,13 @@ class OrgPage(BaseModel):
|
||||
|
||||
|
||||
class OrgUpdate(BaseModel):
|
||||
"""Request model for updating an organization."""
|
||||
"""Request model for updating an organization.
|
||||
|
||||
``agent_settings_diff`` and ``conversation_settings_diff`` are sparse diffs
|
||||
that are deep-merged into the org row and then validated as full settings
|
||||
before persistence.
|
||||
"""
|
||||
|
||||
# Basic organization information (any authenticated user can update)
|
||||
name: Annotated[
|
||||
str | None,
|
||||
StringConstraints(strip_whitespace=True, min_length=1, max_length=255),
|
||||
@@ -235,7 +225,6 @@ class OrgUpdate(BaseModel):
|
||||
contact_name: str | None = None
|
||||
contact_email: EmailStr | None = None
|
||||
conversation_expiration: int | None = None
|
||||
default_max_iterations: int | None = Field(default=None, gt=0)
|
||||
remote_runtime_resource_factor: int | None = Field(default=None, gt=0)
|
||||
billing_margin: float | None = Field(default=None, ge=0, le=1)
|
||||
enable_proactive_conversation_starters: bool | None = None
|
||||
@@ -245,31 +234,152 @@ class OrgUpdate(BaseModel):
|
||||
max_budget_per_task: float | None = Field(default=None, gt=0)
|
||||
enable_solvability_analysis: bool | None = None
|
||||
v1_enabled: bool | None = None
|
||||
|
||||
# LLM settings (require admin/owner role)
|
||||
default_llm_model: str | None = None
|
||||
default_llm_api_key_for_byor: str | None = None
|
||||
default_llm_base_url: str | None = None
|
||||
search_api_key: str | None = None
|
||||
security_analyzer: str | None = None
|
||||
agent: str | None = None
|
||||
confirmation_mode: bool | None = None
|
||||
enable_default_condenser: bool | None = None
|
||||
condenser_max_size: int | None = Field(default=None, ge=20)
|
||||
llm_api_key: str | None = None
|
||||
agent_settings_diff: dict[str, Any] | None = None
|
||||
conversation_settings_diff: dict[str, Any] | None = None
|
||||
|
||||
@model_validator(mode='after')
|
||||
def _normalize_settings_diffs(self) -> 'OrgUpdate':
|
||||
"""Normalize sparse settings diffs before merge/persistence."""
|
||||
self._normalize_agent_settings_diff()
|
||||
self._cleanup_empty_diff('agent_settings_diff', nested_key='llm')
|
||||
self._cleanup_empty_diff('conversation_settings_diff')
|
||||
return self
|
||||
|
||||
def _normalize_agent_settings_diff(self) -> None:
|
||||
"""Normalize nested LLM settings inside ``agent_settings_diff``."""
|
||||
llm_diff = self._get_agent_llm_diff()
|
||||
if llm_diff is None:
|
||||
return
|
||||
|
||||
self._lift_and_mask_llm_api_key(llm_diff)
|
||||
self._resolve_agent_llm_base_url(llm_diff)
|
||||
|
||||
def _get_agent_llm_diff(self) -> dict[str, Any] | None:
|
||||
"""Return the nested ``llm`` diff when present and dictionary-shaped."""
|
||||
if self.agent_settings_diff is None:
|
||||
return None
|
||||
llm_diff = self.agent_settings_diff.get('llm')
|
||||
return llm_diff if isinstance(llm_diff, dict) else None
|
||||
|
||||
def _lift_and_mask_llm_api_key(self, llm_diff: dict[str, Any]) -> None:
|
||||
"""Lift nested api keys to ``llm_api_key`` and mask the JSON diff."""
|
||||
if 'api_key' not in llm_diff:
|
||||
return
|
||||
|
||||
nested_key = llm_diff.pop('api_key')
|
||||
if (
|
||||
self.llm_api_key is None
|
||||
and nested_key is not None
|
||||
and nested_key != MASKED_API_KEY
|
||||
):
|
||||
self.llm_api_key = nested_key
|
||||
if nested_key is not None:
|
||||
llm_diff['api_key'] = MASKED_API_KEY
|
||||
|
||||
def _resolve_agent_llm_base_url(self, llm_diff: dict[str, Any]) -> None:
|
||||
"""Fill provider-default base URLs for sparse LLM diffs when needed."""
|
||||
resolved_base_url = resolve_llm_base_url(
|
||||
model=llm_diff.get('model'),
|
||||
base_url=llm_diff.get('base_url'),
|
||||
managed_proxy_url=LITE_LLM_API_URL,
|
||||
)
|
||||
if resolved_base_url is not None:
|
||||
llm_diff['base_url'] = resolved_base_url
|
||||
|
||||
def _cleanup_empty_diff(
|
||||
self,
|
||||
field_name: str,
|
||||
nested_key: str | None = None,
|
||||
) -> None:
|
||||
"""Drop empty nested diffs and collapse empty diff payloads to ``None``."""
|
||||
settings_diff = getattr(self, field_name)
|
||||
if not isinstance(settings_diff, dict):
|
||||
if not settings_diff:
|
||||
setattr(self, field_name, None)
|
||||
return
|
||||
|
||||
if nested_key is not None and not settings_diff.get(nested_key):
|
||||
settings_diff.pop(nested_key, None)
|
||||
if not settings_diff:
|
||||
setattr(self, field_name, None)
|
||||
|
||||
def updated_fields(self) -> set[str]:
|
||||
"""Return the public field names explicitly present on the update."""
|
||||
return {
|
||||
field
|
||||
for field in type(self).model_fields
|
||||
if getattr(self, field) is not None
|
||||
}
|
||||
|
||||
def has_updates(self) -> bool:
|
||||
"""Check if any public update field is set (not None)."""
|
||||
return bool(self.updated_fields())
|
||||
|
||||
def touches_org_defaults(self) -> bool:
|
||||
"""Whether this update touches shared organization defaults."""
|
||||
return bool(
|
||||
self.updated_fields()
|
||||
& {
|
||||
'agent_settings_diff',
|
||||
'conversation_settings_diff',
|
||||
'search_api_key',
|
||||
'llm_api_key',
|
||||
}
|
||||
)
|
||||
|
||||
def restricted_fields(self) -> set[str]:
|
||||
"""Return fields that require elevated org settings permissions."""
|
||||
return self.updated_fields() & {
|
||||
'agent_settings_diff',
|
||||
'conversation_settings_diff',
|
||||
'search_api_key',
|
||||
'sandbox_api_key',
|
||||
'llm_api_key',
|
||||
}
|
||||
|
||||
def model_update_dict(self) -> dict[str, Any]:
|
||||
"""Return JSON-serializable scalar fields for persistence."""
|
||||
return self.model_dump(
|
||||
mode='json',
|
||||
exclude_none=True,
|
||||
exclude={'agent_settings_diff', 'conversation_settings_diff'},
|
||||
)
|
||||
|
||||
def apply_to_org(self, org: Org) -> None:
|
||||
"""Apply non-settings fields directly to the organization model."""
|
||||
for key, value in self.model_update_dict().items():
|
||||
if hasattr(org, key):
|
||||
setattr(org, key, value)
|
||||
|
||||
def get_member_updates(self) -> 'OrgMemberSettingsUpdate | None':
|
||||
"""Get shared updates that need to be propagated to org members.
|
||||
|
||||
An empty ``llm_api_key`` means the org-wide custom key is being cleared
|
||||
(e.g. owner switching to a managed/OpenHands provider). It must not
|
||||
land in member rows — ``OrgMember.llm_api_key``'s setter has no
|
||||
``if raw else None`` guard because the column is ``nullable=False``,
|
||||
so an empty string would become an encrypted empty blob rather than a
|
||||
cleared value. Coerce ``""`` to ``None`` so member rows are untouched.
|
||||
"""
|
||||
member_settings = OrgMemberSettingsUpdate(
|
||||
agent_settings_diff=self.agent_settings_diff,
|
||||
conversation_settings_diff=self.conversation_settings_diff,
|
||||
llm_api_key=self.llm_api_key or None,
|
||||
)
|
||||
return member_settings if member_settings.has_updates() else None
|
||||
|
||||
|
||||
class OrgLLMSettingsResponse(BaseModel):
|
||||
"""Response model for organization LLM settings."""
|
||||
class OrgDefaultsSettingsResponse(BaseModel):
|
||||
"""Response model for organization default settings."""
|
||||
|
||||
default_llm_model: str | None = None
|
||||
default_llm_base_url: str | None = None
|
||||
agent_settings: AgentSettings = Field(default_factory=AgentSettings)
|
||||
conversation_settings: ConversationSettings = Field(
|
||||
default_factory=ConversationSettings
|
||||
)
|
||||
llm_api_key_set: bool = False
|
||||
search_api_key: str | None = None # Masked in response
|
||||
agent: str | None = None
|
||||
confirmation_mode: bool | None = None
|
||||
security_analyzer: str | None = None
|
||||
enable_default_condenser: bool = True
|
||||
condenser_max_size: int | None = None
|
||||
default_max_iterations: int | None = None
|
||||
|
||||
@staticmethod
|
||||
def _mask_key(secret: SecretStr | None) -> str | None:
|
||||
@@ -284,87 +394,84 @@ class OrgLLMSettingsResponse(BaseModel):
|
||||
return '****' + raw[-4:]
|
||||
|
||||
@classmethod
|
||||
def from_org(cls, org: Org) -> 'OrgLLMSettingsResponse':
|
||||
"""Create response from Org entity."""
|
||||
def from_org(cls, org: Org) -> 'OrgDefaultsSettingsResponse':
|
||||
"""Create response from Org entity.
|
||||
|
||||
Denormalizes the SDK's ``litellm_proxy/`` prefix back to
|
||||
``openhands/`` so the frontend's basic-view provider/model dropdowns
|
||||
can be populated, and nulls ``api_key`` so neither the raw secret
|
||||
nor the ``MASKED_API_KEY`` marker leaks in the response.
|
||||
``base_url`` is returned exactly as stored so ``org.agent_settings``,
|
||||
``org_member.agent_settings_diff`` and this response always carry
|
||||
the same value.
|
||||
"""
|
||||
agent_settings = AgentSettings.model_validate(
|
||||
dict(org.agent_settings) if org.agent_settings else {}
|
||||
)
|
||||
cls._denormalize_llm_for_response(agent_settings)
|
||||
return cls(
|
||||
default_llm_model=org.default_llm_model,
|
||||
default_llm_base_url=org.default_llm_base_url,
|
||||
agent_settings=agent_settings,
|
||||
conversation_settings=ConversationSettings.model_validate(
|
||||
dict(org.conversation_settings) if org.conversation_settings else {}
|
||||
),
|
||||
llm_api_key_set=org.llm_api_key is not None,
|
||||
search_api_key=cls._mask_key(org.search_api_key),
|
||||
agent=org.agent,
|
||||
confirmation_mode=org.confirmation_mode,
|
||||
security_analyzer=org.security_analyzer,
|
||||
enable_default_condenser=org.enable_default_condenser
|
||||
if org.enable_default_condenser is not None
|
||||
else True,
|
||||
condenser_max_size=org.condenser_max_size,
|
||||
default_max_iterations=org.default_max_iterations,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _denormalize_llm_for_response(agent_settings: AgentSettings) -> None:
|
||||
"""Rewrite ``agent_settings.llm`` in-place for UI consumption.
|
||||
|
||||
class OrgMemberLLMSettings(BaseModel):
|
||||
"""LLM settings to propagate to organization members.
|
||||
* ``litellm_proxy/X`` → ``openhands/X`` so the basic-view provider
|
||||
dropdown matches (the SDK's ``AgentSettings`` validator
|
||||
normalizes the other direction on load).
|
||||
* ``base_url`` is returned **as stored** so the three sync targets
|
||||
(``org.agent_settings.llm.base_url``,
|
||||
``org_member.agent_settings_diff.llm.base_url``, and the GET
|
||||
response) always agree. The frontend is responsible for
|
||||
recognizing the managed LiteLLM proxy URL / provider-default URL
|
||||
as "basic mode" — see ``KNOWN_PROVIDER_DEFAULT_BASE_URLS`` in
|
||||
``frontend/src/routes/llm-settings.tsx``.
|
||||
* ``api_key`` is nulled so neither the raw secret nor the
|
||||
``MASKED_API_KEY`` marker leaks in the response — the frontend
|
||||
reads ``llm_api_key_set`` to know whether a key exists.
|
||||
|
||||
Field names match OrgMember DB columns.
|
||||
Pydantic v2 field assignment bypasses ``field_validator`` /
|
||||
``model_validator`` by default (``validate_assignment`` is off on
|
||||
the SDK's ``LLM`` model), so the rename survives without being
|
||||
re-normalized back to ``litellm_proxy/``.
|
||||
"""
|
||||
llm = agent_settings.llm
|
||||
if llm.model and llm.model.startswith('litellm_proxy/'):
|
||||
llm.model = f'openhands/{llm.model.removeprefix("litellm_proxy/")}'
|
||||
llm.api_key = None
|
||||
|
||||
|
||||
class OrgMemberSettingsUpdate(BaseModel):
|
||||
"""Shared settings updates that may be propagated to organization members.
|
||||
|
||||
``llm_api_key`` is typed as ``SecretStr`` so the raw value never ends up
|
||||
in logs or ``model_dump(mode='json')`` output by accident — the
|
||||
column-backed ``OrgMember.llm_api_key`` setter accepts ``SecretStr``
|
||||
directly and unwraps via ``get_secret_value()``.
|
||||
|
||||
``has_custom_llm_api_key`` propagates through
|
||||
``update_all_members_settings_async`` so an org-defaults save can
|
||||
reset every member's "I have a personal BYOR key" flag in one pass —
|
||||
managed-mode switches rely on this to stop load-time fallthrough from
|
||||
returning stale custom markers.
|
||||
"""
|
||||
|
||||
llm_model: str | None = None
|
||||
llm_base_url: str | None = None
|
||||
max_iterations: int | None = None
|
||||
llm_api_key: str | None = None
|
||||
agent_settings_diff: dict[str, Any] | None = None
|
||||
conversation_settings_diff: dict[str, Any] | None = None
|
||||
llm_api_key: SecretStr | None = None
|
||||
has_custom_llm_api_key: bool | None = None
|
||||
|
||||
def has_updates(self) -> bool:
|
||||
"""Check if any field is set (not None)."""
|
||||
return any(getattr(self, field) is not None for field in self.model_fields)
|
||||
|
||||
|
||||
class OrgLLMSettingsUpdate(BaseModel):
|
||||
"""Request model for updating organization LLM settings.
|
||||
|
||||
Field names match Org DB columns exactly.
|
||||
"""
|
||||
|
||||
default_llm_model: str | None = None
|
||||
default_llm_base_url: str | None = None
|
||||
search_api_key: str | None = None
|
||||
agent: str | None = None
|
||||
confirmation_mode: bool | None = None
|
||||
security_analyzer: str | None = None
|
||||
enable_default_condenser: bool | None = None
|
||||
condenser_max_size: int | None = Field(default=None, ge=20)
|
||||
default_max_iterations: int | None = Field(default=None, gt=0)
|
||||
llm_api_key: str | None = None
|
||||
|
||||
def has_updates(self) -> bool:
|
||||
"""Check if any field is set (not None)."""
|
||||
return any(getattr(self, field) is not None for field in self.model_fields)
|
||||
|
||||
def apply_to_org(self, org: Org) -> None:
|
||||
"""Apply non-None settings to the organization model.
|
||||
|
||||
Args:
|
||||
org: Organization entity to update in place
|
||||
"""
|
||||
for field_name in self.model_fields:
|
||||
value = getattr(self, field_name)
|
||||
# Skip llm_api_key - it's only for member propagation, not org-level
|
||||
if value is not None and field_name != 'llm_api_key':
|
||||
setattr(org, field_name, value)
|
||||
|
||||
def get_member_updates(self) -> OrgMemberLLMSettings | None:
|
||||
"""Get updates that need to be propagated to org members.
|
||||
|
||||
Returns:
|
||||
OrgMemberLLMSettings with mapped field values, or None if no member updates needed.
|
||||
Maps: default_llm_model → llm_model, default_llm_base_url → llm_base_url,
|
||||
default_max_iterations → max_iterations, llm_api_key → llm_api_key
|
||||
"""
|
||||
member_settings = OrgMemberLLMSettings(
|
||||
llm_model=self.default_llm_model,
|
||||
llm_base_url=self.default_llm_base_url,
|
||||
max_iterations=self.default_max_iterations,
|
||||
llm_api_key=self.llm_api_key,
|
||||
return any(
|
||||
getattr(self, field) is not None for field in type(self).model_fields
|
||||
)
|
||||
return member_settings if member_settings.has_updates() else None
|
||||
|
||||
|
||||
class OrgMemberResponse(BaseModel):
|
||||
@@ -393,25 +500,28 @@ class OrgMemberUpdate(BaseModel):
|
||||
|
||||
|
||||
class MeResponse(BaseModel):
|
||||
"""Response model for the current user's membership in an organization."""
|
||||
"""Response model for the current user's membership in an organization.
|
||||
|
||||
``agent_settings_diff`` and ``conversation_settings_diff`` carry the
|
||||
member-level overrides on top of the organization defaults.
|
||||
"""
|
||||
|
||||
org_id: str
|
||||
user_id: str
|
||||
email: str
|
||||
role: str
|
||||
llm_api_key: str
|
||||
max_iterations: int | None = None
|
||||
llm_model: str | None = None
|
||||
llm_api_key_for_byor: str | None = None
|
||||
llm_base_url: str | None = None
|
||||
agent_settings_diff: dict[str, Any] = Field(default_factory=dict)
|
||||
conversation_settings_diff: dict[str, Any] = Field(default_factory=dict)
|
||||
status: str | None = None
|
||||
|
||||
@staticmethod
|
||||
def _mask_key(secret: SecretStr | None) -> str:
|
||||
def _mask_key(secret: str | SecretStr | None) -> str:
|
||||
"""Mask an API key, showing only last 4 characters."""
|
||||
if secret is None:
|
||||
return ''
|
||||
raw = secret.get_secret_value()
|
||||
raw = secret.get_secret_value() if isinstance(secret, SecretStr) else secret
|
||||
if not raw:
|
||||
return ''
|
||||
if len(raw) <= 4:
|
||||
@@ -419,27 +529,22 @@ class MeResponse(BaseModel):
|
||||
return '****' + raw[-4:]
|
||||
|
||||
@classmethod
|
||||
def from_org_member(cls, member: OrgMember, role: Role, email: str) -> 'MeResponse':
|
||||
"""Create a MeResponse from an OrgMember, Role, and user email.
|
||||
|
||||
Args:
|
||||
member: The OrgMember entity
|
||||
role: The Role entity (provides role name)
|
||||
email: The user's email address
|
||||
|
||||
Returns:
|
||||
MeResponse with masked API keys
|
||||
"""
|
||||
def from_org_member(
|
||||
cls,
|
||||
member: OrgMember,
|
||||
role: Role,
|
||||
email: str,
|
||||
) -> 'MeResponse':
|
||||
"""Create a MeResponse from an OrgMember, Role, and user email."""
|
||||
return cls(
|
||||
org_id=str(member.org_id),
|
||||
user_id=str(member.user_id),
|
||||
email=email,
|
||||
role=role.name,
|
||||
llm_api_key=cls._mask_key(member.llm_api_key),
|
||||
max_iterations=member.max_iterations,
|
||||
llm_model=member.llm_model,
|
||||
llm_api_key_for_byor=cls._mask_key(member.llm_api_key_for_byor) or None,
|
||||
llm_base_url=member.llm_base_url,
|
||||
agent_settings_diff=dict(member.agent_settings_diff or {}),
|
||||
conversation_settings_diff=dict(member.conversation_settings_diff or {}),
|
||||
status=member.status,
|
||||
)
|
||||
|
||||
|
||||
@@ -24,8 +24,7 @@ from server.routes.org_models import (
|
||||
OrgAuthorizationError,
|
||||
OrgCreate,
|
||||
OrgDatabaseError,
|
||||
OrgLLMSettingsResponse,
|
||||
OrgLLMSettingsUpdate,
|
||||
OrgDefaultsSettingsResponse,
|
||||
OrgMemberFinancialPage,
|
||||
OrgMemberNotFoundError,
|
||||
OrgMemberPage,
|
||||
@@ -43,15 +42,12 @@ from server.services.org_app_settings_service import (
|
||||
OrgAppSettingsService,
|
||||
OrgAppSettingsServiceInjector,
|
||||
)
|
||||
from server.services.org_llm_settings_service import (
|
||||
OrgLLMSettingsService,
|
||||
OrgLLMSettingsServiceInjector,
|
||||
)
|
||||
from server.services.org_member_financial_service import OrgMemberFinancialService
|
||||
from server.services.org_member_service import OrgMemberService
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from storage.org_git_claim_store import OrgGitClaimStore
|
||||
from storage.org_service import OrgService
|
||||
from storage.org_store import OrgStore
|
||||
from storage.user_store import UserStore
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
@@ -60,9 +56,6 @@ from openhands.server.user_auth import get_user_id
|
||||
# Initialize API router
|
||||
org_router = APIRouter(prefix='/api/organizations', tags=['Orgs'])
|
||||
|
||||
# Create injector instance and dependency for LLM settings
|
||||
_org_llm_settings_injector = OrgLLMSettingsServiceInjector()
|
||||
org_llm_settings_service_dependency = Depends(_org_llm_settings_injector.depends)
|
||||
# Create injector instance and dependency at module level
|
||||
_org_app_settings_injector = OrgAppSettingsServiceInjector()
|
||||
org_app_settings_service_dependency = Depends(_org_app_settings_injector.depends)
|
||||
@@ -228,34 +221,15 @@ async def create_org(
|
||||
)
|
||||
|
||||
|
||||
@org_router.get(
|
||||
'/llm',
|
||||
response_model=OrgLLMSettingsResponse,
|
||||
dependencies=[Depends(require_permission(Permission.VIEW_LLM_SETTINGS))],
|
||||
)
|
||||
async def get_org_llm_settings(
|
||||
service: OrgLLMSettingsService = org_llm_settings_service_dependency,
|
||||
) -> OrgLLMSettingsResponse:
|
||||
"""Get LLM settings for the user's current organization.
|
||||
|
||||
This endpoint retrieves the LLM configuration settings for the
|
||||
authenticated user's current organization. All organization members
|
||||
can view these settings.
|
||||
|
||||
Args:
|
||||
service: OrgLLMSettingsService (injected by dependency)
|
||||
|
||||
Returns:
|
||||
OrgLLMSettingsResponse: The organization's LLM settings
|
||||
|
||||
Raises:
|
||||
HTTPException: 401 if not authenticated
|
||||
HTTPException: 403 if not a member of any organization
|
||||
HTTPException: 404 if current organization not found
|
||||
HTTPException: 500 if retrieval fails
|
||||
"""
|
||||
@org_router.get('/{org_id}/settings', response_model=OrgDefaultsSettingsResponse)
|
||||
async def get_org_defaults_settings(
|
||||
org_id: UUID,
|
||||
user_id: str = Depends(require_permission(Permission.VIEW_ORG_SETTINGS)),
|
||||
) -> OrgDefaultsSettingsResponse:
|
||||
"""Get org-default settings for a specific organization."""
|
||||
try:
|
||||
return await service.get_org_llm_settings()
|
||||
org = await OrgService.get_org_by_id(org_id=org_id, user_id=user_id)
|
||||
return OrgDefaultsSettingsResponse.from_org(org)
|
||||
except OrgNotFoundError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
@@ -263,45 +237,45 @@ async def get_org_llm_settings(
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
'Error getting organization LLM settings',
|
||||
extra={'error': str(e)},
|
||||
'Error getting organization defaults settings',
|
||||
extra={'user_id': user_id, 'org_id': str(org_id), 'error': str(e)},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to retrieve LLM settings',
|
||||
detail='Failed to retrieve organization defaults settings',
|
||||
)
|
||||
|
||||
|
||||
@org_router.post(
|
||||
'/llm',
|
||||
response_model=OrgLLMSettingsResponse,
|
||||
dependencies=[Depends(require_permission(Permission.EDIT_LLM_SETTINGS))],
|
||||
)
|
||||
async def update_org_llm_settings(
|
||||
settings: OrgLLMSettingsUpdate,
|
||||
service: OrgLLMSettingsService = org_llm_settings_service_dependency,
|
||||
) -> OrgLLMSettingsResponse:
|
||||
"""Update LLM settings for the user's current organization.
|
||||
|
||||
This endpoint updates the LLM configuration settings for the
|
||||
authenticated user's current organization. Only admins and owners
|
||||
can update these settings.
|
||||
|
||||
Args:
|
||||
settings: The LLM settings to update (only non-None fields are updated)
|
||||
service: OrgLLMSettingsService (injected by dependency)
|
||||
|
||||
Returns:
|
||||
OrgLLMSettingsResponse: The updated organization's LLM settings
|
||||
|
||||
Raises:
|
||||
HTTPException: 401 if not authenticated
|
||||
HTTPException: 403 if user lacks EDIT_LLM_SETTINGS permission
|
||||
HTTPException: 404 if current organization not found
|
||||
HTTPException: 500 if update fails
|
||||
"""
|
||||
@org_router.patch('/{org_id}/settings', response_model=OrgDefaultsSettingsResponse)
|
||||
async def update_org_defaults_settings(
|
||||
org_id: UUID,
|
||||
settings: OrgUpdate,
|
||||
user_id: str = Depends(require_permission(Permission.EDIT_ORG_SETTINGS)),
|
||||
) -> OrgDefaultsSettingsResponse:
|
||||
"""Update org-default settings for a specific organization."""
|
||||
try:
|
||||
return await service.update_org_llm_settings(settings)
|
||||
allowed_fields = {
|
||||
'agent_settings_diff',
|
||||
'conversation_settings_diff',
|
||||
'search_api_key',
|
||||
'llm_api_key',
|
||||
}
|
||||
invalid_fields = settings.updated_fields() - allowed_fields
|
||||
if invalid_fields:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=(
|
||||
'Only organization default settings fields are supported on '
|
||||
'/api/organizations/{org_id}/settings'
|
||||
),
|
||||
)
|
||||
|
||||
updated_org = await OrgService.update_org_with_permissions(
|
||||
org_id=org_id,
|
||||
update_data=settings,
|
||||
user_id=user_id,
|
||||
)
|
||||
return OrgDefaultsSettingsResponse.from_org(updated_org)
|
||||
except OrgNotFoundError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
@@ -309,21 +283,94 @@ async def update_org_llm_settings(
|
||||
)
|
||||
except OrgDatabaseError as e:
|
||||
logger.error(
|
||||
'Database error updating LLM settings',
|
||||
extra={'error': str(e)},
|
||||
'Database error updating organization defaults settings',
|
||||
extra={'user_id': user_id, 'org_id': str(org_id), 'error': str(e)},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to update LLM settings',
|
||||
detail='Failed to update organization defaults settings',
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
'Error updating organization LLM settings',
|
||||
extra={'error': str(e)},
|
||||
'Error updating organization defaults settings',
|
||||
extra={'user_id': user_id, 'org_id': str(org_id), 'error': str(e)},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to update LLM settings',
|
||||
detail='Failed to update organization defaults settings',
|
||||
)
|
||||
|
||||
|
||||
@org_router.get(
|
||||
'/llm',
|
||||
response_model=OrgDefaultsSettingsResponse,
|
||||
deprecated=True,
|
||||
)
|
||||
async def get_legacy_org_defaults_settings(
|
||||
user_id: str = Depends(require_permission(Permission.VIEW_LLM_SETTINGS)),
|
||||
) -> OrgDefaultsSettingsResponse:
|
||||
"""Get org-default settings through the deprecated ``/llm`` wrapper."""
|
||||
try:
|
||||
org = await OrgStore.get_current_org_from_keycloak_user_id(user_id)
|
||||
if not org:
|
||||
raise OrgNotFoundError('current')
|
||||
return await get_org_defaults_settings(org_id=org.id, user_id=user_id)
|
||||
except OrgNotFoundError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e),
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
'Error getting legacy organization defaults settings',
|
||||
extra={'user_id': user_id, 'error': str(e)},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to retrieve organization defaults settings',
|
||||
)
|
||||
|
||||
|
||||
@org_router.post(
|
||||
'/llm',
|
||||
response_model=OrgDefaultsSettingsResponse,
|
||||
deprecated=True,
|
||||
)
|
||||
async def update_legacy_org_defaults_settings(
|
||||
settings: OrgUpdate,
|
||||
user_id: str = Depends(require_permission(Permission.EDIT_LLM_SETTINGS)),
|
||||
) -> OrgDefaultsSettingsResponse:
|
||||
"""Update org-default settings through the deprecated ``/llm`` wrapper."""
|
||||
try:
|
||||
org = await OrgStore.get_current_org_from_keycloak_user_id(user_id)
|
||||
if not org:
|
||||
raise OrgNotFoundError('current')
|
||||
if not settings.has_updates():
|
||||
return OrgDefaultsSettingsResponse.from_org(org)
|
||||
return await update_org_defaults_settings(
|
||||
org_id=org.id,
|
||||
settings=settings,
|
||||
user_id=user_id,
|
||||
)
|
||||
except OrgNotFoundError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e),
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
'Error updating legacy organization defaults settings',
|
||||
extra={'user_id': user_id, 'error': str(e)},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to update organization defaults settings',
|
||||
)
|
||||
|
||||
|
||||
@@ -417,31 +464,17 @@ async def update_org_app_settings(
|
||||
)
|
||||
|
||||
|
||||
@org_router.get('/{org_id}', response_model=OrgResponse, status_code=status.HTTP_200_OK)
|
||||
@org_router.get(
|
||||
'/{org_id}',
|
||||
response_model=OrgResponse,
|
||||
status_code=status.HTTP_200_OK,
|
||||
deprecated=True,
|
||||
)
|
||||
async def get_org(
|
||||
org_id: UUID,
|
||||
user_id: str = Depends(require_permission(Permission.VIEW_ORG_SETTINGS)),
|
||||
) -> OrgResponse:
|
||||
"""Get organization details by ID.
|
||||
|
||||
This endpoint retrieves details for a specific organization. Access requires
|
||||
the VIEW_ORG_SETTINGS permission, which is granted to all organization members
|
||||
(member, admin, and owner roles).
|
||||
|
||||
Args:
|
||||
org_id: Organization ID (UUID)
|
||||
user_id: Authenticated user ID (injected by require_permission dependency)
|
||||
|
||||
Returns:
|
||||
OrgResponse: The organization details
|
||||
|
||||
Raises:
|
||||
HTTPException: 401 if user is not authenticated
|
||||
HTTPException: 403 if user lacks VIEW_ORG_SETTINGS permission
|
||||
HTTPException: 404 if organization not found
|
||||
HTTPException: 422 if org_id is not a valid UUID (handled by FastAPI)
|
||||
HTTPException: 500 if retrieval fails
|
||||
"""
|
||||
"""Get organization details by ID through the deprecated detail route."""
|
||||
logger.info(
|
||||
'Retrieving organization details',
|
||||
extra={
|
||||
@@ -451,15 +484,11 @@ async def get_org(
|
||||
)
|
||||
|
||||
try:
|
||||
# Use service layer to get organization with membership validation
|
||||
org = await OrgService.get_org_by_id(
|
||||
org_id=org_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
# Retrieve credits from LiteLLM
|
||||
credits = await OrgService.get_org_credits(user_id, org.id)
|
||||
|
||||
return OrgResponse.from_org(org, credits=credits, user_id=user_id)
|
||||
except OrgNotFoundError as e:
|
||||
raise HTTPException(
|
||||
|
||||
@@ -1,441 +0,0 @@
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import SecretStr
|
||||
from server.auth.token_manager import TokenManager
|
||||
from storage.user_store import UserStore
|
||||
from utils.identity import resolve_display_name
|
||||
|
||||
from openhands.app_server.utils.dependencies import get_dependencies
|
||||
from openhands.integrations.provider import (
|
||||
PROVIDER_TOKEN_TYPE,
|
||||
ProviderHandler,
|
||||
)
|
||||
from openhands.integrations.service_types import (
|
||||
Branch,
|
||||
PaginatedBranchesResponse,
|
||||
ProviderType,
|
||||
Repository,
|
||||
SuggestedTask,
|
||||
User,
|
||||
)
|
||||
from openhands.microagent.types import (
|
||||
MicroagentContentResponse,
|
||||
MicroagentResponse,
|
||||
)
|
||||
from openhands.server.routes.git import (
|
||||
get_repository_branches,
|
||||
get_repository_microagent_content,
|
||||
get_repository_microagents,
|
||||
get_suggested_tasks,
|
||||
get_user,
|
||||
get_user_installations,
|
||||
get_user_repositories,
|
||||
search_branches,
|
||||
search_repositories,
|
||||
)
|
||||
from openhands.server.user_auth import (
|
||||
get_access_token,
|
||||
get_provider_tokens,
|
||||
get_user_id,
|
||||
)
|
||||
|
||||
saas_user_router = APIRouter(prefix='/api/user', dependencies=get_dependencies())
|
||||
token_manager = TokenManager()
|
||||
|
||||
|
||||
@saas_user_router.get(
|
||||
'/installations',
|
||||
response_model=list[str],
|
||||
deprecated=True,
|
||||
description='Deprecated: Use `/api/v1/git/installations` instead.',
|
||||
)
|
||||
async def saas_get_user_installations(
|
||||
provider: ProviderType,
|
||||
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
|
||||
access_token: SecretStr | None = Depends(get_access_token),
|
||||
user_id: str | None = Depends(get_user_id),
|
||||
):
|
||||
if not provider_tokens:
|
||||
retval = await _check_idp(
|
||||
access_token=access_token,
|
||||
default_value=[],
|
||||
)
|
||||
if retval is not None:
|
||||
return retval
|
||||
|
||||
return await get_user_installations(
|
||||
provider=provider,
|
||||
provider_tokens=provider_tokens,
|
||||
access_token=access_token,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
|
||||
@saas_user_router.get('/git-organizations')
|
||||
async def saas_get_user_git_organizations(
|
||||
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
|
||||
access_token: SecretStr | None = Depends(get_access_token),
|
||||
user_id: str | None = Depends(get_user_id),
|
||||
):
|
||||
if not provider_tokens:
|
||||
retval = await _check_idp(
|
||||
access_token=access_token,
|
||||
default_value={},
|
||||
)
|
||||
if retval is not None:
|
||||
return retval
|
||||
# _check_idp returned None (tokens refreshed on Keycloak side),
|
||||
# but provider_tokens is still None for this request.
|
||||
return JSONResponse(
|
||||
content='Git provider token required.',
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
|
||||
client = ProviderHandler(
|
||||
provider_tokens=provider_tokens,
|
||||
external_auth_token=access_token,
|
||||
external_auth_id=user_id,
|
||||
)
|
||||
|
||||
# SaaS users sign in with one provider at a time
|
||||
provider = next(iter(provider_tokens))
|
||||
|
||||
if provider == ProviderType.GITHUB:
|
||||
orgs = await client.get_github_organizations()
|
||||
elif provider == ProviderType.GITLAB:
|
||||
orgs = await client.get_gitlab_groups()
|
||||
elif provider == ProviderType.BITBUCKET:
|
||||
orgs = await client.get_bitbucket_workspaces()
|
||||
else:
|
||||
return JSONResponse(
|
||||
content=f"Provider {provider.value} doesn't support git organizations",
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
return {
|
||||
'provider': provider.value,
|
||||
'organizations': orgs,
|
||||
}
|
||||
|
||||
|
||||
@saas_user_router.get(
|
||||
'/repositories',
|
||||
response_model=list[Repository],
|
||||
deprecated=True,
|
||||
description='Deprecated: Use `/api/v1/git/repositories` instead.',
|
||||
)
|
||||
async def saas_get_user_repositories(
|
||||
sort: str = 'pushed',
|
||||
selected_provider: ProviderType | None = None,
|
||||
page: int | None = None,
|
||||
per_page: int | None = None,
|
||||
installation_id: str | None = None,
|
||||
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
|
||||
access_token: SecretStr | None = Depends(get_access_token),
|
||||
user_id: str | None = Depends(get_user_id),
|
||||
) -> list[Repository] | JSONResponse:
|
||||
if not provider_tokens:
|
||||
retval = await _check_idp(
|
||||
access_token=access_token,
|
||||
default_value=[],
|
||||
)
|
||||
if retval is not None:
|
||||
return retval
|
||||
|
||||
return await get_user_repositories(
|
||||
sort=sort,
|
||||
selected_provider=selected_provider,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
installation_id=installation_id,
|
||||
provider_tokens=provider_tokens,
|
||||
access_token=access_token,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
|
||||
@saas_user_router.get('/info', response_model=User, deprecated=True)
|
||||
async def saas_get_user(
|
||||
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
|
||||
access_token: SecretStr | None = Depends(get_access_token),
|
||||
user_id: str | None = Depends(get_user_id),
|
||||
) -> User | JSONResponse:
|
||||
"""Get the current user git info. Use GET /api/v1/users/git-info instead"""
|
||||
if not provider_tokens:
|
||||
if not access_token:
|
||||
return JSONResponse(
|
||||
content='User is not authenticated.',
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
user_info = await token_manager.get_user_info(access_token.get_secret_value())
|
||||
# Prefer email from DB; fall back to Keycloak if not yet persisted
|
||||
email = user_info.email
|
||||
sub = user_info.sub
|
||||
if sub:
|
||||
db_user = await UserStore.get_user_by_id(sub)
|
||||
if db_user and db_user.email is not None:
|
||||
email = db_user.email
|
||||
|
||||
user_info_dict = user_info.model_dump(exclude_none=True)
|
||||
retval = await _check_idp(
|
||||
access_token=access_token,
|
||||
default_value=User(
|
||||
id=sub,
|
||||
login=user_info.preferred_username or '',
|
||||
avatar_url='',
|
||||
email=email,
|
||||
name=resolve_display_name(user_info_dict),
|
||||
company=user_info.company,
|
||||
),
|
||||
user_info=user_info_dict,
|
||||
)
|
||||
if retval is not None:
|
||||
return retval
|
||||
|
||||
return await get_user(
|
||||
provider_tokens=provider_tokens, access_token=access_token, user_id=user_id
|
||||
)
|
||||
|
||||
|
||||
@saas_user_router.get('/search/repositories', response_model=list[Repository])
|
||||
async def saas_search_repositories(
|
||||
query: str,
|
||||
per_page: int = 5,
|
||||
sort: str = 'stars',
|
||||
order: str = 'desc',
|
||||
selected_provider: ProviderType | None = None,
|
||||
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
|
||||
access_token: SecretStr | None = Depends(get_access_token),
|
||||
user_id: str | None = Depends(get_user_id),
|
||||
) -> list[Repository] | JSONResponse:
|
||||
if not provider_tokens:
|
||||
retval = await _check_idp(
|
||||
access_token=access_token,
|
||||
default_value=[],
|
||||
)
|
||||
if retval is not None:
|
||||
return retval
|
||||
|
||||
return await search_repositories(
|
||||
query=query,
|
||||
per_page=per_page,
|
||||
sort=sort,
|
||||
order=order,
|
||||
selected_provider=selected_provider,
|
||||
provider_tokens=provider_tokens,
|
||||
access_token=access_token,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
|
||||
@saas_user_router.get('/suggested-tasks', response_model=list[SuggestedTask])
|
||||
async def saas_get_suggested_tasks(
|
||||
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
|
||||
access_token: SecretStr | None = Depends(get_access_token),
|
||||
user_id: str | None = Depends(get_user_id),
|
||||
) -> list[SuggestedTask] | JSONResponse:
|
||||
"""Get suggested tasks for the authenticated user across their most recently pushed repositories.
|
||||
|
||||
Returns:
|
||||
- PRs owned by the user
|
||||
- Issues assigned to the user.
|
||||
"""
|
||||
if not provider_tokens:
|
||||
retval = await _check_idp(
|
||||
access_token=access_token,
|
||||
default_value=[],
|
||||
)
|
||||
if retval is not None:
|
||||
return retval
|
||||
|
||||
return await get_suggested_tasks(
|
||||
provider_tokens=provider_tokens, access_token=access_token, user_id=user_id
|
||||
)
|
||||
|
||||
|
||||
@saas_user_router.get('/repository/branches', response_model=PaginatedBranchesResponse)
|
||||
async def saas_get_repository_branches(
|
||||
repository: str,
|
||||
page: int = 1,
|
||||
per_page: int = 30,
|
||||
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
|
||||
access_token: SecretStr | None = Depends(get_access_token),
|
||||
user_id: str | None = Depends(get_user_id),
|
||||
) -> PaginatedBranchesResponse | JSONResponse:
|
||||
"""Get branches for a repository.
|
||||
|
||||
Args:
|
||||
repository: The repository name in the format 'owner/repo'
|
||||
|
||||
Returns:
|
||||
A list of branches for the repository
|
||||
"""
|
||||
if not provider_tokens:
|
||||
retval = await _check_idp(
|
||||
access_token=access_token,
|
||||
default_value=[],
|
||||
)
|
||||
if retval is not None:
|
||||
return retval
|
||||
|
||||
return await get_repository_branches(
|
||||
repository=repository,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
provider_tokens=provider_tokens,
|
||||
access_token=access_token,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
|
||||
@saas_user_router.get('/search/branches', response_model=list[Branch])
|
||||
async def saas_search_branches(
|
||||
repository: str,
|
||||
query: str,
|
||||
per_page: int = 30,
|
||||
selected_provider: ProviderType | None = None,
|
||||
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
|
||||
access_token: SecretStr | None = Depends(get_access_token),
|
||||
user_id: str | None = Depends(get_user_id),
|
||||
) -> list[Branch] | JSONResponse:
|
||||
if not provider_tokens:
|
||||
retval = await _check_idp(
|
||||
access_token=access_token,
|
||||
default_value=[],
|
||||
)
|
||||
if retval is not None:
|
||||
return retval
|
||||
|
||||
return await search_branches(
|
||||
repository=repository,
|
||||
query=query,
|
||||
per_page=per_page,
|
||||
selected_provider=selected_provider,
|
||||
provider_tokens=provider_tokens,
|
||||
access_token=access_token,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
|
||||
@saas_user_router.get(
|
||||
'/repository/{repository_name:path}/microagents',
|
||||
response_model=list[MicroagentResponse],
|
||||
)
|
||||
async def saas_get_repository_microagents(
|
||||
repository_name: str,
|
||||
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
|
||||
access_token: SecretStr | None = Depends(get_access_token),
|
||||
user_id: str | None = Depends(get_user_id),
|
||||
) -> list[MicroagentResponse] | JSONResponse:
|
||||
"""Scan the microagents directory of a repository and return the list of microagents.
|
||||
|
||||
The microagents directory location depends on the git provider and actual repository name:
|
||||
- If git provider is not GitLab and actual repository name is ".openhands": scans "microagents" folder
|
||||
- If git provider is GitLab and actual repository name is "openhands-config": scans "microagents" folder
|
||||
- Otherwise: scans ".openhands/microagents" folder
|
||||
|
||||
Note: This API returns microagent metadata without content for performance.
|
||||
Use the separate content API to fetch individual microagent content.
|
||||
|
||||
Args:
|
||||
repository_name: Repository name in the format 'owner/repo' or 'domain/owner/repo'
|
||||
provider_tokens: Provider tokens for authentication
|
||||
access_token: Access token for external authentication
|
||||
user_id: User ID for authentication
|
||||
|
||||
Returns:
|
||||
List of microagents found in the repository's microagents directory (without content)
|
||||
"""
|
||||
if not provider_tokens:
|
||||
retval = await _check_idp(
|
||||
access_token=access_token,
|
||||
default_value=[],
|
||||
)
|
||||
if retval is not None:
|
||||
return retval
|
||||
|
||||
return await get_repository_microagents(
|
||||
repository_name=repository_name,
|
||||
provider_tokens=provider_tokens,
|
||||
access_token=access_token,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
|
||||
@saas_user_router.get(
|
||||
'/repository/{repository_name:path}/microagents/content',
|
||||
response_model=MicroagentContentResponse,
|
||||
)
|
||||
async def saas_get_repository_microagent_content(
|
||||
repository_name: str,
|
||||
file_path: str = Query(
|
||||
..., description='Path to the microagent file within the repository'
|
||||
),
|
||||
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
|
||||
access_token: SecretStr | None = Depends(get_access_token),
|
||||
user_id: str | None = Depends(get_user_id),
|
||||
) -> MicroagentContentResponse | JSONResponse:
|
||||
"""Fetch the content of a specific microagent file from a repository.
|
||||
|
||||
Args:
|
||||
repository_name: Repository name in the format 'owner/repo' or 'domain/owner/repo'
|
||||
file_path: Query parameter - Path to the microagent file within the repository
|
||||
provider_tokens: Provider tokens for authentication
|
||||
access_token: Access token for external authentication
|
||||
user_id: User ID for authentication
|
||||
|
||||
Returns:
|
||||
Microagent file content and metadata
|
||||
|
||||
Example:
|
||||
GET /api/user/repository/owner/repo/microagents/content?file_path=.openhands/microagents/my-agent.md
|
||||
"""
|
||||
if not provider_tokens:
|
||||
retval = await _check_idp(
|
||||
access_token=access_token,
|
||||
default_value=MicroagentContentResponse(content='', path=''),
|
||||
)
|
||||
if retval is not None:
|
||||
return retval
|
||||
|
||||
return await get_repository_microagent_content(
|
||||
repository_name=repository_name,
|
||||
file_path=file_path,
|
||||
provider_tokens=provider_tokens,
|
||||
access_token=access_token,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
|
||||
async def _check_idp(
|
||||
access_token: SecretStr | None,
|
||||
default_value: Any,
|
||||
user_info: dict | None = None,
|
||||
):
|
||||
if not access_token:
|
||||
return JSONResponse(
|
||||
content='User is not authenticated.',
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
if user_info is None:
|
||||
user_info_model = await token_manager.get_user_info(
|
||||
access_token.get_secret_value()
|
||||
)
|
||||
user_info = user_info_model.model_dump(exclude_none=True)
|
||||
idp: str | None = user_info.get('identity_provider')
|
||||
if not idp:
|
||||
return JSONResponse(
|
||||
content='IDP not found.',
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
if ':' in idp:
|
||||
idp, _ = idp.rsplit(':', 1)
|
||||
|
||||
# Will return empty dict if IDP doesn't support provider tokens
|
||||
if not await token_manager.get_idp_tokens_from_keycloak(
|
||||
access_token.get_secret_value(), ProviderType(idp)
|
||||
):
|
||||
return default_value
|
||||
return None
|
||||
@@ -5,17 +5,24 @@ user endpoints with organization context (org_id, org_name, role, permissions).
|
||||
"""
|
||||
|
||||
import logging
|
||||
from types import MappingProxyType
|
||||
from typing import Any, cast
|
||||
|
||||
from fastapi import APIRouter, FastAPI, Header, HTTPException, Query, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from server.auth.saas_user_auth import SaasUserAuth
|
||||
from server.models.user_models import SaasUserInfo
|
||||
from server.models.user_models import GitOrganizationsResponse, SaasUserInfo
|
||||
|
||||
from openhands.app_server.config import depends_user_context
|
||||
from openhands.app_server.config import (
|
||||
depends_user_context,
|
||||
resolve_provider_llm_base_url,
|
||||
)
|
||||
from openhands.app_server.sandbox.session_auth import validate_session_key_ownership
|
||||
from openhands.app_server.user.auth_user_context import AuthUserContext
|
||||
from openhands.app_server.user.user_context import UserContext
|
||||
from openhands.app_server.utils.dependencies import get_dependencies
|
||||
from openhands.integrations.provider import ProviderHandler
|
||||
from openhands.integrations.service_types import ProviderType
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -25,6 +32,30 @@ saas_users_v1_router = APIRouter(
|
||||
user_dependency = depends_user_context()
|
||||
|
||||
|
||||
def _inject_sdk_compat_fields(
|
||||
content: dict[str, Any], *, include_api_key: bool
|
||||
) -> None:
|
||||
"""Inject flat top-level convenience fields for the SDK.
|
||||
|
||||
The SDK's ``get_llm()`` and ``get_mcp_config()`` read ``llm_model``,
|
||||
``llm_api_key``, ``llm_base_url``, and ``mcp_config`` from the top
|
||||
level of the ``/api/v1/users/me`` response. These values live inside
|
||||
the nested ``agent_settings`` structure, so we mirror them at the top
|
||||
level for backward compatibility.
|
||||
|
||||
The canonical representation is ``agent_settings``; these flat fields
|
||||
exist solely for SDK backward compatibility.
|
||||
"""
|
||||
agent_settings = content.get('agent_settings') or {}
|
||||
llm = agent_settings.get('llm') or {}
|
||||
model = llm.get('model')
|
||||
content['llm_model'] = model
|
||||
content['llm_base_url'] = resolve_provider_llm_base_url(model, llm.get('base_url'))
|
||||
if include_api_key:
|
||||
content['llm_api_key'] = llm.get('api_key')
|
||||
content['mcp_config'] = agent_settings.get('mcp_config')
|
||||
|
||||
|
||||
@saas_users_v1_router.get('/me')
|
||||
async def get_current_user_saas(
|
||||
user_context: UserContext = user_dependency,
|
||||
@@ -63,10 +94,52 @@ async def get_current_user_saas(
|
||||
|
||||
if expose_secrets:
|
||||
await validate_session_key_ownership(user_context, x_session_api_key)
|
||||
return JSONResponse( # type: ignore[return-value]
|
||||
content=user_info.model_dump(mode='json', context={'expose_secrets': True})
|
||||
content = user_info.model_dump(mode='json', context={'expose_secrets': True})
|
||||
_inject_sdk_compat_fields(content, include_api_key=True)
|
||||
return JSONResponse(content=content) # type: ignore[return-value]
|
||||
|
||||
content = user_info.model_dump(mode='json')
|
||||
_inject_sdk_compat_fields(content, include_api_key=False)
|
||||
return JSONResponse(content=content) # type: ignore[return-value]
|
||||
|
||||
|
||||
@saas_users_v1_router.get('/git-organizations')
|
||||
async def get_current_user_git_organizations(
|
||||
user_context: UserContext = user_dependency,
|
||||
) -> GitOrganizationsResponse:
|
||||
"""Return the Git organizations, groups, or workspaces the user belongs to
|
||||
on their active provider.
|
||||
|
||||
In SAAS mode users sign in with one provider at a time, so the response
|
||||
reflects that single provider.
|
||||
"""
|
||||
provider_tokens = await user_context.get_provider_tokens()
|
||||
if not provider_tokens:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail='Git provider token required.',
|
||||
)
|
||||
return user_info
|
||||
|
||||
user_id = await user_context.get_user_id()
|
||||
client = ProviderHandler(
|
||||
provider_tokens=MappingProxyType(provider_tokens), # type: ignore[arg-type]
|
||||
external_auth_id=user_id,
|
||||
)
|
||||
|
||||
provider = cast(ProviderType, next(iter(provider_tokens)))
|
||||
if provider == ProviderType.GITHUB:
|
||||
orgs = await client.get_github_organizations()
|
||||
elif provider == ProviderType.GITLAB:
|
||||
orgs = await client.get_gitlab_groups()
|
||||
elif provider == ProviderType.BITBUCKET:
|
||||
orgs = await client.get_bitbucket_workspaces()
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Provider {provider.value} doesn't support git organizations",
|
||||
)
|
||||
|
||||
return GitOrganizationsResponse(provider=provider, organizations=orgs)
|
||||
|
||||
|
||||
async def _get_org_info_from_context(user_context: UserContext) -> dict | None:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
517
enterprise/server/services/automation_event_service.py
Normal file
517
enterprise/server/services/automation_event_service.py
Normal file
@@ -0,0 +1,517 @@
|
||||
"""
|
||||
Service for forwarding Git provider webhook events to the automation service.
|
||||
|
||||
This service is optimized for high-traffic scenarios:
|
||||
1. Resolves Git org → OpenHands org_id (via cached OrgGitClaim lookup)
|
||||
2. For personal repos, resolves to personal org (via cached provider→Keycloak mapping)
|
||||
3. Forwards minimal payload to automation service (just org_id + payload)
|
||||
4. Access control checks are deferred to automation execution time
|
||||
|
||||
Supports multiple Git providers (GitHub, GitLab, Bitbucket, etc.).
|
||||
|
||||
The lazy access control approach means:
|
||||
- Most webhooks only do cached lookups + HTTP forward
|
||||
- Membership checks only happen when an automation actually matches
|
||||
|
||||
Security notes:
|
||||
- Uses AUTOMATION_WEBHOOK_SECRET (not provider webhook secret) for signing
|
||||
- Negative results are cached to prevent DoS via repeated lookups
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import aiohttp
|
||||
from integrations.resolver_org_router import resolve_org_for_repo
|
||||
from server.auth.constants import (
|
||||
AUTOMATION_SERVICE_TIMEOUT,
|
||||
AUTOMATION_SERVICE_URL,
|
||||
AUTOMATION_WEBHOOK_SECRET,
|
||||
)
|
||||
from server.auth.token_manager import TokenManager
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.provider import ProviderType
|
||||
from openhands.server.shared import sio
|
||||
|
||||
# Cache TTL constants
|
||||
ORG_CLAIM_CACHE_TTL_SECONDS = 3600 # 1 hour for org claims (rarely change)
|
||||
USER_ID_CACHE_TTL_SECONDS = 86400 # 24 hours for user ID mappings (never change)
|
||||
|
||||
# Cache key prefixes (provider is appended dynamically)
|
||||
ORG_CLAIM_CACHE_PREFIX = 'automation:org_claim'
|
||||
USER_ID_CACHE_PREFIX = 'automation:idp_to_kc_user'
|
||||
|
||||
|
||||
@dataclass
|
||||
class OrgContext:
|
||||
"""Context for the resolved organization."""
|
||||
|
||||
org_id: UUID
|
||||
git_org: str
|
||||
|
||||
|
||||
class AutomationEventService:
|
||||
"""
|
||||
Service for forwarding webhook events to the automation service.
|
||||
|
||||
Optimized for high traffic with:
|
||||
- Redis caching for org claim lookups (1 hour TTL)
|
||||
- Redis caching for provider→Keycloak user ID mappings (24 hour TTL)
|
||||
- Lazy access control (membership checks deferred to execution time)
|
||||
|
||||
Supports multiple Git providers (GitHub, GitLab, Bitbucket, etc.).
|
||||
"""
|
||||
|
||||
def __init__(self, token_manager: TokenManager):
|
||||
from server.auth.constants import AUTOMATION_EVENT_FORWARDING_ENABLED
|
||||
|
||||
self.token_manager = token_manager
|
||||
|
||||
# Fail fast if forwarding is enabled but misconfigured
|
||||
if AUTOMATION_EVENT_FORWARDING_ENABLED:
|
||||
if not AUTOMATION_SERVICE_URL:
|
||||
raise ValueError(
|
||||
'AUTOMATION_EVENT_FORWARDING_ENABLED=true but '
|
||||
'AUTOMATION_SERVICE_URL is not configured'
|
||||
)
|
||||
if not AUTOMATION_WEBHOOK_SECRET:
|
||||
raise ValueError(
|
||||
'AUTOMATION_EVENT_FORWARDING_ENABLED=true but '
|
||||
'AUTOMATION_WEBHOOK_SECRET is not configured'
|
||||
)
|
||||
|
||||
async def forward_event(
|
||||
self,
|
||||
provider: ProviderType,
|
||||
payload: dict[str, Any],
|
||||
installation_id: int | str,
|
||||
) -> None:
|
||||
"""
|
||||
Forward a Git provider webhook event to the automation service.
|
||||
|
||||
This is designed to be called as a fire-and-forget background task.
|
||||
The forward path is optimized for speed - only org resolution is done here.
|
||||
Access control checks are deferred to automation execution time.
|
||||
|
||||
Args:
|
||||
provider: The Git provider type (e.g., GITHUB, GITLAB, BITBUCKET)
|
||||
payload: The raw webhook payload from the provider
|
||||
installation_id: The provider's installation/webhook ID
|
||||
"""
|
||||
org_id: UUID | None = None
|
||||
try:
|
||||
# Resolve org context (org_id and git_org name) - uses Redis cache
|
||||
org_context = await self._resolve_org_context(provider, payload)
|
||||
if not org_context:
|
||||
return
|
||||
|
||||
org_id = org_context.org_id
|
||||
|
||||
# Build minimal payload and forward immediately
|
||||
# Access control is NOT computed here - it's deferred to execution time
|
||||
event_payload = self._build_event_payload(org_context, payload)
|
||||
await self._send_to_automation_service(provider, org_id, event_payload)
|
||||
|
||||
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
|
||||
# Network errors are expected and recoverable
|
||||
logger.error(
|
||||
f'[AutomationEventService] Network error forwarding '
|
||||
f'{provider.value} event (org_id={org_id}): {e}',
|
||||
exc_info=True,
|
||||
extra={'installation_id': installation_id},
|
||||
)
|
||||
except Exception as e:
|
||||
# Log unexpected errors. Note: This is a background task, so exceptions
|
||||
# won't surface to the HTTP caller - they're logged for debugging only.
|
||||
logger.error(
|
||||
f'[AutomationEventService] Unexpected error forwarding '
|
||||
f'{provider.value} event (org_id={org_id}): {e}',
|
||||
exc_info=True,
|
||||
extra={'installation_id': installation_id},
|
||||
)
|
||||
# Don't re-raise in background task - just log for debugging
|
||||
|
||||
async def _resolve_org_context(
|
||||
self, provider: ProviderType, payload: dict[str, Any]
|
||||
) -> OrgContext | None:
|
||||
"""
|
||||
Resolve the organization context from the webhook payload.
|
||||
|
||||
Uses Redis caching for both org claims and user ID mappings.
|
||||
Returns None if the org cannot be resolved (not claimed, no personal org).
|
||||
|
||||
Args:
|
||||
provider: The Git provider type
|
||||
payload: The webhook payload from the provider
|
||||
"""
|
||||
git_org_name, owner_type, owner_id = self._extract_owner_info(provider, payload)
|
||||
|
||||
if not git_org_name:
|
||||
logger.warning(
|
||||
f'[AutomationEventService] No repository owner in '
|
||||
f'{provider.value} payload, skipping'
|
||||
)
|
||||
return None
|
||||
|
||||
# Try to resolve via OrgGitClaim
|
||||
org_id = await self._resolve_git_org(provider, git_org_name)
|
||||
|
||||
# Fallback for personal repos (owner_type indicates individual user)
|
||||
if not org_id and owner_type == 'User':
|
||||
org_id = await self._resolve_personal_org(provider, owner_id)
|
||||
if org_id:
|
||||
logger.info(
|
||||
f'[AutomationEventService] Resolved personal repo owner '
|
||||
f'{git_org_name} to personal org {org_id} ({provider.value})'
|
||||
)
|
||||
|
||||
if not org_id:
|
||||
logger.warning(
|
||||
f'[AutomationEventService] {provider.value} org {git_org_name} '
|
||||
f'not claimed and no personal org found, skipping'
|
||||
)
|
||||
return None
|
||||
|
||||
return OrgContext(org_id=org_id, git_org=git_org_name)
|
||||
|
||||
def _extract_owner_info(
|
||||
self, provider: ProviderType, payload: dict[str, Any]
|
||||
) -> tuple[str | None, str | None, int | None]:
|
||||
"""
|
||||
Extract owner information from the webhook payload.
|
||||
|
||||
Different providers structure their payloads differently, so this method
|
||||
normalizes the extraction.
|
||||
|
||||
Args:
|
||||
provider: The Git provider type
|
||||
payload: The webhook payload
|
||||
|
||||
Returns:
|
||||
Tuple of (git_org_name, owner_type, owner_id)
|
||||
- git_org_name: The organization/user name that owns the repo
|
||||
- owner_type: 'User' or 'Organization' (or provider-specific equivalent)
|
||||
- owner_id: The numeric ID of the owner (for personal org resolution)
|
||||
"""
|
||||
# Compare using .value to handle different ProviderType enum instances
|
||||
# (e.g., test mocks may use a different enum class with the same values)
|
||||
if provider == ProviderType.GITHUB:
|
||||
repo = payload.get('repository', {})
|
||||
owner = repo.get('owner', {})
|
||||
return owner.get('login'), owner.get('type'), owner.get('id')
|
||||
|
||||
logger.warning(f'Unsupported provider ({provider.value})')
|
||||
return None, None, None
|
||||
|
||||
def _build_event_payload(
|
||||
self,
|
||||
org_context: OrgContext,
|
||||
payload: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Build the minimal event payload to forward to the automation service.
|
||||
|
||||
Access control is NOT included here - it's deferred to execution time.
|
||||
This keeps the forward path fast for high-traffic scenarios.
|
||||
"""
|
||||
return {
|
||||
'organization': {
|
||||
'git_org': org_context.git_org,
|
||||
'openhands_org_id': str(org_context.org_id),
|
||||
},
|
||||
'payload': payload,
|
||||
}
|
||||
|
||||
# =========================================================================
|
||||
# Cached Org Resolution Methods
|
||||
# =========================================================================
|
||||
|
||||
async def _resolve_git_org(
|
||||
self, provider: ProviderType, git_org_name: str
|
||||
) -> UUID | None:
|
||||
"""
|
||||
Resolve a Git organization name to an OpenHands org_id.
|
||||
|
||||
Uses Redis caching with 1-hour TTL. Caches both positive and negative
|
||||
results to avoid repeated DB queries for unclaimed orgs.
|
||||
|
||||
Args:
|
||||
provider: The Git provider type
|
||||
git_org_name: The organization/user name from the provider
|
||||
|
||||
Note: Org names are normalized to lowercase for both cache keys and
|
||||
DB queries. This matches the OrgGitClaim schema which stores
|
||||
git_organization as lowercase.
|
||||
"""
|
||||
normalized_org = git_org_name.lower()
|
||||
cache_key = f'{ORG_CLAIM_CACHE_PREFIX}:{provider.value}:{normalized_org}'
|
||||
|
||||
# Check cache first
|
||||
cached = await self._get_cached_value(cache_key)
|
||||
if cached is not None:
|
||||
if cached == 'none':
|
||||
logger.debug(
|
||||
f'[AutomationEventService] Cache hit (negative): '
|
||||
f'{provider.value} org {git_org_name} not claimed'
|
||||
)
|
||||
return None
|
||||
logger.debug(
|
||||
f'[AutomationEventService] Cache hit: '
|
||||
f'{provider.value} org {git_org_name} -> {cached}'
|
||||
)
|
||||
return UUID(cached)
|
||||
|
||||
# Cache miss - use resolve_org_for_repo without user_id (no membership check)
|
||||
# Construct a minimal repo name since resolve_org_for_repo extracts the org
|
||||
org_id = await resolve_org_for_repo(
|
||||
provider=provider.value,
|
||||
full_repo_name=f'{normalized_org}/',
|
||||
)
|
||||
|
||||
# Cache the result (including negative results)
|
||||
if org_id:
|
||||
await self._set_cached_value(
|
||||
cache_key, str(org_id), ORG_CLAIM_CACHE_TTL_SECONDS
|
||||
)
|
||||
return org_id
|
||||
else:
|
||||
# Cache negative result to avoid repeated DB queries
|
||||
await self._set_cached_value(cache_key, 'none', ORG_CLAIM_CACHE_TTL_SECONDS)
|
||||
return None
|
||||
|
||||
async def _resolve_personal_org(
|
||||
self, provider: ProviderType, provider_user_id: int | str | None
|
||||
) -> UUID | None:
|
||||
"""
|
||||
Resolve a provider user to their personal OpenHands org.
|
||||
|
||||
For personal repos (owner type is 'User'), the OpenHands org_id
|
||||
is the user's keycloak user ID. This allows users to set up
|
||||
automations on their personal repos without needing an OrgGitClaim.
|
||||
|
||||
Uses Redis caching for the provider→Keycloak user ID mapping (24h TTL).
|
||||
|
||||
Args:
|
||||
provider: The Git provider type
|
||||
provider_user_id: The user ID from the provider (numeric or string UUID)
|
||||
"""
|
||||
if not provider_user_id:
|
||||
return None
|
||||
|
||||
keycloak_id = await self._get_keycloak_user_id_cached(
|
||||
provider, provider_user_id
|
||||
)
|
||||
if keycloak_id:
|
||||
return UUID(keycloak_id)
|
||||
return None
|
||||
|
||||
async def _get_keycloak_user_id_cached(
|
||||
self, provider: ProviderType, provider_user_id: int | str
|
||||
) -> str | None:
|
||||
"""
|
||||
Convert a provider user ID to a Keycloak user ID.
|
||||
|
||||
Uses Redis caching with 24-hour TTL since this mapping never changes.
|
||||
Caches negative results to avoid repeated Keycloak queries.
|
||||
|
||||
Args:
|
||||
provider: The Git provider type
|
||||
provider_user_id: The user ID from the provider
|
||||
"""
|
||||
cache_key = f'{USER_ID_CACHE_PREFIX}:{provider.value}:{provider_user_id}'
|
||||
|
||||
# Check cache first
|
||||
cached = await self._get_cached_value(cache_key)
|
||||
if cached is not None:
|
||||
if cached == 'none':
|
||||
logger.debug(
|
||||
f'[AutomationEventService] Cache hit (negative): '
|
||||
f'{provider.value} user {provider_user_id} not in Keycloak'
|
||||
)
|
||||
return None
|
||||
logger.debug(
|
||||
f'[AutomationEventService] Cache hit: '
|
||||
f'{provider.value} user {provider_user_id} -> Keycloak {cached}'
|
||||
)
|
||||
return cached
|
||||
|
||||
# Cache miss - query Keycloak
|
||||
try:
|
||||
keycloak_id = await self.token_manager.get_user_id_from_idp_user_id(
|
||||
str(provider_user_id), provider
|
||||
)
|
||||
|
||||
# Cache the result (including negative results)
|
||||
if keycloak_id:
|
||||
await self._set_cached_value(
|
||||
cache_key, keycloak_id, USER_ID_CACHE_TTL_SECONDS
|
||||
)
|
||||
else:
|
||||
# Cache negative result to prevent repeated Keycloak queries
|
||||
await self._set_cached_value(
|
||||
cache_key, 'none', USER_ID_CACHE_TTL_SECONDS
|
||||
)
|
||||
|
||||
return keycloak_id
|
||||
except Exception as e:
|
||||
# Log at warning level to surface programmer errors and API issues
|
||||
logger.warning(
|
||||
f'[AutomationEventService] Failed to get keycloak ID for '
|
||||
f'{provider.value} user {provider_user_id}: {e}'
|
||||
)
|
||||
return None
|
||||
|
||||
# =========================================================================
|
||||
# Generic Redis Cache Helpers
|
||||
# =========================================================================
|
||||
|
||||
async def _get_cached_value(self, cache_key: str) -> str | None:
|
||||
"""
|
||||
Get a cached value from Redis.
|
||||
|
||||
Returns the cached string value, or None if not cached or Redis unavailable.
|
||||
Falls back to DB/API queries if Redis is unavailable (graceful degradation).
|
||||
|
||||
Warning: When Redis is unavailable, every webhook will hit the DB directly.
|
||||
Monitor logs for 'Redis unavailable' warnings to detect degradation.
|
||||
"""
|
||||
try:
|
||||
redis = getattr(sio.manager, 'redis', None)
|
||||
if not redis:
|
||||
# Log at warning level - this is a significant degradation that
|
||||
# will cause DB load. Monitor these logs for alerting.
|
||||
logger.warning(
|
||||
'[AutomationEventService] Redis unavailable for cache read, '
|
||||
'falling back to direct DB queries (this will increase DB load)'
|
||||
)
|
||||
return None
|
||||
|
||||
cached = await redis.get(cache_key)
|
||||
if cached is None:
|
||||
return None
|
||||
|
||||
# Redis returns bytes, decode to string
|
||||
return cached.decode('utf-8') if isinstance(cached, bytes) else cached
|
||||
except Exception as e:
|
||||
# Log at warning level - cache errors cause DB fallback
|
||||
logger.warning(
|
||||
f'[AutomationEventService] Redis cache read error '
|
||||
f'(falling back to DB): {e}'
|
||||
)
|
||||
return None
|
||||
|
||||
async def _set_cached_value(
|
||||
self, cache_key: str, value: str, ttl_seconds: int
|
||||
) -> None:
|
||||
"""
|
||||
Set a cached value in Redis with TTL.
|
||||
|
||||
Fails silently if Redis is unavailable (graceful degradation).
|
||||
"""
|
||||
try:
|
||||
redis = getattr(sio.manager, 'redis', None)
|
||||
if not redis:
|
||||
# Silent failure - read path already logs the warning
|
||||
return
|
||||
|
||||
await redis.setex(cache_key, ttl_seconds, value)
|
||||
except Exception as e:
|
||||
# Log at warning level for visibility
|
||||
logger.warning(f'[AutomationEventService] Redis cache write error: {e}')
|
||||
|
||||
def _sign_payload(self, payload_bytes: bytes) -> str:
|
||||
"""
|
||||
Sign a payload using the dedicated automation shared secret.
|
||||
|
||||
Uses AUTOMATION_WEBHOOK_SECRET (not GitHub webhook secret) to maintain
|
||||
separate trust boundaries between GitHub webhooks and internal services.
|
||||
|
||||
Returns the signature in the format 'sha256=<hex_digest>'.
|
||||
"""
|
||||
signature = hmac.new(
|
||||
AUTOMATION_WEBHOOK_SECRET.encode('utf-8'),
|
||||
msg=payload_bytes,
|
||||
digestmod=hashlib.sha256,
|
||||
).hexdigest()
|
||||
return f'sha256={signature}'
|
||||
|
||||
async def _send_to_automation_service(
|
||||
self,
|
||||
provider: ProviderType,
|
||||
org_id: UUID,
|
||||
payload: dict[str, Any],
|
||||
) -> None:
|
||||
"""
|
||||
Send the normalized payload to the automation service.
|
||||
|
||||
The payload is signed using AUTOMATION_WEBHOOK_SECRET so the
|
||||
automation service can verify it came from the OpenHands server.
|
||||
|
||||
Args:
|
||||
provider: The Git provider type
|
||||
org_id: The OpenHands organization ID
|
||||
payload: The event payload to send
|
||||
"""
|
||||
if not AUTOMATION_SERVICE_URL:
|
||||
logger.warning(
|
||||
'[AutomationEventService] AUTOMATION_SERVICE_URL not configured'
|
||||
)
|
||||
return
|
||||
|
||||
# Build endpoint URL. AUTOMATION_SERVICE_URL may include path segments
|
||||
# (e.g., https://example.com/api/automation), so we strip trailing slash
|
||||
# and append our path. The provider is included in the URL path.
|
||||
base_url = AUTOMATION_SERVICE_URL.rstrip('/')
|
||||
url = f'{base_url}/v1/events/{org_id}/{provider.value}'
|
||||
|
||||
# Serialize payload to JSON bytes for signing
|
||||
payload_bytes = json.dumps(payload, separators=(',', ':')).encode('utf-8')
|
||||
signature = self._sign_payload(payload_bytes)
|
||||
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Hub-Signature-256': signature,
|
||||
}
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
url,
|
||||
data=payload_bytes,
|
||||
headers=headers,
|
||||
timeout=aiohttp.ClientTimeout(total=AUTOMATION_SERVICE_TIMEOUT),
|
||||
) as resp:
|
||||
if resp.status >= 400:
|
||||
# Try JSON first (expected interface), fall back to text
|
||||
# for infrastructure errors (502/503 from load balancer)
|
||||
try:
|
||||
body = await resp.json()
|
||||
except (aiohttp.ContentTypeError, ValueError):
|
||||
body = await resp.text()
|
||||
logger.warning(
|
||||
f'[AutomationEventService] Automation service returned '
|
||||
f'{resp.status} for {provider.value} org {org_id}: {body}'
|
||||
)
|
||||
else:
|
||||
data = await resp.json()
|
||||
matched = data.get('matched', 0)
|
||||
logger.info(
|
||||
f'[AutomationEventService] Forwarded {provider.value} '
|
||||
f'event to org {org_id}: {matched} automations matched'
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(
|
||||
f'[AutomationEventService] Timeout ({AUTOMATION_SERVICE_TIMEOUT}s) '
|
||||
f'forwarding {provider.value} event to automation service'
|
||||
)
|
||||
except aiohttp.ClientError as e:
|
||||
logger.warning(
|
||||
f'[AutomationEventService] HTTP error forwarding '
|
||||
f'{provider.value} event to automation service: {e}'
|
||||
)
|
||||
@@ -365,15 +365,17 @@ class OrgInvitationService:
|
||||
'Failed to set up organization access. Please try again.'
|
||||
)
|
||||
|
||||
# Step 4.5: Fetch organization to get its LLM settings
|
||||
# Step 4.5: Ensure the organization still exists before adding membership
|
||||
org = await OrgStore.get_org_by_id(invitation.org_id)
|
||||
if not org:
|
||||
raise InvitationInvalidError('Organization not found')
|
||||
|
||||
# Step 5: Add user to organization with inherited org LLM settings
|
||||
# Get the llm_api_key as string (it's SecretStr | None in Settings)
|
||||
# Step 5: Add user to organization. New members start with no
|
||||
# personal agent-setting overrides so future org default changes
|
||||
# continue to flow through automatically.
|
||||
llm_api_key_secret = settings.agent_settings.llm.api_key
|
||||
llm_api_key = (
|
||||
settings.llm_api_key.get_secret_value() if settings.llm_api_key else ''
|
||||
llm_api_key_secret.get_secret_value() if llm_api_key_secret else ''
|
||||
)
|
||||
|
||||
await OrgMemberStore.add_user_to_org(
|
||||
@@ -382,9 +384,8 @@ class OrgInvitationService:
|
||||
role_id=invitation.role_id,
|
||||
llm_api_key=llm_api_key,
|
||||
status='active',
|
||||
llm_model=org.default_llm_model,
|
||||
llm_base_url=org.default_llm_base_url,
|
||||
max_iterations=org.default_max_iterations,
|
||||
agent_settings_diff={},
|
||||
conversation_settings_diff={},
|
||||
)
|
||||
|
||||
# Step 6: Mark invitation as accepted
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
"""Service class for managing organization LLM settings.
|
||||
|
||||
Separates business logic from route handlers.
|
||||
Uses dependency injection for db_session and user_context.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from fastapi import Request
|
||||
from server.routes.org_models import (
|
||||
OrgLLMSettingsResponse,
|
||||
OrgLLMSettingsUpdate,
|
||||
OrgNotFoundError,
|
||||
)
|
||||
from storage.org_llm_settings_store import OrgLLMSettingsStore
|
||||
|
||||
from openhands.app_server.services.injector import Injector, InjectorState
|
||||
from openhands.app_server.user.user_context import UserContext
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
|
||||
@dataclass
|
||||
class OrgLLMSettingsService:
|
||||
"""Service for org LLM settings with injected dependencies."""
|
||||
|
||||
store: OrgLLMSettingsStore
|
||||
user_context: UserContext
|
||||
|
||||
async def get_org_llm_settings(self) -> OrgLLMSettingsResponse:
|
||||
"""Get LLM settings for user's current organization.
|
||||
|
||||
User ID is obtained from the injected user_context.
|
||||
|
||||
Returns:
|
||||
OrgLLMSettingsResponse: The organization's LLM settings
|
||||
|
||||
Raises:
|
||||
ValueError: If user is not authenticated
|
||||
OrgNotFoundError: If current organization not found
|
||||
"""
|
||||
user_id = await self.user_context.get_user_id()
|
||||
if not user_id:
|
||||
raise ValueError('User is not authenticated')
|
||||
|
||||
logger.info(
|
||||
'Getting organization LLM settings',
|
||||
extra={'user_id': user_id},
|
||||
)
|
||||
|
||||
org = await self.store.get_current_org_by_user_id(user_id)
|
||||
|
||||
if not org:
|
||||
raise OrgNotFoundError('No current organization')
|
||||
|
||||
return OrgLLMSettingsResponse.from_org(org)
|
||||
|
||||
async def update_org_llm_settings(
|
||||
self,
|
||||
update_data: OrgLLMSettingsUpdate,
|
||||
) -> OrgLLMSettingsResponse:
|
||||
"""Update LLM settings for user's current organization.
|
||||
|
||||
Only updates fields that are explicitly provided in update_data.
|
||||
User ID is obtained from the injected user_context.
|
||||
Session auto-commits at request end via DbSessionInjector.
|
||||
|
||||
Args:
|
||||
update_data: The update data from the request
|
||||
|
||||
Returns:
|
||||
OrgLLMSettingsResponse: The updated organization's LLM settings
|
||||
|
||||
Raises:
|
||||
ValueError: If user is not authenticated
|
||||
OrgNotFoundError: If current organization not found
|
||||
"""
|
||||
user_id = await self.user_context.get_user_id()
|
||||
if not user_id:
|
||||
raise ValueError('User is not authenticated')
|
||||
|
||||
logger.info(
|
||||
'Updating organization LLM settings',
|
||||
extra={'user_id': user_id},
|
||||
)
|
||||
|
||||
# Check if any fields are provided
|
||||
if not update_data.has_updates():
|
||||
# No fields to update, just return current settings
|
||||
return await self.get_org_llm_settings()
|
||||
|
||||
# Get user's current org first
|
||||
org = await self.store.get_current_org_by_user_id(user_id)
|
||||
if not org:
|
||||
raise OrgNotFoundError('No current organization')
|
||||
|
||||
# Update the org LLM settings
|
||||
updated_org = await self.store.update_org_llm_settings(
|
||||
org_id=org.id,
|
||||
update_data=update_data,
|
||||
)
|
||||
|
||||
if not updated_org:
|
||||
raise OrgNotFoundError(str(org.id))
|
||||
|
||||
logger.info(
|
||||
'Organization LLM settings updated successfully',
|
||||
extra={'user_id': user_id, 'org_id': str(org.id)},
|
||||
)
|
||||
|
||||
return OrgLLMSettingsResponse.from_org(updated_org)
|
||||
|
||||
|
||||
class OrgLLMSettingsServiceInjector(Injector[OrgLLMSettingsService]):
|
||||
"""Injector that composes store and user_context for OrgLLMSettingsService."""
|
||||
|
||||
async def inject(
|
||||
self, state: InjectorState, request: Request | None = None
|
||||
) -> AsyncGenerator[OrgLLMSettingsService, None]:
|
||||
# Local imports to avoid circular dependencies
|
||||
from openhands.app_server.config import get_db_session, get_user_context
|
||||
|
||||
async with (
|
||||
get_user_context(state, request) as user_context,
|
||||
get_db_session(state, request) as db_session,
|
||||
):
|
||||
store = OrgLLMSettingsStore(db_session=db_session)
|
||||
yield OrgLLMSettingsService(store=store, user_context=user_context)
|
||||
@@ -101,19 +101,36 @@ async def search_shared_events(
|
||||
] = 100,
|
||||
shared_event_service: SharedEventService = shared_event_service_dependency,
|
||||
) -> EventPage:
|
||||
"""Search / List events for a shared conversation."""
|
||||
page = await shared_event_service.search_shared_events(
|
||||
conversation_id=UUID(conversation_id),
|
||||
kind__eq=kind__eq,
|
||||
timestamp__gte=timestamp__gte,
|
||||
timestamp__lt=timestamp__lt,
|
||||
sort_order=sort_order,
|
||||
page_id=page_id,
|
||||
limit=limit,
|
||||
)
|
||||
"""Search / List events for a shared conversation.
|
||||
|
||||
Because non-viewable events (e.g. ``ConversationStateUpdateEvent``) are
|
||||
filtered out after fetching, a single backend page may yield fewer items
|
||||
than *limit*. This method transparently fetches additional backend pages
|
||||
until the requested *limit* is reached or there are no more results.
|
||||
"""
|
||||
conv_id = UUID(conversation_id)
|
||||
viewable: list[Event] = []
|
||||
cursor = page_id
|
||||
|
||||
while len(viewable) < limit:
|
||||
remaining = limit - len(viewable)
|
||||
page = await shared_event_service.search_shared_events(
|
||||
conversation_id=conv_id,
|
||||
kind__eq=kind__eq,
|
||||
timestamp__gte=timestamp__gte,
|
||||
timestamp__lt=timestamp__lt,
|
||||
sort_order=sort_order,
|
||||
page_id=cursor,
|
||||
limit=remaining,
|
||||
)
|
||||
viewable.extend(e for e in page.items if _is_viewable(e))
|
||||
cursor = page.next_page_id
|
||||
if cursor is None:
|
||||
break
|
||||
|
||||
return EventPage(
|
||||
items=[e for e in page.items if _is_viewable(e)],
|
||||
next_page_id=page.next_page_id,
|
||||
items=viewable[:limit],
|
||||
next_page_id=cursor,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -26,13 +26,13 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from storage.stored_conversation_metadata_saas import StoredConversationMetadataSaas
|
||||
|
||||
from openhands.agent_server.utils import utc_now
|
||||
from openhands.app_server.app_conversation.sql_app_conversation_info_service import (
|
||||
StoredConversationMetadata,
|
||||
)
|
||||
from openhands.app_server.services.injector import InjectorState
|
||||
from openhands.integrations.provider import ProviderType
|
||||
from openhands.sdk.llm import MetricsSnapshot
|
||||
from openhands.sdk.llm.utils.metrics import TokenUsage
|
||||
from openhands.sdk.llm import MetricsSnapshot, TokenUsage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -147,9 +147,15 @@ class SQLSharedConversationInfoService(SharedConversationInfoService):
|
||||
updated_at=updated_at,
|
||||
)
|
||||
|
||||
def _fix_timezone(self, value: datetime) -> datetime:
|
||||
def _fix_timezone(self, value: datetime | None) -> datetime:
|
||||
"""Sqlite does not store timezones - and since we can't update the existing models
|
||||
we assume UTC if the timezone is missing."""
|
||||
we assume UTC if the timezone is missing. Returns current UTC time if value is None.
|
||||
"""
|
||||
if value is None:
|
||||
# Fallback for legacy data: use current time to match model defaults.
|
||||
# The DB columns have default=utc_now, so None only occurs in legacy records.
|
||||
# Using utc_now() keeps the API model non-nullable and matches new record behavior.
|
||||
return utc_now()
|
||||
if not value.tzinfo:
|
||||
value = value.replace(tzinfo=UTC)
|
||||
return value
|
||||
|
||||
72
enterprise/server/utils/conversation_utils.py
Normal file
72
enterprise/server/utils/conversation_utils.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""Utility functions for conversation operations."""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from storage.database import session_maker
|
||||
from storage.stored_conversation_metadata_saas import StoredConversationMetadataSaas
|
||||
|
||||
|
||||
def get_user_id(conversation_id: str) -> str:
|
||||
"""Get the user ID for a conversation from the metadata.
|
||||
|
||||
Args:
|
||||
conversation_id: The conversation ID
|
||||
|
||||
Returns:
|
||||
The user ID as a string
|
||||
"""
|
||||
with session_maker() as session:
|
||||
conversation_metadata_saas = (
|
||||
session.query(StoredConversationMetadataSaas)
|
||||
.filter(StoredConversationMetadataSaas.conversation_id == conversation_id)
|
||||
.first()
|
||||
)
|
||||
if not conversation_metadata_saas:
|
||||
raise ValueError(f'Conversation not found: {conversation_id}')
|
||||
return str(conversation_metadata_saas.user_id)
|
||||
|
||||
|
||||
async def get_session_api_key(conversation_id: str) -> str | None:
|
||||
"""Get the session API key for a conversation.
|
||||
|
||||
This retrieves the session API key from the V1 sandbox system.
|
||||
|
||||
Args:
|
||||
conversation_id: The conversation ID
|
||||
|
||||
Returns:
|
||||
The session API key, or None if not available
|
||||
"""
|
||||
from openhands.app_server.config import (
|
||||
get_app_conversation_info_service,
|
||||
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 the conversation info to find the sandbox_id
|
||||
app_conversation_info = (
|
||||
await app_conversation_info_service.get_app_conversation_info(
|
||||
UUID(conversation_id)
|
||||
)
|
||||
)
|
||||
if not app_conversation_info:
|
||||
return None
|
||||
|
||||
# Get the sandbox to retrieve the session API key
|
||||
sandbox = await sandbox_service.get_sandbox(app_conversation_info.sandbox_id)
|
||||
if not sandbox:
|
||||
return None
|
||||
|
||||
return sandbox.session_api_key
|
||||
@@ -5,7 +5,7 @@ from typing import AsyncGenerator
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import Request
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import ColumnElement, func, select
|
||||
from storage.stored_conversation_metadata import StoredConversationMetadata
|
||||
from storage.stored_conversation_metadata_saas import StoredConversationMetadataSaas
|
||||
from storage.user import User
|
||||
@@ -242,7 +242,7 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
|
||||
):
|
||||
"""Apply filters to query that includes SAAS metadata."""
|
||||
# Apply the same filters as the base class
|
||||
conditions = []
|
||||
conditions: list[ColumnElement[bool]] = []
|
||||
if title__contains is not None:
|
||||
conditions.append(
|
||||
StoredConversationMetadata.title.like(f'%{title__contains}%')
|
||||
@@ -350,8 +350,8 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
|
||||
# Convert string user_id to UUID
|
||||
user_id_uuid = UUID(user_id_str)
|
||||
user_query = select(User).where(User.id == user_id_uuid)
|
||||
result = await self.db_session.execute(user_query)
|
||||
user = result.scalar_one_or_none()
|
||||
user_result = await self.db_session.execute(user_query)
|
||||
user = user_result.scalar_one_or_none()
|
||||
assert user
|
||||
|
||||
# Determine org_id: prefer API key's org_id if authenticated via API key
|
||||
@@ -372,8 +372,8 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
|
||||
saas_query = select(StoredConversationMetadataSaas).where(
|
||||
StoredConversationMetadataSaas.conversation_id == str(info.id)
|
||||
)
|
||||
result = await self.db_session.execute(saas_query)
|
||||
existing_saas_metadata = result.scalar_one_or_none()
|
||||
saas_result = await self.db_session.execute(saas_query)
|
||||
existing_saas_metadata = saas_result.scalar_one_or_none()
|
||||
assert existing_saas_metadata is None or (
|
||||
existing_saas_metadata.user_id == user_id_uuid
|
||||
and existing_saas_metadata.org_id == org_id
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
"""Store for managing verified LLM models in the database."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from server.verified_models.verified_model_models import (
|
||||
VerifiedModel,
|
||||
VerifiedModelPage,
|
||||
)
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
Column,
|
||||
DateTime,
|
||||
Identity,
|
||||
Integer,
|
||||
String,
|
||||
UniqueConstraint,
|
||||
and_,
|
||||
@@ -20,13 +18,14 @@ from sqlalchemy import (
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from storage.base import Base
|
||||
|
||||
from openhands.app_server.config import depends_db_session
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
|
||||
class StoredVerifiedModel(Base): # type: ignore
|
||||
class StoredVerifiedModel(Base):
|
||||
"""A verified LLM model available in the model selector.
|
||||
|
||||
The composite unique constraint on (model_name, provider) allows the same
|
||||
@@ -39,14 +38,16 @@ class StoredVerifiedModel(Base): # type: ignore
|
||||
UniqueConstraint('model_name', 'provider', name='uq_verified_model_provider'),
|
||||
)
|
||||
|
||||
id = Column(Integer, Identity(), primary_key=True)
|
||||
model_name = Column(String(255), nullable=False)
|
||||
provider = Column(String(100), nullable=False, index=True)
|
||||
is_enabled = Column(
|
||||
Boolean, nullable=False, default=True, server_default=text('true')
|
||||
id: Mapped[int] = mapped_column(Identity(), primary_key=True)
|
||||
model_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
provider: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||
is_enabled: Mapped[bool] = mapped_column(
|
||||
nullable=False, default=True, server_default=text('true')
|
||||
)
|
||||
created_at = Column(DateTime, nullable=False, server_default=func.now())
|
||||
updated_at = Column(
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
@@ -137,7 +138,8 @@ class VerifiedModelService:
|
||||
)
|
||||
)
|
||||
result = await self.db_session.execute(query)
|
||||
return result.scalars().first()
|
||||
stored = result.scalars().first()
|
||||
return verified_model(stored) if stored else None
|
||||
|
||||
async def create_verified_model(
|
||||
self,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user