mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 05:48:36 +08:00
refactor: move session initialization from WebSocket to REST API (#5493)
Co-authored-by: openhands <openhands@all-hands.dev> Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com>
This commit is contained in:
parent
0dd919bacf
commit
73c38f1163
@ -71,6 +71,14 @@ or run it on tagged issues with [a github action](https://github.com/All-Hands-A
|
||||
|
||||
Visit [Installation](https://docs.all-hands.dev/modules/usage/installation) for more information and setup instructions.
|
||||
|
||||
> [!CAUTION]
|
||||
> OpenHands is meant to be run by a single user on their local workstation.
|
||||
> It is not appropriate for multi-tenant deployments, where multiple users share the same instance--there is no built-in isolation or scalability.
|
||||
>
|
||||
> If you're interested in running OpenHands in a multi-tenant environment, please
|
||||
> [get in touch with us](https://docs.google.com/forms/d/e/1FAIpQLSet3VbGaz8z32gW9Wm-Grl4jpt5WgMXPgJ4EDPVmCETCBpJtQ/viewform)
|
||||
> for advanced deployment options.
|
||||
|
||||
If you want to modify the OpenHands source code, check out [Development.md](https://github.com/All-Hands-AI/OpenHands/blob/main/Development.md).
|
||||
|
||||
Having issues? The [Troubleshooting Guide](https://docs.all-hands.dev/modules/usage/troubleshooting) can help.
|
||||
|
||||
@ -1,338 +0,0 @@
|
||||
|
||||
|
||||
# Kubernetes
|
||||
|
||||
Il existe différentes façons d'exécuter OpenHands sur Kubernetes ou OpenShift. Ce guide présente une façon possible :
|
||||
1. Créer un PV "en tant qu'administrateur du cluster" pour mapper les données workspace_base et le répertoire docker au pod via le nœud worker
|
||||
2. Créer un PVC pour pouvoir monter ces PV sur le pod
|
||||
3. Créer un pod qui contient deux conteneurs : les conteneurs OpenHands et Sandbox
|
||||
|
||||
## Étapes détaillées pour l'exemple ci-dessus
|
||||
|
||||
> Remarque : Assurez-vous d'être connecté au cluster avec le compte approprié pour chaque étape. La création de PV nécessite un administrateur de cluster !
|
||||
|
||||
> Assurez-vous d'avoir les autorisations de lecture/écriture sur le hostPath utilisé ci-dessous (c'est-à-dire /tmp/workspace)
|
||||
|
||||
1. Créer le PV :
|
||||
Le fichier yaml d'exemple ci-dessous peut être utilisé par un administrateur de cluster pour créer le PV.
|
||||
- workspace-pv.yaml
|
||||
|
||||
```yamlfile
|
||||
apiVersion: v1
|
||||
kind: PersistentVolume
|
||||
metadata:
|
||||
name: workspace-pv
|
||||
spec:
|
||||
capacity:
|
||||
storage: 2Gi
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
persistentVolumeReclaimPolicy: Retain
|
||||
hostPath:
|
||||
path: /tmp/workspace
|
||||
```
|
||||
|
||||
```bash
|
||||
# appliquer le fichier yaml
|
||||
$ oc create -f workspace-pv.yaml
|
||||
persistentvolume/workspace-pv created
|
||||
|
||||
# vérifier :
|
||||
$ oc get pv
|
||||
NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS REASON AGE
|
||||
workspace-pv 2Gi RWO Retain Available 7m23s
|
||||
```
|
||||
|
||||
- docker-pv.yaml
|
||||
|
||||
```yamlfile
|
||||
apiVersion: v1
|
||||
kind: PersistentVolume
|
||||
metadata:
|
||||
name: docker-pv
|
||||
spec:
|
||||
capacity:
|
||||
storage: 2Gi
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
persistentVolumeReclaimPolicy: Retain
|
||||
hostPath:
|
||||
path: /var/run/docker.sock
|
||||
```
|
||||
|
||||
```bash
|
||||
# appliquer le fichier yaml
|
||||
$ oc create -f docker-pv.yaml
|
||||
persistentvolume/docker-pv created
|
||||
|
||||
# vérifier :
|
||||
oc get pv
|
||||
NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS REASON AGE
|
||||
docker-pv 2Gi RWO Retain Available 6m55s
|
||||
workspace-pv 2Gi RWO Retain Available 7m23s
|
||||
```
|
||||
|
||||
2. Créer le PVC :
|
||||
Exemple de fichier yaml PVC ci-dessous :
|
||||
|
||||
- workspace-pvc.yaml
|
||||
|
||||
```yamlfile
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: workspace-pvc
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
```
|
||||
|
||||
```bash
|
||||
# créer le pvc
|
||||
$ oc create -f workspace-pvc.yaml
|
||||
persistentvolumeclaim/workspace-pvc created
|
||||
|
||||
# vérifier
|
||||
$ oc get pvc
|
||||
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
|
||||
workspace-pvc Pending hcloud-volumes 4s
|
||||
|
||||
$ oc get events
|
||||
LAST SEEN TYPE REASON OBJECT MESSAGE
|
||||
8s Normal WaitForFirstConsumer persistentvolumeclaim/workspace-pvc waiting for first consumer to be created before binding
|
||||
```
|
||||
|
||||
- docker-pvc.yaml
|
||||
|
||||
```yamlfile
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: docker-pvc
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
```
|
||||
|
||||
```bash
|
||||
# créer le pvc
|
||||
$ oc create -f docker-pvc.yaml
|
||||
persistentvolumeclaim/docker-pvc created
|
||||
|
||||
# vérifier
|
||||
$ oc get pvc
|
||||
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
|
||||
docker-pvc Pending hcloud-volumes 4s
|
||||
workspace-pvc Pending hcloud-volumes 2m53s
|
||||
|
||||
$ oc get events
|
||||
LAST SEEN TYPE REASON OBJECT MESSAGE
|
||||
10s Normal WaitForFirstConsumer persistentvolumeclaim/docker-pvc waiting for first consumer to be created before binding
|
||||
10s Normal WaitForFirstConsumer persistentvolumeclaim/workspace-pvc waiting for first consumer to be created before binding
|
||||
```
|
||||
|
||||
3. Créer le fichier yaml du pod :
|
||||
Exemple de fichier yaml de pod ci-dessous :
|
||||
|
||||
- pod.yaml
|
||||
|
||||
```yamlfile
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: openhands-app-2024
|
||||
labels:
|
||||
app: openhands-app-2024
|
||||
spec:
|
||||
containers:
|
||||
- name: openhands-app-2024
|
||||
image: ghcr.io/all-hands-ai/openhands:main
|
||||
env:
|
||||
- name: SANDBOX_USER_ID
|
||||
value: "1000"
|
||||
- name: WORKSPACE_MOUNT_PATH
|
||||
value: "/opt/workspace_base"
|
||||
volumeMounts:
|
||||
- name: workspace-volume
|
||||
mountPath: /opt/workspace_base
|
||||
- name: docker-sock
|
||||
mountPath: /var/run/docker.sock
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
- name: openhands-sandbox-2024
|
||||
image: ghcr.io/all-hands-ai/sandbox:main
|
||||
ports:
|
||||
- containerPort: 51963
|
||||
command: ["/usr/sbin/sshd", "-D", "-p 51963", "-o", "PermitRootLogin=yes"]
|
||||
volumes:
|
||||
- name: workspace-volume
|
||||
persistentVolumeClaim:
|
||||
claimName: workspace-pvc
|
||||
- name: docker-sock
|
||||
persistentVolumeClaim:
|
||||
claimName: docker-pvc
|
||||
```
|
||||
|
||||
|
||||
```bash
|
||||
# créer le pod
|
||||
$ oc create -f pod.yaml
|
||||
W0716 11:22:07.776271 107626 warnings.go:70] would violate PodSecurity "restricted:v1.24": allowPrivilegeEscalation != false (containers "openhands-app-2024", "openhands-sandbox-2024" must set securityContext.allowPrivilegeEscalation=false), unrestricted capabilities (containers "openhands-app-2024", "openhands-sandbox-2024" must set securityContext.capabilities.drop=["ALL"]), runAsNonRoot != true (pod or containers "openhands-app-2024", "openhands-sandbox-2024" must set securityContext.runAsNonRoot=true), seccompProfile (pod or containers "openhands-app-2024", "openhands-sandbox-2024" must set securityContext.seccompProfile.type to "RuntimeDefault" or "Localhost")
|
||||
pod/openhands-app-2024 created
|
||||
|
||||
# L'avertissement ci-dessus peut être ignoré pour l'instant car nous ne modifierons pas les restrictions SCC.
|
||||
|
||||
# vérifier
|
||||
$ oc get pods
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
openhands-app-2024 0/2 Pending 0 5s
|
||||
|
||||
$ oc get pods
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
openhands-app-2024 0/2 ContainerCreating 0 15s
|
||||
|
||||
$ oc get events
|
||||
LAST SEEN TYPE REASON OBJECT MESSAGE
|
||||
38s Normal WaitForFirstConsumer persistentvolumeclaim/docker-pvc waiting for first consumer to be created before binding
|
||||
23s Normal ExternalProvisioning persistentvolumeclaim/docker-pvc waiting for a volume to be created, either by external provisioner "csi.hetzner.cloud" or manually created by system administrator
|
||||
27s Normal Provisioning persistentvolumeclaim/docker-pvc External provisioner is provisioning volume for claim "openhands/docker-pvc"
|
||||
17s Normal ProvisioningSucceeded persistentvolumeclaim/docker-pvc Successfully provisioned volume pvc-2b1d223a-1c8f-4990-8e3d-68061a9ae252
|
||||
16s Normal Scheduled pod/openhands-app-2024 Successfully assigned All-Hands-AI/OpenHands-app-2024 to worker1.hub.internal.blakane.com
|
||||
9s Normal SuccessfulAttachVolume pod/openhands-app-2024 AttachVolume.Attach succeeded for volume "pvc-2b1d223a-1c8f-4990-8e3d-68061a9ae252"
|
||||
9s Normal SuccessfulAttachVolume pod/openhands-app-2024 AttachVolume.Attach succeeded for volume "pvc-31f15b25-faad-4665-a25f-201a530379af"
|
||||
6s Normal AddedInterface pod/openhands-app-2024 Add eth0 [10.128.2.48/23] from openshift-sdn
|
||||
6s Normal Pulled pod/openhands-app-2024 Container image "ghcr.io/all-hands-ai/openhands:main" already present on machine
|
||||
6s Normal Created pod/openhands-app-2024 Created container openhands-app-2024
|
||||
6s Normal Started pod/openhands-app-2024 Started container openhands-app-2024
|
||||
6s Normal Pulled pod/openhands-app-2024 Container image "ghcr.io/all-hands-ai/sandbox:main" already present on machine
|
||||
5s Normal Created pod/openhands-app-2024 Created container openhands-sandbox-2024
|
||||
5s Normal Started pod/openhands-app-2024 Started container openhands-sandbox-2024
|
||||
83s Normal WaitForFirstConsumer persistentvolumeclaim/workspace-pvc waiting for first consumer to be created before binding
|
||||
27s Normal Provisioning persistentvolumeclaim/workspace-pvc External provisioner is provisioning volume for claim "openhands/workspace-pvc"
|
||||
17s Normal ProvisioningSucceeded persistentvolumeclaim/workspace-pvc Successfully provisioned volume pvc-31f15b25-faad-4665-a25f-201a530379af
|
||||
|
||||
$ oc get pods
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
openhands-app-2024 2/2 Running 0 23s
|
||||
|
||||
$ oc get pvc
|
||||
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
|
||||
docker-pvc Bound pvc-2b1d223a-1c8f-4990-8e3d-68061a9ae252 10Gi RWO hcloud-volumes 10m
|
||||
workspace-pvc Bound pvc-31f15b25-faad-4665-a25f-201a530379af 10Gi RWO hcloud-volumes 13m
|
||||
|
||||
```
|
||||
|
||||
4. Créer un service NodePort.
|
||||
Exemple de commande de création de service ci-dessous :
|
||||
|
||||
```bash
|
||||
# créer le service de type NodePort
|
||||
$ oc create svc nodeport openhands-app-2024 --tcp=3000:3000
|
||||
service/openhands-app-2024 created
|
||||
|
||||
# vérifier
|
||||
|
||||
$ oc get svc
|
||||
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
|
||||
openhands-app-2024 NodePort 172.30.225.42 <none> 3000:30495/TCP 4s
|
||||
|
||||
$ oc describe svc openhands-app-2024
|
||||
Name: openhands-app-2024
|
||||
Namespace: openhands
|
||||
Labels: app=openhands-app-2024
|
||||
Annotations: <none>
|
||||
Selector: app=openhands-app-2024
|
||||
Type: NodePort
|
||||
IP Family Policy: SingleStack
|
||||
IP Families: IPv4
|
||||
IP: 172.30.225.42
|
||||
IPs: 172.30.225.42
|
||||
Port: 3000-3000 3000/TCP
|
||||
TargetPort: 3000/TCP
|
||||
NodePort: 3000-3000 30495/TCP
|
||||
Endpoints: 10.128.2.48:3000
|
||||
Session Affinity: None
|
||||
External Traffic Policy: Cluster
|
||||
Events: <none>
|
||||
```
|
||||
|
||||
6. Se connecter à l'interface utilisateur d'OpenHands, configurer l'Agent, puis tester :
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
## Déploiement d'Openhands sur GCP GKE
|
||||
|
||||
**Avertissement** : ce déploiement accorde à l'application OpenHands l'accès au socket docker de Kubernetes, ce qui crée un risque de sécurité. Utilisez à vos propres risques.
|
||||
1- Créer une politique pour l'accès privilégié
|
||||
2- Créer des informations d'identification gke (facultatif)
|
||||
3- Créer le déploiement openhands
|
||||
4- Commandes de vérification et d'accès à l'interface utilisateur
|
||||
5- Dépanner le pod pour vérifier le conteneur interne
|
||||
|
||||
1. créer une politique pour l'accès privilégié
|
||||
```bash
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: privileged-role
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["pods"]
|
||||
verbs: ["create", "get", "list", "watch", "delete"]
|
||||
- apiGroups: ["apps"]
|
||||
resources: ["deployments"]
|
||||
verbs: ["create", "get", "list", "watch", "delete"]
|
||||
- apiGroups: [""]
|
||||
resources: ["pods/exec"]
|
||||
verbs: ["create"]
|
||||
- apiGroups: [""]
|
||||
resources: ["pods/log"]
|
||||
verbs: ["get"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: privileged-role-binding
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: ClusterRole
|
||||
name: privileged-role
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: default # Remplacez par le nom de votre compte de service
|
||||
namespace: default
|
||||
```
|
||||
2. créer des informations d'identification gke (facultatif)
|
||||
```bash
|
||||
kubectl create secret generic google-cloud-key \
|
||||
--from-file=key.json=/path/to/your/google-cloud-key.json
|
||||
```
|
||||
3. créer le déploiement openhands
|
||||
## comme cela est testé pour le nœud worker unique, si vous en avez plusieurs, spécifiez l'indicateur pour le worker unique
|
||||
|
||||
```bash
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: openhands-app-2024
|
||||
labels:
|
||||
app: openhands-app-2024
|
||||
spec:
|
||||
replicas: 1 # Vous pouvez augmenter ce nombre pour plusieurs réplicas
|
||||
selector:
|
||||
matchLabels:
|
||||
app: openhands-app-2024
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: openhands-app-2024
|
||||
spec:
|
||||
containers:
|
||||
-
|
||||
@ -1,343 +0,0 @@
|
||||
以下是翻译后的内容:
|
||||
|
||||
# Kubernetes
|
||||
|
||||
在 Kubernetes 或 OpenShift 上运行 OpenHands 有不同的方式。本指南介绍了一种可能的方式:
|
||||
1. 作为集群管理员,创建一个 PV 将 workspace_base 数据和 docker 目录映射到 worker 节点上的 pod
|
||||
2. 创建一个 PVC 以便将这些 PV 挂载到 pod
|
||||
3. 创建一个包含两个容器的 pod:OpenHands 和 Sandbox 容器
|
||||
|
||||
## 上述示例的详细步骤
|
||||
|
||||
> 注意:确保首先使用适当的帐户登录到集群以执行每个步骤。创建 PV 需要集群管理员权限!
|
||||
|
||||
> 确保你对下面使用的 hostPath(即 /tmp/workspace)有读写权限
|
||||
|
||||
1. 创建 PV:
|
||||
集群管理员可以使用下面的示例 yaml 文件创建 PV。
|
||||
- workspace-pv.yaml
|
||||
|
||||
```yamlfile
|
||||
apiVersion: v1
|
||||
kind: PersistentVolume
|
||||
metadata:
|
||||
name: workspace-pv
|
||||
spec:
|
||||
capacity:
|
||||
storage: 2Gi
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
persistentVolumeReclaimPolicy: Retain
|
||||
hostPath:
|
||||
path: /tmp/workspace
|
||||
```
|
||||
|
||||
```bash
|
||||
# 应用 yaml 文件
|
||||
$ oc create -f workspace-pv.yaml
|
||||
persistentvolume/workspace-pv created
|
||||
|
||||
# 查看:
|
||||
$ oc get pv
|
||||
NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS REASON AGE
|
||||
workspace-pv 2Gi RWO Retain Available 7m23s
|
||||
```
|
||||
|
||||
- docker-pv.yaml
|
||||
|
||||
```yamlfile
|
||||
apiVersion: v1
|
||||
kind: PersistentVolume
|
||||
metadata:
|
||||
name: docker-pv
|
||||
spec:
|
||||
capacity:
|
||||
storage: 2Gi
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
persistentVolumeReclaimPolicy: Retain
|
||||
hostPath:
|
||||
path: /var/run/docker.sock
|
||||
```
|
||||
|
||||
```bash
|
||||
# 应用 yaml 文件
|
||||
$ oc create -f docker-pv.yaml
|
||||
persistentvolume/docker-pv created
|
||||
|
||||
# 查看:
|
||||
oc get pv
|
||||
NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS REASON AGE
|
||||
docker-pv 2Gi RWO Retain Available 6m55s
|
||||
workspace-pv 2Gi RWO Retain Available 7m23s
|
||||
```
|
||||
|
||||
2. 创建 PVC:
|
||||
下面是示例 PVC yaml 文件:
|
||||
|
||||
- workspace-pvc.yaml
|
||||
|
||||
```yamlfile
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: workspace-pvc
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
```
|
||||
|
||||
```bash
|
||||
# 创建 pvc
|
||||
$ oc create -f workspace-pvc.yaml
|
||||
persistentvolumeclaim/workspace-pvc created
|
||||
|
||||
# 查看
|
||||
$ oc get pvc
|
||||
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
|
||||
workspace-pvc Pending hcloud-volumes 4s
|
||||
|
||||
$ oc get events
|
||||
LAST SEEN TYPE REASON OBJECT MESSAGE
|
||||
8s Normal WaitForFirstConsumer persistentvolumeclaim/workspace-pvc waiting for first consumer to be created before binding
|
||||
```
|
||||
|
||||
- docker-pvc.yaml
|
||||
|
||||
```yamlfile
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: docker-pvc
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
```
|
||||
|
||||
```bash
|
||||
# 创建 pvc
|
||||
$ oc create -f docker-pvc.yaml
|
||||
persistentvolumeclaim/docker-pvc created
|
||||
|
||||
# 查看
|
||||
$ oc get pvc
|
||||
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
|
||||
docker-pvc Pending hcloud-volumes 4s
|
||||
workspace-pvc Pending hcloud-volumes 2m53s
|
||||
|
||||
$ oc get events
|
||||
LAST SEEN TYPE REASON OBJECT MESSAGE
|
||||
10s Normal WaitForFirstConsumer persistentvolumeclaim/docker-pvc waiting for first consumer to be created before binding
|
||||
10s Normal WaitForFirstConsumer persistentvolumeclaim/workspace-pvc waiting for first consumer to be created before binding
|
||||
```
|
||||
|
||||
3. 创建 pod yaml 文件:
|
||||
下面是示例 pod yaml 文件:
|
||||
|
||||
- pod.yaml
|
||||
|
||||
```yamlfile
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: openhands-app-2024
|
||||
labels:
|
||||
app: openhands-app-2024
|
||||
spec:
|
||||
containers:
|
||||
- name: openhands-app-2024
|
||||
image: ghcr.io/all-hands-ai/openhands:main
|
||||
env:
|
||||
- name: SANDBOX_USER_ID
|
||||
value: "1000"
|
||||
- name: WORKSPACE_MOUNT_PATH
|
||||
value: "/opt/workspace_base"
|
||||
volumeMounts:
|
||||
- name: workspace-volume
|
||||
mountPath: /opt/workspace_base
|
||||
- name: docker-sock
|
||||
mountPath: /var/run/docker.sock
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
- name: openhands-sandbox-2024
|
||||
image: ghcr.io/all-hands-ai/sandbox:main
|
||||
ports:
|
||||
- containerPort: 51963
|
||||
command: ["/usr/sbin/sshd", "-D", "-p 51963", "-o", "PermitRootLogin=yes"]
|
||||
volumes:
|
||||
- name: workspace-volume
|
||||
persistentVolumeClaim:
|
||||
claimName: workspace-pvc
|
||||
- name: docker-sock
|
||||
persistentVolumeClaim:
|
||||
claimName: docker-pvc
|
||||
```
|
||||
|
||||
|
||||
```bash
|
||||
# 创建 pod
|
||||
$ oc create -f pod.yaml
|
||||
W0716 11:22:07.776271 107626 warnings.go:70] would violate PodSecurity "restricted:v1.24": allowPrivilegeEscalation != false (containers "openhands-app-2024", "openhands-sandbox-2024" must set securityContext.allowPrivilegeEscalation=false), unrestricted capabilities (containers "openhands-app-2024", "openhands-sandbox-2024" must set securityContext.capabilities.drop=["ALL"]), runAsNonRoot != true (pod or containers "openhands-app-2024", "openhands-sandbox-2024" must set securityContext.runAsNonRoot=true), seccompProfile (pod or containers "openhands-app-2024", "openhands-sandbox-2024" must set securityContext.seccompProfile.type to "RuntimeDefault" or "Localhost")
|
||||
pod/openhands-app-2024 created
|
||||
|
||||
# 上面的警告可以暂时忽略,因为我们不会修改 SCC 限制。
|
||||
|
||||
# 查看
|
||||
$ oc get pods
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
openhands-app-2024 0/2 Pending 0 5s
|
||||
|
||||
$ oc get pods
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
openhands-app-2024 0/2 ContainerCreating 0 15s
|
||||
|
||||
$ oc get events
|
||||
LAST SEEN TYPE REASON OBJECT MESSAGE
|
||||
38s Normal WaitForFirstConsumer persistentvolumeclaim/docker-pvc waiting for first consumer to be created before binding
|
||||
23s Normal ExternalProvisioning persistentvolumeclaim/docker-pvc waiting for a volume to be created, either by external provisioner "csi.hetzner.cloud" or manually created by system administrator
|
||||
27s Normal Provisioning persistentvolumeclaim/docker-pvc External provisioner is provisioning volume for claim "openhands/docker-pvc"
|
||||
17s Normal ProvisioningSucceeded persistentvolumeclaim/docker-pvc Successfully provisioned volume pvc-2b1d223a-1c8f-4990-8e3d-68061a9ae252
|
||||
16s Normal Scheduled pod/openhands-app-2024 Successfully assigned All-Hands-AI/OpenHands-app-2024 to worker1.hub.internal.blakane.com
|
||||
9s Normal SuccessfulAttachVolume pod/openhands-app-2024 AttachVolume.Attach succeeded for volume "pvc-2b1d223a-1c8f-4990-8e3d-68061a9ae252"
|
||||
9s Normal SuccessfulAttachVolume pod/openhands-app-2024 AttachVolume.Attach succeeded for volume "pvc-31f15b25-faad-4665-a25f-201a530379af"
|
||||
6s Normal AddedInterface pod/openhands-app-2024 Add eth0 [10.128.2.48/23] from openshift-sdn
|
||||
6s Normal Pulled pod/openhands-app-2024 Container image "ghcr.io/all-hands-ai/openhands:main" already present on machine
|
||||
6s Normal Created pod/openhands-app-2024 Created container openhands-app-2024
|
||||
6s Normal Started pod/openhands-app-2024 Started container openhands-app-2024
|
||||
6s Normal Pulled pod/openhands-app-2024 Container image "ghcr.io/all-hands-ai/sandbox:main" already present on machine
|
||||
5s Normal Created pod/openhands-app-2024 Created container openhands-sandbox-2024
|
||||
5s Normal Started pod/openhands-app-2024 Started container openhands-sandbox-2024
|
||||
83s Normal WaitForFirstConsumer persistentvolumeclaim/workspace-pvc waiting for first consumer to be created before binding
|
||||
27s Normal Provisioning persistentvolumeclaim/workspace-pvc External provisioner is provisioning volume for claim "openhands/workspace-pvc"
|
||||
17s Normal ProvisioningSucceeded persistentvolumeclaim/workspace-pvc Successfully provisioned volume pvc-31f15b25-faad-4665-a25f-201a530379af
|
||||
|
||||
$ oc get pods
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
openhands-app-2024 2/2 Running 0 23s
|
||||
|
||||
$ oc get pvc
|
||||
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
|
||||
docker-pvc Bound pvc-2b1d223a-1c8f-4990-8e3d-68061a9ae252 10Gi RWO hcloud-volumes 10m
|
||||
workspace-pvc Bound pvc-31f15b25-faad-4665-a25f-201a530379af 10Gi RWO hcloud-volumes 13m
|
||||
|
||||
```
|
||||
|
||||
4. 创建一个 NodePort 服务。
|
||||
下面是示例服务创建命令:
|
||||
|
||||
```bash
|
||||
# 创建 NodePort 类型的服务
|
||||
$ oc create svc nodeport openhands-app-2024 --tcp=3000:3000
|
||||
service/openhands-app-2024 created
|
||||
|
||||
# 查看
|
||||
|
||||
$ oc get svc
|
||||
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
|
||||
openhands-app-2024 NodePort 172.30.225.42 <none> 3000:30495/TCP 4s
|
||||
|
||||
$ oc describe svc openhands-app-2024
|
||||
Name: openhands-app-2024
|
||||
Namespace: openhands
|
||||
Labels: app=openhands-app-2024
|
||||
Annotations: <none>
|
||||
Selector: app=openhands-app-2024
|
||||
Type: NodePort
|
||||
IP Family Policy: SingleStack
|
||||
IP Families: IPv4
|
||||
IP: 172.30.225.42
|
||||
IPs: 172.30.225.42
|
||||
Port: 3000-3000 3000/TCP
|
||||
TargetPort: 3000/TCP
|
||||
NodePort: 3000-3000 30495/TCP
|
||||
Endpoints: 10.128.2.48:3000
|
||||
Session Affinity: None
|
||||
External Traffic Policy: Cluster
|
||||
Events: <none>
|
||||
```
|
||||
|
||||
6. 连接到 OpenHands UI,配置 Agent,然后测试:
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
## GCP GKE OpenHands 部署
|
||||
|
||||
**警告**:此部署授予 OpenHands 应用程序访问 Kubernetes docker socket 的权限,这会带来安全风险。请自行决定是否使用。
|
||||
1- 创建特权访问策略
|
||||
2- 创建 gke 凭证(可选)
|
||||
3- 创建 openhands 部署
|
||||
4- 验证和 UI 访问命令
|
||||
5- 排查 pod 以验证内部容器
|
||||
|
||||
1. 创建特权访问策略
|
||||
```bash
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: privileged-role
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["pods"]
|
||||
verbs: ["create", "get", "list", "watch", "delete"]
|
||||
- apiGroups: ["apps"]
|
||||
resources: ["deployments"]
|
||||
verbs: ["create", "get", "list", "watch", "delete"]
|
||||
- apiGroups: [""]
|
||||
resources: ["pods/exec"]
|
||||
verbs: ["create"]
|
||||
- apiGroups: [""]
|
||||
resources: ["pods/log"]
|
||||
verbs: ["get"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: privileged-role-binding
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: ClusterRole
|
||||
name: privileged-role
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: default # 更改为你的服务帐户名称
|
||||
namespace: default
|
||||
```
|
||||
2. 创建 gke 凭证(可选)
|
||||
```bash
|
||||
kubectl create secret generic google-cloud-key \
|
||||
--from-file=key.json=/path/to/your/google-cloud-key.json
|
||||
```
|
||||
3. 创建 openhands 部署
|
||||
## 由于这是针对单个工作节点进行测试的,如果你有多个节点,请指定单个工作节点的标志
|
||||
|
||||
```bash
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: openhands-app-2024
|
||||
labels:
|
||||
app: openhands-app-2024
|
||||
spec:
|
||||
replicas: 1 # 你可以增加这个数字以获得多个副本
|
||||
selector:
|
||||
matchLabels:
|
||||
app: openhands-app-2024
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: openhands-app-2024
|
||||
spec:
|
||||
containers:
|
||||
- name: openhands-app-2024
|
||||
image: ghcr.io/all-hands-ai/openhands:main
|
||||
env:
|
||||
- name: SANDBOX_USER_ID
|
||||
value: "1000"
|
||||
- name: SANDBOX_API
|
||||
@ -1,429 +0,0 @@
|
||||
# Kubernetes
|
||||
|
||||
There are different ways you might run OpenHands on Kubernetes or OpenShift. This guide goes through one possible way:
|
||||
1. Create a PV "as a cluster admin" to map workspace_base data and docker directory to the pod through the worker node
|
||||
2. Create a PVC to be able to mount those PVs to the pod
|
||||
3. Create a pod which contains two containers; the OpenHands and Sandbox containers
|
||||
|
||||
## Detailed Steps for the Example Above
|
||||
|
||||
> Note: Make sure you are logged in to the cluster first with the proper account for each step. PV creation requires cluster administrator!
|
||||
|
||||
> Make sure you have read/write permissions on the hostPath used below (i.e. /tmp/workspace)
|
||||
|
||||
1. Create the PV:
|
||||
Sample yaml file below can be used by a cluster admin to create the PV.
|
||||
- workspace-pv.yaml
|
||||
|
||||
```yamlfile
|
||||
apiVersion: v1
|
||||
kind: PersistentVolume
|
||||
metadata:
|
||||
name: workspace-pv
|
||||
spec:
|
||||
capacity:
|
||||
storage: 2Gi
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
persistentVolumeReclaimPolicy: Retain
|
||||
hostPath:
|
||||
path: /tmp/workspace
|
||||
```
|
||||
|
||||
```bash
|
||||
# apply yaml file
|
||||
$ oc create -f workspace-pv.yaml
|
||||
persistentvolume/workspace-pv created
|
||||
|
||||
# review:
|
||||
$ oc get pv
|
||||
NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS REASON AGE
|
||||
workspace-pv 2Gi RWO Retain Available 7m23s
|
||||
```
|
||||
|
||||
- docker-pv.yaml
|
||||
|
||||
```yamlfile
|
||||
apiVersion: v1
|
||||
kind: PersistentVolume
|
||||
metadata:
|
||||
name: docker-pv
|
||||
spec:
|
||||
capacity:
|
||||
storage: 2Gi
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
persistentVolumeReclaimPolicy: Retain
|
||||
hostPath:
|
||||
path: /var/run/docker.sock
|
||||
```
|
||||
|
||||
```bash
|
||||
# apply yaml file
|
||||
$ oc create -f docker-pv.yaml
|
||||
persistentvolume/docker-pv created
|
||||
|
||||
# review:
|
||||
oc get pv
|
||||
NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS REASON AGE
|
||||
docker-pv 2Gi RWO Retain Available 6m55s
|
||||
workspace-pv 2Gi RWO Retain Available 7m23s
|
||||
```
|
||||
|
||||
2. Create the PVC:
|
||||
Sample PVC yaml file below:
|
||||
|
||||
- workspace-pvc.yaml
|
||||
|
||||
```yamlfile
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: workspace-pvc
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
```
|
||||
|
||||
```bash
|
||||
# create the pvc
|
||||
$ oc create -f workspace-pvc.yaml
|
||||
persistentvolumeclaim/workspace-pvc created
|
||||
|
||||
# review
|
||||
$ oc get pvc
|
||||
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
|
||||
workspace-pvc Pending hcloud-volumes 4s
|
||||
|
||||
$ oc get events
|
||||
LAST SEEN TYPE REASON OBJECT MESSAGE
|
||||
8s Normal WaitForFirstConsumer persistentvolumeclaim/workspace-pvc waiting for first consumer to be created before binding
|
||||
```
|
||||
|
||||
- docker-pvc.yaml
|
||||
|
||||
```yamlfile
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: docker-pvc
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
```
|
||||
|
||||
```bash
|
||||
# create pvc
|
||||
$ oc create -f docker-pvc.yaml
|
||||
persistentvolumeclaim/docker-pvc created
|
||||
|
||||
# review
|
||||
$ oc get pvc
|
||||
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
|
||||
docker-pvc Pending hcloud-volumes 4s
|
||||
workspace-pvc Pending hcloud-volumes 2m53s
|
||||
|
||||
$ oc get events
|
||||
LAST SEEN TYPE REASON OBJECT MESSAGE
|
||||
10s Normal WaitForFirstConsumer persistentvolumeclaim/docker-pvc waiting for first consumer to be created before binding
|
||||
10s Normal WaitForFirstConsumer persistentvolumeclaim/workspace-pvc waiting for first consumer to be created before binding
|
||||
```
|
||||
|
||||
3. Create the pod yaml file:
|
||||
Sample pod yaml file below:
|
||||
|
||||
- pod.yaml
|
||||
|
||||
```yamlfile
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: openhands-app-2024
|
||||
labels:
|
||||
app: openhands-app-2024
|
||||
spec:
|
||||
containers:
|
||||
- name: openhands-app-2024
|
||||
image: docker.all-hands.dev/all-hands-ai/openhands:main
|
||||
env:
|
||||
- name: SANDBOX_USER_ID
|
||||
value: "1000"
|
||||
- name: WORKSPACE_MOUNT_PATH
|
||||
value: "/opt/workspace_base"
|
||||
volumeMounts:
|
||||
- name: workspace-volume
|
||||
mountPath: /opt/workspace_base
|
||||
- name: docker-sock
|
||||
mountPath: /var/run/docker.sock
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
- name: openhands-sandbox-2024
|
||||
image: docker.all-hands.dev/all-hands-ai/runtime:main
|
||||
ports:
|
||||
- containerPort: 51963
|
||||
command: ["/usr/sbin/sshd", "-D", "-p 51963", "-o", "PermitRootLogin=yes"]
|
||||
volumes:
|
||||
- name: workspace-volume
|
||||
persistentVolumeClaim:
|
||||
claimName: workspace-pvc
|
||||
- name: docker-sock
|
||||
persistentVolumeClaim:
|
||||
claimName: docker-pvc
|
||||
```
|
||||
|
||||
|
||||
```bash
|
||||
# create the pod
|
||||
$ oc create -f pod.yaml
|
||||
W0716 11:22:07.776271 107626 warnings.go:70] would violate PodSecurity "restricted:v1.24": allowPrivilegeEscalation != false (containers "openhands-app-2024", "openhands-sandbox-2024" must set securityContext.allowPrivilegeEscalation=false), unrestricted capabilities (containers "openhands-app-2024", "openhands-sandbox-2024" must set securityContext.capabilities.drop=["ALL"]), runAsNonRoot != true (pod or containers "openhands-app-2024", "openhands-sandbox-2024" must set securityContext.runAsNonRoot=true), seccompProfile (pod or containers "openhands-app-2024", "openhands-sandbox-2024" must set securityContext.seccompProfile.type to "RuntimeDefault" or "Localhost")
|
||||
pod/openhands-app-2024 created
|
||||
|
||||
# Above warning can be ignored for now as we will not modify SCC restrictions.
|
||||
|
||||
# review
|
||||
$ oc get pods
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
openhands-app-2024 0/2 Pending 0 5s
|
||||
|
||||
$ oc get pods
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
openhands-app-2024 0/2 ContainerCreating 0 15s
|
||||
|
||||
$ oc get events
|
||||
LAST SEEN TYPE REASON OBJECT MESSAGE
|
||||
38s Normal WaitForFirstConsumer persistentvolumeclaim/docker-pvc waiting for first consumer to be created before binding
|
||||
23s Normal ExternalProvisioning persistentvolumeclaim/docker-pvc waiting for a volume to be created, either by external provisioner "csi.hetzner.cloud" or manually created by system administrator
|
||||
27s Normal Provisioning persistentvolumeclaim/docker-pvc External provisioner is provisioning volume for claim "openhands/docker-pvc"
|
||||
17s Normal ProvisioningSucceeded persistentvolumeclaim/docker-pvc Successfully provisioned volume pvc-2b1d223a-1c8f-4990-8e3d-68061a9ae252
|
||||
16s Normal Scheduled pod/openhands-app-2024 Successfully assigned All-Hands-AI/OpenHands-app-2024 to worker1.hub.internal.blakane.com
|
||||
9s Normal SuccessfulAttachVolume pod/openhands-app-2024 AttachVolume.Attach succeeded for volume "pvc-2b1d223a-1c8f-4990-8e3d-68061a9ae252"
|
||||
9s Normal SuccessfulAttachVolume pod/openhands-app-2024 AttachVolume.Attach succeeded for volume "pvc-31f15b25-faad-4665-a25f-201a530379af"
|
||||
6s Normal AddedInterface pod/openhands-app-2024 Add eth0 [10.128.2.48/23] from openshift-sdn
|
||||
6s Normal Pulled pod/openhands-app-2024 Container image "docker.all-hands.dev/all-hands-ai/openhands:main" already present on machine
|
||||
6s Normal Created pod/openhands-app-2024 Created container openhands-app-2024
|
||||
6s Normal Started pod/openhands-app-2024 Started container openhands-app-2024
|
||||
6s Normal Pulled pod/openhands-app-2024 Container image "docker.all-hands.dev/all-hands-ai/sandbox:main" already present on machine
|
||||
5s Normal Created pod/openhands-app-2024 Created container openhands-sandbox-2024
|
||||
5s Normal Started pod/openhands-app-2024 Started container openhands-sandbox-2024
|
||||
83s Normal WaitForFirstConsumer persistentvolumeclaim/workspace-pvc waiting for first consumer to be created before binding
|
||||
27s Normal Provisioning persistentvolumeclaim/workspace-pvc External provisioner is provisioning volume for claim "openhands/workspace-pvc"
|
||||
17s Normal ProvisioningSucceeded persistentvolumeclaim/workspace-pvc Successfully provisioned volume pvc-31f15b25-faad-4665-a25f-201a530379af
|
||||
|
||||
$ oc get pods
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
openhands-app-2024 2/2 Running 0 23s
|
||||
|
||||
$ oc get pvc
|
||||
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
|
||||
docker-pvc Bound pvc-2b1d223a-1c8f-4990-8e3d-68061a9ae252 10Gi RWO hcloud-volumes 10m
|
||||
workspace-pvc Bound pvc-31f15b25-faad-4665-a25f-201a530379af 10Gi RWO hcloud-volumes 13m
|
||||
|
||||
```
|
||||
|
||||
4. Create a NodePort service.
|
||||
Sample service creation command below:
|
||||
|
||||
```bash
|
||||
# create the service of type NodePort
|
||||
$ oc create svc nodeport openhands-app-2024 --tcp=3000:3000
|
||||
service/openhands-app-2024 created
|
||||
|
||||
# review
|
||||
|
||||
$ oc get svc
|
||||
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
|
||||
openhands-app-2024 NodePort 172.30.225.42 <none> 3000:30495/TCP 4s
|
||||
|
||||
$ oc describe svc openhands-app-2024
|
||||
Name: openhands-app-2024
|
||||
Namespace: openhands
|
||||
Labels: app=openhands-app-2024
|
||||
Annotations: <none>
|
||||
Selector: app=openhands-app-2024
|
||||
Type: NodePort
|
||||
IP Family Policy: SingleStack
|
||||
IP Families: IPv4
|
||||
IP: 172.30.225.42
|
||||
IPs: 172.30.225.42
|
||||
Port: 3000-3000 3000/TCP
|
||||
TargetPort: 3000/TCP
|
||||
NodePort: 3000-3000 30495/TCP
|
||||
Endpoints: 10.128.2.48:3000
|
||||
Session Affinity: None
|
||||
External Traffic Policy: Cluster
|
||||
Events: <none>
|
||||
```
|
||||
|
||||
6. Connect to OpenHands UI, configure the Agent, then test:
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
## GCP GKE Openhands deployment
|
||||
|
||||
**Warning**: this deployment grants the OpenHands application access to the Kubernetes docker socket, which creates security risk. Use at your own discretion.
|
||||
1- Create policy for privillege access
|
||||
2- Create gke credentials(optional)
|
||||
3- Create openhands deployment
|
||||
4- Verification and ui access commands
|
||||
5- Tshoot pod to verify the internal container
|
||||
|
||||
1. create policy for privillege access
|
||||
```bash
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: privileged-role
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["pods"]
|
||||
verbs: ["create", "get", "list", "watch", "delete"]
|
||||
- apiGroups: ["apps"]
|
||||
resources: ["deployments"]
|
||||
verbs: ["create", "get", "list", "watch", "delete"]
|
||||
- apiGroups: [""]
|
||||
resources: ["pods/exec"]
|
||||
verbs: ["create"]
|
||||
- apiGroups: [""]
|
||||
resources: ["pods/log"]
|
||||
verbs: ["get"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: privileged-role-binding
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: ClusterRole
|
||||
name: privileged-role
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: default # Change to your service account name
|
||||
namespace: default
|
||||
```
|
||||
2. create gke credentials(optional)
|
||||
```bash
|
||||
kubectl create secret generic google-cloud-key \
|
||||
--from-file=key.json=/path/to/your/google-cloud-key.json
|
||||
```
|
||||
3. create openhands deployment
|
||||
## as this is tested for the single worker node if you have multiple specify the flag for the single worker
|
||||
|
||||
```bash
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: openhands-app-2024
|
||||
labels:
|
||||
app: openhands-app-2024
|
||||
spec:
|
||||
replicas: 1 # You can increase this number for multiple replicas
|
||||
selector:
|
||||
matchLabels:
|
||||
app: openhands-app-2024
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: openhands-app-2024
|
||||
spec:
|
||||
containers:
|
||||
- name: openhands-app-2024
|
||||
image: docker.all-hands.dev/all-hands-ai/openhands:main
|
||||
env:
|
||||
- name: SANDBOX_USER_ID
|
||||
value: "1000"
|
||||
- name: SANDBOX_API_HOSTNAME
|
||||
value: '10.164.0.4'
|
||||
- name: WORKSPACE_MOUNT_PATH
|
||||
value: "/tmp/workspace_base"
|
||||
- name: GOOGLE_APPLICATION_CREDENTIALS
|
||||
value: "/tmp/workspace_base/google-cloud-key.json"
|
||||
volumeMounts:
|
||||
- name: workspace-volume
|
||||
mountPath: /tmp/workspace_base
|
||||
- name: docker-sock
|
||||
mountPath: /var/run/docker.sock
|
||||
- name: google-credentials
|
||||
mountPath: "/tmp/workspace_base/google-cloud-key.json"
|
||||
securityContext:
|
||||
privileged: true # Add this to allow privileged access
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
- name: openhands-sandbox-2024
|
||||
image: docker.all-hands.dev/all-hands-ai/runtime:main
|
||||
# securityContext:
|
||||
# privileged: true # Add this to allow privileged access
|
||||
ports:
|
||||
- containerPort: 51963
|
||||
command: ["/usr/sbin/sshd", "-D", "-p 51963", "-o", "PermitRootLogin=yes"]
|
||||
volumes:
|
||||
#- name: workspace-volume
|
||||
# persistentVolumeClaim:
|
||||
# claimName: workspace-pvc
|
||||
- name: workspace-volume
|
||||
emptyDir: {}
|
||||
- name: docker-sock
|
||||
hostPath:
|
||||
path: /var/run/docker.sock # Use host's Docker socket
|
||||
type: Socket
|
||||
- name: google-credentials
|
||||
secret:
|
||||
secretName: google-cloud-key
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: openhands-app-2024-svc
|
||||
spec:
|
||||
selector:
|
||||
app: openhands-app-2024
|
||||
ports:
|
||||
- name: http
|
||||
protocol: TCP
|
||||
port: 80
|
||||
targetPort: 3000
|
||||
- name: ssh
|
||||
protocol: TCP
|
||||
port: 51963
|
||||
targetPort: 51963
|
||||
type: LoadBalancer
|
||||
```
|
||||
|
||||
5. Tshoot pod to verify the internal container
|
||||
### if you want to know more regarding the internal container runtime use below mention pod deployment use kubectl exec -it to enter into container and you can check the contaienr run time using normal docker commands like "docker ps -a"
|
||||
|
||||
```bash
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: docker-in-docker
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: docker-in-docker
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: docker-in-docker
|
||||
spec:
|
||||
containers:
|
||||
- name: dind
|
||||
image: docker:20.10-dind
|
||||
securityContext:
|
||||
privileged: true
|
||||
volumeMounts:
|
||||
- name: docker-sock
|
||||
mountPath: /var/run/docker.sock
|
||||
volumes:
|
||||
- name: docker-sock
|
||||
hostPath:
|
||||
path: /var/run/docker.sock
|
||||
type: Socket
|
||||
```
|
||||
@ -168,11 +168,6 @@ const sidebars: SidebarsConfig = {
|
||||
label: 'Evaluation',
|
||||
id: 'usage/how-to/evaluation-harness',
|
||||
},
|
||||
{
|
||||
type: 'doc',
|
||||
label: 'Kubernetes Deployment',
|
||||
id: 'usage/how-to/openshift-example',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@ -1,10 +1,38 @@
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import * as router from "react-router";
|
||||
|
||||
// Mock useParams before importing components
|
||||
vi.mock("react-router", async () => {
|
||||
const actual = await vi.importActual("react-router");
|
||||
return {
|
||||
...actual as object,
|
||||
useParams: () => ({ conversationId: "test-conversation-id" }),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock i18next
|
||||
vi.mock("react-i18next", async () => {
|
||||
const actual = await vi.importActual("react-i18next");
|
||||
return {
|
||||
...actual as object,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: {
|
||||
changeLanguage: () => new Promise(() => {}),
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
import { screen } from "@testing-library/react";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderWithProviders } from "../../test-utils";
|
||||
import { BrowserPanel } from "#/components/features/browser/browser";
|
||||
|
||||
|
||||
describe("Browser", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
it("renders a message if no screenshotSrc is provided", () => {
|
||||
renderWithProviders(<BrowserPanel />, {
|
||||
preloadedState: {
|
||||
|
||||
@ -1,6 +1,17 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as router from "react-router";
|
||||
|
||||
// Mock useParams before importing components
|
||||
vi.mock("react-router", async () => {
|
||||
const actual = await vi.importActual("react-router");
|
||||
return {
|
||||
...actual as object,
|
||||
useParams: () => ({ conversationId: "test-conversation-id" }),
|
||||
};
|
||||
});
|
||||
|
||||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { FeedbackForm } from "#/components/features/feedback/feedback-form";
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import * as router from "react-router";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import { screen, waitFor, within } from "@testing-library/react";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
|
||||
@ -53,8 +53,12 @@ class OpenHands {
|
||||
* @param path Path to list files from
|
||||
* @returns List of files available in the given path. If path is not provided, it lists all the files in the workspace
|
||||
*/
|
||||
static async getFiles(path?: string): Promise<string[]> {
|
||||
const { data } = await openHands.get<string[]>("/api/list-files", {
|
||||
static async getFiles(
|
||||
conversationId: string,
|
||||
path?: string,
|
||||
): Promise<string[]> {
|
||||
const url = `/api/conversations/${conversationId}/list-files`;
|
||||
const { data } = await openHands.get<string[]>(url, {
|
||||
params: { path },
|
||||
});
|
||||
return data;
|
||||
@ -65,8 +69,9 @@ class OpenHands {
|
||||
* @param path Full path of the file to retrieve
|
||||
* @returns Content of the file
|
||||
*/
|
||||
static async getFile(path: string): Promise<string> {
|
||||
const { data } = await openHands.get<{ code: string }>("/api/select-file", {
|
||||
static async getFile(conversationId: string, path: string): Promise<string> {
|
||||
const url = `/api/conversations/${conversationId}/select-file`;
|
||||
const { data } = await openHands.get<{ code: string }>(url, {
|
||||
params: { file: path },
|
||||
});
|
||||
|
||||
@ -80,12 +85,14 @@ class OpenHands {
|
||||
* @returns Success message or error message
|
||||
*/
|
||||
static async saveFile(
|
||||
conversationId: string,
|
||||
path: string,
|
||||
content: string,
|
||||
): Promise<SaveFileSuccessResponse> {
|
||||
const url = `/api/conversations/${conversationId}/save-file`;
|
||||
const { data } = await openHands.post<
|
||||
SaveFileSuccessResponse | ErrorResponse
|
||||
>("/api/save-file", {
|
||||
>(url, {
|
||||
filePath: path,
|
||||
content,
|
||||
});
|
||||
@ -99,13 +106,17 @@ class OpenHands {
|
||||
* @param file File to upload
|
||||
* @returns Success message or error message
|
||||
*/
|
||||
static async uploadFiles(files: File[]): Promise<FileUploadSuccessResponse> {
|
||||
static async uploadFiles(
|
||||
conversationId: string,
|
||||
files: File[],
|
||||
): Promise<FileUploadSuccessResponse> {
|
||||
const url = `/api/conversations/${conversationId}/upload-files`;
|
||||
const formData = new FormData();
|
||||
files.forEach((file) => formData.append("files", file));
|
||||
|
||||
const { data } = await openHands.post<
|
||||
FileUploadSuccessResponse | ErrorResponse
|
||||
>("/api/upload-files", formData);
|
||||
>(url, formData);
|
||||
|
||||
if ("error" in data) throw new Error(data.error);
|
||||
return data;
|
||||
@ -116,11 +127,12 @@ class OpenHands {
|
||||
* @param data Feedback data
|
||||
* @returns The stored feedback data
|
||||
*/
|
||||
static async submitFeedback(feedback: Feedback): Promise<FeedbackResponse> {
|
||||
const { data } = await openHands.post<FeedbackResponse>(
|
||||
"/api/submit-feedback",
|
||||
feedback,
|
||||
);
|
||||
static async submitFeedback(
|
||||
conversationId: string,
|
||||
feedback: Feedback,
|
||||
): Promise<FeedbackResponse> {
|
||||
const url = `/api/conversations/${conversationId}/submit-feedback`;
|
||||
const { data } = await openHands.post<FeedbackResponse>(url, feedback);
|
||||
return data;
|
||||
}
|
||||
|
||||
@ -156,8 +168,9 @@ class OpenHands {
|
||||
* Get the blob of the workspace zip
|
||||
* @returns Blob of the workspace zip
|
||||
*/
|
||||
static async getWorkspaceZip(): Promise<Blob> {
|
||||
const response = await openHands.get("/api/zip-directory", {
|
||||
static async getWorkspaceZip(conversationId: string): Promise<Blob> {
|
||||
const url = `/api/conversations/${conversationId}/zip-directory`;
|
||||
const response = await openHands.get(url, {
|
||||
responseType: "blob",
|
||||
});
|
||||
return response.data;
|
||||
@ -183,18 +196,67 @@ class OpenHands {
|
||||
* Get the VSCode URL
|
||||
* @returns VSCode URL
|
||||
*/
|
||||
static async getVSCodeUrl(): Promise<GetVSCodeUrlResponse> {
|
||||
const { data } =
|
||||
await openHands.get<GetVSCodeUrlResponse>("/api/vscode-url");
|
||||
static async getVSCodeUrl(
|
||||
conversationId: string,
|
||||
): Promise<GetVSCodeUrlResponse> {
|
||||
const { data } = await openHands.get<GetVSCodeUrlResponse>(
|
||||
`/api/conversations/${conversationId}/vscode-url`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
static async getRuntimeId(): Promise<{ runtime_id: string }> {
|
||||
static async getRuntimeId(
|
||||
conversationId: string,
|
||||
): Promise<{ runtime_id: string }> {
|
||||
const { data } = await openHands.get<{ runtime_id: string }>(
|
||||
"/api/conversation",
|
||||
`/api/conversations/${conversationId}/config`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
static async searchEvents(
|
||||
conversationId: string,
|
||||
params: {
|
||||
query?: string;
|
||||
startId?: number;
|
||||
limit?: number;
|
||||
eventType?: string;
|
||||
source?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
},
|
||||
): Promise<{ events: Record<string, unknown>[]; has_more: boolean }> {
|
||||
const { data } = await openHands.get<{
|
||||
events: Record<string, unknown>[];
|
||||
has_more: boolean;
|
||||
}>(`/api/conversations/${conversationId}/events/search`, {
|
||||
params: {
|
||||
query: params.query,
|
||||
start_id: params.startId,
|
||||
limit: params.limit,
|
||||
event_type: params.eventType,
|
||||
source: params.source,
|
||||
start_date: params.startDate,
|
||||
end_date: params.endDate,
|
||||
},
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
static async newConversation(params: {
|
||||
githubToken?: string;
|
||||
args?: Record<string, unknown>;
|
||||
selectedRepository?: string;
|
||||
}): Promise<{ conversation_id: string }> {
|
||||
const { data } = await openHands.post<{
|
||||
conversation_id: string;
|
||||
}>("/api/conversations", {
|
||||
github_token: params.githubToken,
|
||||
args: params.args,
|
||||
selected_repository: params.selectedRepository,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
export default OpenHands;
|
||||
|
||||
@ -45,7 +45,7 @@ export function Sidebar() {
|
||||
};
|
||||
|
||||
const handleClickLogo = () => {
|
||||
if (location.pathname.startsWith("/app"))
|
||||
if (location.pathname.startsWith("/conversations/"))
|
||||
setStartNewProjectModalIsOpen(true);
|
||||
};
|
||||
|
||||
|
||||
@ -87,7 +87,7 @@ export function SettingsForm({
|
||||
const [showWarningModal, setShowWarningModal] = React.useState(false);
|
||||
|
||||
const resetOngoingSession = () => {
|
||||
if (location.pathname.startsWith("/app")) {
|
||||
if (location.pathname.startsWith("/conversations/")) {
|
||||
endSession();
|
||||
onClose();
|
||||
}
|
||||
@ -129,7 +129,7 @@ export function SettingsForm({
|
||||
|
||||
if (!apiKey) {
|
||||
setShowWarningModal(true);
|
||||
} else if (location.pathname.startsWith("/app")) {
|
||||
} else if (location.pathname.startsWith("/conversations/")) {
|
||||
setConfirmEndSessionModalOpen(true);
|
||||
} else {
|
||||
handleFormSubmission(formData);
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import React from "react";
|
||||
import { useNavigate, useNavigation } from "react-router";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import posthog from "posthog-js";
|
||||
import { RootState } from "#/store";
|
||||
import {
|
||||
@ -8,6 +9,10 @@ import {
|
||||
removeFile,
|
||||
setInitialQuery,
|
||||
} from "#/state/initial-query-slice";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
import { useUserPrefs } from "#/context/user-prefs-context";
|
||||
|
||||
import { SuggestionBubble } from "#/components/features/suggestions/suggestion-bubble";
|
||||
import { SUGGESTIONS } from "#/utils/suggestions";
|
||||
import { convertImageToBase64 } from "#/utils/convert-image-to-base-64";
|
||||
@ -22,6 +27,8 @@ export const TaskForm = React.forwardRef<HTMLFormElement>((_, ref) => {
|
||||
const dispatch = useDispatch();
|
||||
const navigation = useNavigation();
|
||||
const navigate = useNavigate();
|
||||
const { gitHubToken } = useAuth();
|
||||
const { settings } = useUserPrefs();
|
||||
|
||||
const { selectedRepository, files } = useSelector(
|
||||
(state: RootState) => state.initalQuery,
|
||||
@ -32,6 +39,25 @@ export const TaskForm = React.forwardRef<HTMLFormElement>((_, ref) => {
|
||||
getRandomKey(SUGGESTIONS["non-repo"]),
|
||||
);
|
||||
const [inputIsFocused, setInputIsFocused] = React.useState(false);
|
||||
const newConversationMutation = useMutation({
|
||||
mutationFn: (variables: { q: string }) => {
|
||||
dispatch(setInitialQuery(variables.q));
|
||||
return OpenHands.newConversation({
|
||||
githubToken: gitHubToken || undefined,
|
||||
selectedRepository: selectedRepository || undefined,
|
||||
args: settings || undefined,
|
||||
});
|
||||
},
|
||||
onSuccess: ({ conversation_id: conversationId }, { q }) => {
|
||||
posthog.capture("initial_query_submitted", {
|
||||
entry_point: "task_form",
|
||||
query_character_length: q.length,
|
||||
has_repository: !!selectedRepository,
|
||||
has_files: files.length > 0,
|
||||
});
|
||||
navigate(`/conversations/${conversationId}`);
|
||||
},
|
||||
});
|
||||
|
||||
const onRefreshSuggestion = () => {
|
||||
const suggestions = SUGGESTIONS["non-repo"];
|
||||
@ -62,16 +88,9 @@ export const TaskForm = React.forwardRef<HTMLFormElement>((_, ref) => {
|
||||
const formData = new FormData(event.currentTarget);
|
||||
|
||||
const q = formData.get("q")?.toString();
|
||||
if (q) dispatch(setInitialQuery(q));
|
||||
if (!q) return;
|
||||
|
||||
posthog.capture("initial_query_submitted", {
|
||||
entry_point: "task_form",
|
||||
query_character_length: q?.length,
|
||||
has_repository: !!selectedRepository,
|
||||
has_files: files.length > 0,
|
||||
});
|
||||
|
||||
navigate("/app");
|
||||
newConversationMutation.mutate({ q });
|
||||
};
|
||||
|
||||
return (
|
||||
@ -114,7 +133,10 @@ export const TaskForm = React.forwardRef<HTMLFormElement>((_, ref) => {
|
||||
showButton={!!text}
|
||||
className="text-[17px] leading-5 py-[17px]"
|
||||
buttonClassName="pb-[17px]"
|
||||
disabled={navigation.state === "submitting"}
|
||||
disabled={
|
||||
navigation.state === "submitting" ||
|
||||
newConversationMutation.isPending
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
42
frontend/src/context/conversation-context.tsx
Normal file
42
frontend/src/context/conversation-context.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import React, { useMemo } from "react";
|
||||
import { useParams } from "react-router";
|
||||
|
||||
interface ConversationContextType {
|
||||
conversationId: string;
|
||||
}
|
||||
|
||||
const ConversationContext = React.createContext<ConversationContextType | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
export function ConversationProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { conversationId } = useParams<{ conversationId: string }>();
|
||||
|
||||
if (!conversationId) {
|
||||
throw new Error(
|
||||
"ConversationProvider must be used within a route that has a conversationId parameter",
|
||||
);
|
||||
}
|
||||
|
||||
const value = useMemo(() => ({ conversationId }), [conversationId]);
|
||||
|
||||
return (
|
||||
<ConversationContext.Provider value={value}>
|
||||
{children}
|
||||
</ConversationContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useConversation() {
|
||||
const context = React.useContext(ConversationContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useConversation must be used within a ConversationProvider",
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@ -1,8 +1,7 @@
|
||||
import posthog from "posthog-js";
|
||||
import React from "react";
|
||||
import { io, Socket } from "socket.io-client";
|
||||
import { Settings } from "#/services/settings";
|
||||
import ActionType from "#/types/action-type";
|
||||
|
||||
import EventLogger from "#/utils/event-logger";
|
||||
import { handleAssistantMessage } from "#/services/actions";
|
||||
import { useRate } from "#/hooks/use-rate";
|
||||
@ -36,22 +35,19 @@ const WsClientContext = React.createContext<UseWsClient>({
|
||||
|
||||
interface WsClientProviderProps {
|
||||
enabled: boolean;
|
||||
token: string | null;
|
||||
conversationId: string;
|
||||
ghToken: string | null;
|
||||
selectedRepository: string | null;
|
||||
settings: Settings | null;
|
||||
}
|
||||
|
||||
export function WsClientProvider({
|
||||
enabled,
|
||||
token,
|
||||
ghToken,
|
||||
selectedRepository,
|
||||
settings,
|
||||
conversationId,
|
||||
children,
|
||||
}: React.PropsWithChildren<WsClientProviderProps>) {
|
||||
const sioRef = React.useRef<Socket | null>(null);
|
||||
const tokenRef = React.useRef<string | null>(token);
|
||||
const ghTokenRef = React.useRef<string | null>(ghToken);
|
||||
const selectedRepositoryRef = React.useRef<string | null>(selectedRepository);
|
||||
const disconnectRef = React.useRef<ReturnType<typeof setTimeout> | null>(
|
||||
@ -73,25 +69,6 @@ export function WsClientProvider({
|
||||
|
||||
function handleConnect() {
|
||||
setStatus(WsClientProviderStatus.OPENING);
|
||||
|
||||
const initEvent: Record<string, unknown> = {
|
||||
action: ActionType.INIT,
|
||||
args: settings,
|
||||
};
|
||||
if (token) {
|
||||
initEvent.token = token;
|
||||
}
|
||||
if (ghToken) {
|
||||
initEvent.github_token = ghToken;
|
||||
}
|
||||
if (selectedRepository) {
|
||||
initEvent.selected_repository = selectedRepository;
|
||||
}
|
||||
const lastEvent = lastEventRef.current;
|
||||
if (lastEvent) {
|
||||
initEvent.latest_event_id = lastEvent.id;
|
||||
}
|
||||
send(initEvent);
|
||||
}
|
||||
|
||||
function handleMessage(event: Record<string, unknown>) {
|
||||
@ -116,9 +93,7 @@ export function WsClientProvider({
|
||||
return;
|
||||
}
|
||||
|
||||
if (!event.token) {
|
||||
handleAssistantMessage(event);
|
||||
}
|
||||
handleAssistantMessage(event);
|
||||
}
|
||||
|
||||
function handleDisconnect() {
|
||||
@ -130,11 +105,13 @@ export function WsClientProvider({
|
||||
setStatus(WsClientProviderStatus.ERROR);
|
||||
}
|
||||
|
||||
// Connect websocket
|
||||
React.useEffect(() => {
|
||||
if (!conversationId) {
|
||||
throw new Error("No conversation ID provided");
|
||||
}
|
||||
|
||||
let sio = sioRef.current;
|
||||
|
||||
// If disabled disconnect any existing websockets...
|
||||
if (!enabled) {
|
||||
if (sio) {
|
||||
sio.disconnect();
|
||||
@ -142,21 +119,22 @@ export function WsClientProvider({
|
||||
return () => {};
|
||||
}
|
||||
|
||||
// If there is no websocket or the tokens have changed or the current websocket is disconnected,
|
||||
// create a new one
|
||||
if (
|
||||
!sio ||
|
||||
(tokenRef.current && token && token !== tokenRef.current) ||
|
||||
ghToken !== ghTokenRef.current
|
||||
) {
|
||||
sio?.disconnect();
|
||||
const lastEvent = lastEventRef.current;
|
||||
const query = {
|
||||
latest_event_id: lastEvent?.id ?? -1,
|
||||
conversation_id: conversationId,
|
||||
};
|
||||
|
||||
const baseUrl =
|
||||
import.meta.env.VITE_BACKEND_BASE_URL || window?.location.host;
|
||||
sio = io(baseUrl, {
|
||||
transports: ["websocket"],
|
||||
});
|
||||
}
|
||||
const baseUrl =
|
||||
import.meta.env.VITE_BACKEND_BASE_URL || window?.location.host;
|
||||
|
||||
sio = io(baseUrl, {
|
||||
transports: ["websocket"],
|
||||
auth: {
|
||||
github_token: ghToken || undefined,
|
||||
},
|
||||
query,
|
||||
});
|
||||
sio.on("connect", handleConnect);
|
||||
sio.on("oh_event", handleMessage);
|
||||
sio.on("connect_error", handleError);
|
||||
@ -164,7 +142,6 @@ export function WsClientProvider({
|
||||
sio.on("disconnect", handleDisconnect);
|
||||
|
||||
sioRef.current = sio;
|
||||
tokenRef.current = token;
|
||||
ghTokenRef.current = ghToken;
|
||||
selectedRepositoryRef.current = selectedRepository;
|
||||
|
||||
@ -175,7 +152,7 @@ export function WsClientProvider({
|
||||
sio.off("connect_failed", handleError);
|
||||
sio.off("disconnect", handleDisconnect);
|
||||
};
|
||||
}, [enabled, token, ghToken, selectedRepository]);
|
||||
}, [enabled, ghToken, selectedRepository, conversationId]);
|
||||
|
||||
// Strict mode mounts and unmounts each component twice, so we have to wait in the destructor
|
||||
// before actually disconnecting the socket and cancel the operation if the component gets remounted.
|
||||
|
||||
@ -1,17 +1,20 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import toast from "react-hot-toast";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useConversation } from "#/context/conversation-context";
|
||||
|
||||
type SaveFileArgs = {
|
||||
path: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
export const useSaveFile = () =>
|
||||
useMutation({
|
||||
export const useSaveFile = () => {
|
||||
const { conversationId } = useConversation();
|
||||
return useMutation({
|
||||
mutationFn: ({ path, content }: SaveFileArgs) =>
|
||||
OpenHands.saveFile(path, content),
|
||||
OpenHands.saveFile(conversationId, path, content),
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@ -2,16 +2,19 @@ import { useMutation } from "@tanstack/react-query";
|
||||
import toast from "react-hot-toast";
|
||||
import { Feedback } from "#/api/open-hands.types";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useConversation } from "#/context/conversation-context";
|
||||
|
||||
type SubmitFeedbackArgs = {
|
||||
feedback: Feedback;
|
||||
};
|
||||
|
||||
export const useSubmitFeedback = () =>
|
||||
useMutation({
|
||||
export const useSubmitFeedback = () => {
|
||||
const { conversationId } = useConversation();
|
||||
return useMutation({
|
||||
mutationFn: ({ feedback }: SubmitFeedbackArgs) =>
|
||||
OpenHands.submitFeedback(feedback),
|
||||
OpenHands.submitFeedback(conversationId, feedback),
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@ -1,11 +1,15 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useConversation } from "#/context/conversation-context";
|
||||
|
||||
type UploadFilesArgs = {
|
||||
files: File[];
|
||||
};
|
||||
|
||||
export const useUploadFiles = () =>
|
||||
useMutation({
|
||||
mutationFn: ({ files }: UploadFilesArgs) => OpenHands.uploadFiles(files),
|
||||
export const useUploadFiles = () => {
|
||||
const { conversationId } = useConversation();
|
||||
return useMutation({
|
||||
mutationFn: ({ files }: UploadFilesArgs) =>
|
||||
OpenHands.uploadFiles(conversationId, files),
|
||||
});
|
||||
};
|
||||
|
||||
@ -4,15 +4,20 @@ import {
|
||||
useWsClient,
|
||||
WsClientProviderStatus,
|
||||
} from "#/context/ws-client-provider";
|
||||
import { useConversation } from "#/context/conversation-context";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
|
||||
export const useConversationConfig = () => {
|
||||
const { status } = useWsClient();
|
||||
const { conversationId } = useConversation();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["conversation_config"],
|
||||
queryFn: OpenHands.getRuntimeId,
|
||||
enabled: status === WsClientProviderStatus.ACTIVE,
|
||||
queryKey: ["conversation_config", conversationId],
|
||||
queryFn: () => {
|
||||
if (!conversationId) throw new Error("No conversation ID");
|
||||
return OpenHands.getRuntimeId(conversationId);
|
||||
},
|
||||
enabled: status === WsClientProviderStatus.ACTIVE && !!conversationId,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
|
||||
@ -1,13 +1,16 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useConversation } from "#/context/conversation-context";
|
||||
|
||||
interface UseListFileConfig {
|
||||
path: string;
|
||||
}
|
||||
|
||||
export const useListFile = (config: UseListFileConfig) =>
|
||||
useQuery({
|
||||
queryKey: ["file", config.path],
|
||||
queryFn: () => OpenHands.getFile(config.path),
|
||||
export const useListFile = (config: UseListFileConfig) => {
|
||||
const { conversationId } = useConversation();
|
||||
return useQuery({
|
||||
queryKey: ["file", conversationId, config.path],
|
||||
queryFn: () => OpenHands.getFile(conversationId, config.path),
|
||||
enabled: false, // don't fetch by default, trigger manually via `refetch`
|
||||
});
|
||||
};
|
||||
|
||||
@ -4,6 +4,7 @@ import {
|
||||
WsClientProviderStatus,
|
||||
} from "#/context/ws-client-provider";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useConversation } from "#/context/conversation-context";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
|
||||
interface UseListFilesConfig {
|
||||
@ -13,12 +14,13 @@ interface UseListFilesConfig {
|
||||
|
||||
export const useListFiles = (config?: UseListFilesConfig) => {
|
||||
const { token } = useAuth();
|
||||
const { conversationId } = useConversation();
|
||||
const { status } = useWsClient();
|
||||
const isActive = status === WsClientProviderStatus.ACTIVE;
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["files", token, config?.path],
|
||||
queryFn: () => OpenHands.getFiles(config?.path),
|
||||
queryKey: ["files", token, conversationId, config?.path],
|
||||
queryFn: () => OpenHands.getFiles(conversationId, config?.path),
|
||||
enabled: !!(isActive && config?.enabled && token),
|
||||
});
|
||||
};
|
||||
|
||||
24
frontend/src/hooks/query/use-search-events.ts
Normal file
24
frontend/src/hooks/query/use-search-events.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useConversation } from "#/context/conversation-context";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
|
||||
export const useSearchEvents = (params: {
|
||||
query?: string;
|
||||
startId?: number;
|
||||
limit?: number;
|
||||
eventType?: string;
|
||||
source?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}) => {
|
||||
const { conversationId } = useConversation();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["search_events", conversationId, params],
|
||||
queryFn: () => {
|
||||
if (!conversationId) throw new Error("No conversation ID");
|
||||
return OpenHands.searchEvents(conversationId, params);
|
||||
},
|
||||
enabled: !!conversationId,
|
||||
});
|
||||
};
|
||||
@ -1,11 +1,17 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useConversation } from "#/context/conversation-context";
|
||||
|
||||
export const useVSCodeUrl = (config: { enabled: boolean }) => {
|
||||
const { conversationId } = useConversation();
|
||||
|
||||
const data = useQuery({
|
||||
queryKey: ["vscode_url"],
|
||||
queryFn: OpenHands.getVSCodeUrl,
|
||||
enabled: config.enabled,
|
||||
queryKey: ["vscode_url", conversationId],
|
||||
queryFn: () => {
|
||||
if (!conversationId) throw new Error("No conversation ID");
|
||||
return OpenHands.getVSCodeUrl(conversationId);
|
||||
},
|
||||
enabled: !!conversationId && config.enabled,
|
||||
refetchOnMount: false,
|
||||
});
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { downloadFiles } from "#/utils/download-files";
|
||||
import { DownloadProgressState } from "#/components/shared/download-progress";
|
||||
import { useConversation } from "#/context/conversation-context";
|
||||
|
||||
export const INITIAL_PROGRESS: DownloadProgressState = {
|
||||
filesTotal: 0,
|
||||
@ -20,6 +21,7 @@ export function useDownloadProgress(
|
||||
useState<DownloadProgressState>(INITIAL_PROGRESS);
|
||||
const progressRef = useRef<DownloadProgressState>(INITIAL_PROGRESS);
|
||||
const abortController = useRef<AbortController>();
|
||||
const { conversationId } = useConversation();
|
||||
|
||||
// Create AbortController on mount
|
||||
useEffect(() => {
|
||||
@ -45,7 +47,7 @@ export function useDownloadProgress(
|
||||
// Start download
|
||||
const download = async () => {
|
||||
try {
|
||||
await downloadFiles(initialPath, {
|
||||
await downloadFiles(conversationId, initialPath, {
|
||||
onProgress: (p) => {
|
||||
// Update both the ref and state
|
||||
progressRef.current = { ...p };
|
||||
|
||||
@ -8,7 +8,7 @@ import {
|
||||
export default [
|
||||
layout("routes/_oh/route.tsx", [
|
||||
index("routes/_oh._index/route.tsx"),
|
||||
route("app", "routes/_oh.app/route.tsx", [
|
||||
route("conversations/:conversationId", "routes/_oh.app/route.tsx", [
|
||||
index("routes/_oh.app._index/route.tsx"),
|
||||
route("browser", "routes/_oh.app.browser.tsx"),
|
||||
route("jupyter", "routes/_oh.app.jupyter.tsx"),
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import { useLocation, useNavigate } from "react-router";
|
||||
import React from "react";
|
||||
import { useDispatch } from "react-redux";
|
||||
import posthog from "posthog-js";
|
||||
@ -17,12 +16,8 @@ import { HeroHeading } from "#/components/shared/hero-heading";
|
||||
import { TaskForm } from "#/components/shared/task-form";
|
||||
|
||||
function Home() {
|
||||
const { token, gitHubToken } = useAuth();
|
||||
|
||||
const { gitHubToken } = useAuth();
|
||||
const dispatch = useDispatch();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const formRef = React.useRef<HTMLFormElement>(null);
|
||||
|
||||
const { data: config } = useConfig();
|
||||
@ -36,10 +31,6 @@ function Home() {
|
||||
gitHubClientId: config?.GITHUB_CLIENT_ID || null,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (token) navigate("/app");
|
||||
}, [location.pathname]);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="root-index"
|
||||
|
||||
@ -2,6 +2,10 @@ import { useDisclosure } from "@nextui-org/react";
|
||||
import React from "react";
|
||||
import { Outlet } from "react-router";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import {
|
||||
ConversationProvider,
|
||||
useConversation,
|
||||
} from "#/context/conversation-context";
|
||||
import { Controls } from "#/components/features/controls/controls";
|
||||
import { RootState } from "#/store";
|
||||
import { clearMessages } from "#/state/chat-slice";
|
||||
@ -24,9 +28,10 @@ import Security from "#/components/shared/modals/security/security";
|
||||
import { CountBadge } from "#/components/layout/count-badge";
|
||||
import { TerminalStatusLabel } from "#/components/features/terminal/terminal-status-label";
|
||||
|
||||
function App() {
|
||||
const { token, gitHubToken } = useAuth();
|
||||
function AppContent() {
|
||||
const { gitHubToken } = useAuth();
|
||||
const { settings } = useUserPrefs();
|
||||
const { conversationId } = useConversation();
|
||||
|
||||
const dispatch = useDispatch();
|
||||
useConversationConfig();
|
||||
@ -42,8 +47,8 @@ function App() {
|
||||
});
|
||||
|
||||
const secrets = React.useMemo(
|
||||
() => [gitHubToken, token].filter((secret) => secret !== null),
|
||||
[gitHubToken, token],
|
||||
() => [gitHubToken].filter((secret) => secret !== null),
|
||||
[gitHubToken],
|
||||
);
|
||||
|
||||
const Terminal = React.useMemo(
|
||||
@ -66,10 +71,9 @@ function App() {
|
||||
return (
|
||||
<WsClientProvider
|
||||
enabled
|
||||
token={token}
|
||||
ghToken={gitHubToken}
|
||||
selectedRepository={selectedRepository}
|
||||
settings={settings}
|
||||
conversationId={conversationId}
|
||||
>
|
||||
<EventHandler>
|
||||
<div className="flex flex-col h-full gap-3">
|
||||
@ -131,4 +135,12 @@ function App() {
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ConversationProvider>
|
||||
<AppContent />
|
||||
</ConversationProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
@ -38,11 +38,12 @@ async function createSubdirectories(
|
||||
* Recursively gets all files in a directory
|
||||
*/
|
||||
async function getAllFiles(
|
||||
conversationID: string,
|
||||
path: string,
|
||||
progress: DownloadProgress,
|
||||
options?: DownloadOptions,
|
||||
): Promise<string[]> {
|
||||
const entries = await OpenHands.getFiles(path);
|
||||
const entries = await OpenHands.getFiles(conversationID, path);
|
||||
|
||||
const processEntry = async (entry: string): Promise<string[]> => {
|
||||
if (options?.signal?.aborted) {
|
||||
@ -51,7 +52,7 @@ async function getAllFiles(
|
||||
|
||||
const fullPath = path + entry;
|
||||
if (entry.endsWith("/")) {
|
||||
const subEntries = await OpenHands.getFiles(fullPath);
|
||||
const subEntries = await OpenHands.getFiles(conversationID, fullPath);
|
||||
const subFilesPromises = subEntries.map((subEntry) =>
|
||||
processEntry(subEntry),
|
||||
);
|
||||
@ -83,6 +84,7 @@ async function getAllFiles(
|
||||
* Process a batch of files
|
||||
*/
|
||||
async function processBatch(
|
||||
conversationID: string,
|
||||
batch: string[],
|
||||
directoryHandle: FileSystemDirectoryHandle,
|
||||
progress: DownloadProgress,
|
||||
@ -110,7 +112,7 @@ async function processBatch(
|
||||
};
|
||||
options?.onProgress?.(newProgress);
|
||||
|
||||
const content = await OpenHands.getFile(path);
|
||||
const content = await OpenHands.getFile(conversationID, path);
|
||||
|
||||
// Save to the selected directory preserving structure
|
||||
const pathParts = path.split("/").filter(Boolean);
|
||||
@ -165,6 +167,7 @@ async function processBatch(
|
||||
* @param options Download options including progress callback and abort signal
|
||||
*/
|
||||
export async function downloadFiles(
|
||||
conversationID: string,
|
||||
initialPath?: string,
|
||||
options?: DownloadOptions,
|
||||
): Promise<void> {
|
||||
@ -203,7 +206,12 @@ export async function downloadFiles(
|
||||
}
|
||||
|
||||
// Then recursively get all files
|
||||
const files = await getAllFiles(initialPath || "", progress, options);
|
||||
const files = await getAllFiles(
|
||||
conversationID,
|
||||
initialPath || "",
|
||||
progress,
|
||||
options,
|
||||
);
|
||||
|
||||
// Set isDiscoveringFiles to false now that we have the full list and preserve filesTotal
|
||||
const finalTotal = progress.filesTotal;
|
||||
@ -270,6 +278,7 @@ export async function downloadFiles(
|
||||
(promise, batch) =>
|
||||
promise.then(async () => {
|
||||
const { newCompleted, newBytes } = await processBatch(
|
||||
conversationID,
|
||||
batch,
|
||||
directoryHandle,
|
||||
progress,
|
||||
|
||||
@ -3,8 +3,8 @@ import OpenHands from "#/api/open-hands";
|
||||
/**
|
||||
* Downloads the current workspace as a .zip file.
|
||||
*/
|
||||
export const downloadWorkspace = async () => {
|
||||
const blob = await OpenHands.getWorkspaceZip();
|
||||
export const downloadWorkspace = async (conversationId: string) => {
|
||||
const blob = await OpenHands.getWorkspaceZip(conversationId);
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
|
||||
@ -10,8 +10,20 @@ import { I18nextProvider } from "react-i18next";
|
||||
import i18n from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import { AppStore, RootState, rootReducer } from "./src/store";
|
||||
import { vi } from "vitest";
|
||||
import { AuthProvider } from "#/context/auth-context";
|
||||
import { UserPrefsProvider } from "#/context/user-prefs-context";
|
||||
import { ConversationProvider } from "#/context/conversation-context";
|
||||
|
||||
// Mock useParams before importing components
|
||||
vi.mock("react-router", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("react-router")>("react-router");
|
||||
return {
|
||||
...actual,
|
||||
useParams: () => ({ conversationId: "test-conversation-id" }),
|
||||
};
|
||||
});
|
||||
|
||||
// Initialize i18n for tests
|
||||
i18n
|
||||
@ -60,11 +72,13 @@ export function renderWithProviders(
|
||||
<Provider store={store}>
|
||||
<UserPrefsProvider>
|
||||
<AuthProvider>
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
{children}
|
||||
</I18nextProvider>
|
||||
</QueryClientProvider>
|
||||
<ConversationProvider>
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
{children}
|
||||
</I18nextProvider>
|
||||
</QueryClientProvider>
|
||||
</ConversationProvider>
|
||||
</AuthProvider>
|
||||
</UserPrefsProvider>
|
||||
</Provider>
|
||||
|
||||
@ -10,6 +10,11 @@ from openhands.core.utils import json
|
||||
from openhands.events.event import Event, EventSource
|
||||
from openhands.events.serialization.event import event_from_dict, event_to_dict
|
||||
from openhands.storage import FileStore
|
||||
from openhands.storage.locations import (
|
||||
get_conversation_dir,
|
||||
get_conversation_event_file,
|
||||
get_conversation_events_dir,
|
||||
)
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
from openhands.utils.shutdown_listener import should_continue
|
||||
|
||||
@ -26,7 +31,7 @@ class EventStreamSubscriber(str, Enum):
|
||||
|
||||
async def session_exists(sid: str, file_store: FileStore) -> bool:
|
||||
try:
|
||||
await call_sync_from_async(file_store.list, f'sessions/{sid}')
|
||||
await call_sync_from_async(file_store.list, get_conversation_dir(sid))
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
@ -59,7 +64,7 @@ class EventStream:
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
try:
|
||||
events = self.file_store.list(f'sessions/{self.sid}/events')
|
||||
events = self.file_store.list(get_conversation_events_dir(self.sid))
|
||||
except FileNotFoundError:
|
||||
logger.debug(f'No events found for session {self.sid}')
|
||||
self._cur_id = 0
|
||||
@ -72,7 +77,7 @@ class EventStream:
|
||||
self._cur_id = id + 1
|
||||
|
||||
def _get_filename_for_id(self, id: int) -> str:
|
||||
return f'sessions/{self.sid}/events/{id}.json'
|
||||
return get_conversation_event_file(self.sid, id)
|
||||
|
||||
@staticmethod
|
||||
def _get_id_from_filename(filename: str) -> int:
|
||||
@ -299,9 +304,3 @@ class EventStream:
|
||||
break
|
||||
|
||||
return matching_events
|
||||
|
||||
def clear(self):
|
||||
self.file_store.delete(f'sessions/{self.sid}')
|
||||
self._cur_id = 0
|
||||
# self._subscribers = {}
|
||||
self.__post_init__()
|
||||
|
||||
@ -10,16 +10,16 @@ from fastapi import (
|
||||
|
||||
import openhands.agenthub # noqa F401 (we import this to get the agents registered)
|
||||
from openhands.server.middleware import (
|
||||
AttachSessionMiddleware,
|
||||
AttachConversationMiddleware,
|
||||
InMemoryRateLimiter,
|
||||
LocalhostCORSMiddleware,
|
||||
NoCacheMiddleware,
|
||||
RateLimitMiddleware,
|
||||
)
|
||||
from openhands.server.routes.conversation import app as conversation_api_router
|
||||
from openhands.server.routes.feedback import app as feedback_api_router
|
||||
from openhands.server.routes.files import app as files_api_router
|
||||
from openhands.server.routes.github import app as github_api_router
|
||||
from openhands.server.routes.new_conversation import app as new_conversation_api_router
|
||||
from openhands.server.routes.public import app as public_api_router
|
||||
from openhands.server.routes.security import app as security_api_router
|
||||
from openhands.server.routes.settings import app as settings_router
|
||||
@ -54,23 +54,13 @@ async def health():
|
||||
|
||||
app.include_router(public_api_router)
|
||||
app.include_router(files_api_router)
|
||||
app.include_router(conversation_api_router)
|
||||
app.include_router(security_api_router)
|
||||
app.include_router(feedback_api_router)
|
||||
app.include_router(new_conversation_api_router)
|
||||
app.include_router(settings_router)
|
||||
app.include_router(github_api_router)
|
||||
|
||||
|
||||
AttachSessionMiddlewareImpl = get_impl(
|
||||
AttachSessionMiddleware, openhands_config.attach_session_middleware_path
|
||||
)
|
||||
app.middleware('http')(AttachSessionMiddlewareImpl(app, target_router=files_api_router))
|
||||
app.middleware('http')(
|
||||
AttachSessionMiddlewareImpl(app, target_router=conversation_api_router)
|
||||
)
|
||||
app.middleware('http')(
|
||||
AttachSessionMiddlewareImpl(app, target_router=security_api_router)
|
||||
)
|
||||
app.middleware('http')(
|
||||
AttachSessionMiddlewareImpl(app, target_router=feedback_api_router)
|
||||
AttachConversationMiddlewareImpl = get_impl(
|
||||
AttachConversationMiddleware, openhands_config.attach_conversation_middleware_path
|
||||
)
|
||||
app.middleware('http')(AttachConversationMiddlewareImpl(app))
|
||||
|
||||
@ -12,8 +12,8 @@ class OpenhandsConfig(OpenhandsConfigInterface):
|
||||
app_mode = AppMode.OSS
|
||||
posthog_client_key = 'phc_3ESMmY9SgqEAGBB6sMGK5ayYHkeUuknH2vP6FmWH9RA'
|
||||
github_client_id = os.environ.get('GITHUB_APP_CLIENT_ID', '')
|
||||
attach_session_middleware_path = (
|
||||
'openhands.server.middleware.AttachSessionMiddleware'
|
||||
attach_conversation_middleware_path = (
|
||||
'openhands.server.middleware.AttachConversationMiddleware'
|
||||
)
|
||||
settings_store_class: str = (
|
||||
'openhands.storage.file_settings_store.FileSettingsStore'
|
||||
@ -39,22 +39,12 @@ class OpenhandsConfig(OpenhandsConfigInterface):
|
||||
|
||||
return config
|
||||
|
||||
async def github_auth(self, data: dict):
|
||||
"""
|
||||
Skip Github Auth for AppMode OSS
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
def load_openhands_config():
|
||||
config_cls = os.environ.get('OPENHANDS_CONFIG_CLS', None)
|
||||
logger.info(f'Using config class {config_cls}')
|
||||
|
||||
if config_cls:
|
||||
openhands_config_cls = get_impl(OpenhandsConfig, config_cls)
|
||||
else:
|
||||
openhands_config_cls = OpenhandsConfig
|
||||
|
||||
openhands_config_cls = get_impl(OpenhandsConfig, config_cls)
|
||||
openhands_config = openhands_config_cls()
|
||||
openhands_config.verify_config()
|
||||
|
||||
|
||||
@ -1,5 +1,9 @@
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
from github import Github
|
||||
from socketio.exceptions import ConnectionRefusedError
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.schema.action import ActionType
|
||||
from openhands.events.action import (
|
||||
NullAction,
|
||||
)
|
||||
@ -9,73 +13,50 @@ from openhands.events.observation import (
|
||||
from openhands.events.observation.agent import AgentStateChangedObservation
|
||||
from openhands.events.serialization import event_to_dict
|
||||
from openhands.events.stream import AsyncEventStreamWrapper
|
||||
from openhands.server.auth import get_sid_from_token, sign_token
|
||||
from openhands.server.routes.settings import SettingsStoreImpl
|
||||
from openhands.server.session.session_init_data import SessionInitData
|
||||
from openhands.server.session.manager import ConversationDoesNotExistError
|
||||
from openhands.server.shared import config, session_manager, sio
|
||||
from openhands.storage.conversation.conversation_store import (
|
||||
ConversationStore,
|
||||
)
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
|
||||
|
||||
@sio.event
|
||||
async def connect(connection_id: str, environ):
|
||||
async def connect(connection_id: str, environ, auth):
|
||||
logger.info(f'sio:connect: {connection_id}')
|
||||
query_params = parse_qs(environ.get('QUERY_STRING', ''))
|
||||
latest_event_id = int(query_params.get('latest_event_id', [-1])[0])
|
||||
conversation_id = query_params.get('conversation_id', [None])[0]
|
||||
if not conversation_id:
|
||||
logger.error('No conversation_id in query params')
|
||||
raise ConnectionRefusedError('No conversation_id in query params')
|
||||
|
||||
user_id = ''
|
||||
if auth and 'github_token' in auth:
|
||||
g = Github(auth['github_token'])
|
||||
gh_user = await call_sync_from_async(g.get_user)
|
||||
user_id = gh_user.id
|
||||
|
||||
@sio.event
|
||||
async def oh_action(connection_id: str, data: dict):
|
||||
# If it's an init, we do it here.
|
||||
action = data.get('action', '')
|
||||
if action == ActionType.INIT:
|
||||
await init_connection(
|
||||
connection_id=connection_id,
|
||||
token=data.get('token', None),
|
||||
github_token=data.get('github_token', None),
|
||||
session_init_args={
|
||||
k.lower(): v for k, v in (data.get('args') or {}).items()
|
||||
},
|
||||
latest_event_id=int(data.get('latest_event_id', -1)),
|
||||
selected_repository=data.get('selected_repository'),
|
||||
logger.info(f'User {user_id} is connecting to conversation {conversation_id}')
|
||||
|
||||
conversation_store = await ConversationStore.get_instance(config)
|
||||
metadata = await conversation_store.get_metadata(conversation_id)
|
||||
if metadata.github_user_id != user_id:
|
||||
logger.error(
|
||||
f'User {user_id} is not allowed to join conversation {conversation_id}'
|
||||
)
|
||||
raise ConnectionRefusedError(
|
||||
f'User {user_id} is not allowed to join conversation {conversation_id}'
|
||||
)
|
||||
return
|
||||
|
||||
logger.info(f'sio:oh_action:{connection_id}')
|
||||
await session_manager.send_to_event_stream(connection_id, data)
|
||||
try:
|
||||
event_stream = await session_manager.join_conversation(
|
||||
conversation_id, connection_id
|
||||
)
|
||||
except ConversationDoesNotExistError:
|
||||
logger.error(f'Conversation {conversation_id} does not exist')
|
||||
raise ConnectionRefusedError(f'Conversation {conversation_id} does not exist')
|
||||
|
||||
|
||||
async def init_connection(
|
||||
connection_id: str,
|
||||
token: str | None,
|
||||
github_token: str | None,
|
||||
session_init_args: dict,
|
||||
latest_event_id: int,
|
||||
selected_repository: str | None,
|
||||
):
|
||||
settings_store = await SettingsStoreImpl.get_instance(config, github_token)
|
||||
settings = await settings_store.load()
|
||||
if settings:
|
||||
session_init_args = {**settings.__dict__, **session_init_args}
|
||||
session_init_args['github_token'] = github_token
|
||||
session_init_args['selected_repository'] = selected_repository
|
||||
session_init_data = SessionInitData(**session_init_args)
|
||||
|
||||
if token:
|
||||
sid = get_sid_from_token(token, config.jwt_secret)
|
||||
if sid == '':
|
||||
await sio.emit('oh_event', {'error': 'Invalid token', 'error_code': 401})
|
||||
return
|
||||
logger.info(f'Existing session: {sid}')
|
||||
else:
|
||||
sid = connection_id
|
||||
logger.info(f'New session: {sid}')
|
||||
|
||||
token = sign_token({'sid': sid}, config.jwt_secret)
|
||||
await sio.emit('oh_event', {'token': token, 'status': 'ok'}, to=connection_id)
|
||||
|
||||
# The session in question should exist, but may not actually be running locally...
|
||||
event_stream = await session_manager.init_or_join_session(
|
||||
sid, connection_id, session_init_data
|
||||
)
|
||||
|
||||
# Send events
|
||||
agent_state_changed = None
|
||||
async_stream = AsyncEventStreamWrapper(event_stream, latest_event_id + 1)
|
||||
async for event in async_stream:
|
||||
@ -98,6 +79,11 @@ async def init_connection(
|
||||
await sio.emit('oh_event', event_to_dict(agent_state_changed), to=connection_id)
|
||||
|
||||
|
||||
@sio.event
|
||||
async def oh_action(connection_id: str, data: dict):
|
||||
await session_manager.send_to_event_stream(connection_id, data)
|
||||
|
||||
|
||||
@sio.event
|
||||
async def disconnect(connection_id: str):
|
||||
logger.info(f'sio:disconnect:{connection_id}')
|
||||
|
||||
@ -4,15 +4,13 @@ from datetime import datetime, timedelta
|
||||
from typing import Callable
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import APIRouter, Request, status
|
||||
from fastapi import Request, status
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.types import ASGIApp
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.auth import get_sid_from_token
|
||||
from openhands.server.shared import config, session_manager
|
||||
from openhands.server.shared import session_manager
|
||||
from openhands.server.types import SessionMiddlewareInterface
|
||||
|
||||
|
||||
@ -108,11 +106,9 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
class AttachSessionMiddleware(SessionMiddlewareInterface):
|
||||
def __init__(self, app, target_router: APIRouter):
|
||||
class AttachConversationMiddleware(SessionMiddlewareInterface):
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
self.target_router = target_router
|
||||
self.target_paths = {route.path for route in target_router.routes}
|
||||
|
||||
def _should_attach(self, request) -> bool:
|
||||
"""
|
||||
@ -120,26 +116,24 @@ class AttachSessionMiddleware(SessionMiddlewareInterface):
|
||||
"""
|
||||
if request.method == 'OPTIONS':
|
||||
return False
|
||||
if request.url.path not in self.target_paths:
|
||||
|
||||
conversation_id = ''
|
||||
if request.url.path.startswith('/api/conversation'):
|
||||
# FIXME: we should be able to use path_params
|
||||
path_parts = request.url.path.split('/')
|
||||
if len(path_parts) > 3:
|
||||
conversation_id = request.url.path.split('/')[3]
|
||||
if not conversation_id:
|
||||
return False
|
||||
|
||||
request.state.sid = conversation_id
|
||||
|
||||
return True
|
||||
|
||||
async def _attach_session(self, request: Request) -> JSONResponse | None:
|
||||
async def _attach_conversation(self, request: Request) -> JSONResponse | None:
|
||||
"""
|
||||
Attach the user's session based on the provided authentication token.
|
||||
"""
|
||||
auth_token = request.headers.get('Authorization', '')
|
||||
if 'Bearer' in auth_token:
|
||||
auth_token = auth_token.split('Bearer')[1].strip()
|
||||
|
||||
request.state.sid = get_sid_from_token(auth_token, config.jwt_secret)
|
||||
if not request.state.sid:
|
||||
logger.warning('Invalid token')
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
content={'error': 'Invalid token'},
|
||||
)
|
||||
|
||||
request.state.conversation = await session_manager.attach_to_conversation(
|
||||
request.state.sid
|
||||
)
|
||||
@ -160,7 +154,7 @@ class AttachSessionMiddleware(SessionMiddlewareInterface):
|
||||
if not self._should_attach(request):
|
||||
return await call_next(request)
|
||||
|
||||
response = await self._attach_session(request)
|
||||
response = await self._attach_conversation(request)
|
||||
if response:
|
||||
return response
|
||||
|
||||
|
||||
@ -4,10 +4,10 @@ from fastapi.responses import JSONResponse
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.runtime.base import Runtime
|
||||
|
||||
app = APIRouter(prefix='/api')
|
||||
app = APIRouter(prefix='/api/conversations/{conversation_id}')
|
||||
|
||||
|
||||
@app.get('/conversation')
|
||||
@app.get('/config')
|
||||
async def get_remote_runtime_config(request: Request):
|
||||
"""Retrieve the runtime configuration.
|
||||
|
||||
|
||||
@ -5,14 +5,13 @@ from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events.serialization import event_to_dict
|
||||
from openhands.events.stream import AsyncEventStreamWrapper
|
||||
from openhands.server.data_models.feedback import FeedbackDataModel, store_feedback
|
||||
from openhands.server.shared import config
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
|
||||
app = APIRouter(prefix='/api')
|
||||
app = APIRouter(prefix='/api/conversations/{conversation_id}')
|
||||
|
||||
|
||||
@app.post('/submit-feedback')
|
||||
async def submit_feedback(request: Request):
|
||||
async def submit_feedback(request: Request, conversation_id: str):
|
||||
"""Submit user feedback.
|
||||
|
||||
This function stores the provided feedback data.
|
||||
@ -57,18 +56,3 @@ async def submit_feedback(request: Request):
|
||||
return JSONResponse(
|
||||
status_code=500, content={'error': 'Failed to submit feedback'}
|
||||
)
|
||||
|
||||
|
||||
@app.get('/api/defaults')
|
||||
async def appconfig_defaults():
|
||||
"""Retrieve the default configuration settings.
|
||||
|
||||
To get the default configurations:
|
||||
```sh
|
||||
curl http://localhost:3000/api/defaults
|
||||
```
|
||||
|
||||
Returns:
|
||||
dict: The default configuration settings.
|
||||
"""
|
||||
return config.defaults_dict
|
||||
|
||||
@ -33,11 +33,11 @@ from openhands.server.file_config import (
|
||||
)
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
|
||||
app = APIRouter(prefix='/api')
|
||||
app = APIRouter(prefix='/api/conversations/{conversation_id}')
|
||||
|
||||
|
||||
@app.get('/list-files')
|
||||
async def list_files(request: Request, path: str | None = None):
|
||||
async def list_files(request: Request, conversation_id: str, path: str | None = None):
|
||||
"""List files in the specified path.
|
||||
|
||||
This function retrieves a list of files from the agent's runtime file store,
|
||||
@ -149,7 +149,7 @@ async def select_file(file: str, request: Request):
|
||||
|
||||
|
||||
@app.post('/upload-files')
|
||||
async def upload_file(request: Request, files: list[UploadFile]):
|
||||
async def upload_file(request: Request, conversation_id: str, files: list[UploadFile]):
|
||||
"""Upload a list of files to the workspace.
|
||||
|
||||
To upload a files:
|
||||
@ -311,7 +311,9 @@ async def save_file(request: Request):
|
||||
|
||||
|
||||
@app.get('/zip-directory')
|
||||
async def zip_current_workspace(request: Request, background_tasks: BackgroundTasks):
|
||||
async def zip_current_workspace(
|
||||
request: Request, conversation_id: str, background_tasks: BackgroundTasks
|
||||
):
|
||||
try:
|
||||
logger.debug('Zipping workspace')
|
||||
runtime: Runtime = request.state.conversation.runtime
|
||||
|
||||
74
openhands/server/routes/new_conversation.py
Normal file
74
openhands/server/routes/new_conversation.py
Normal file
@ -0,0 +1,74 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from github import Github
|
||||
from pydantic import BaseModel
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.routes.settings import SettingsStoreImpl
|
||||
from openhands.server.session.session_init_data import SessionInitData
|
||||
from openhands.server.shared import config, session_manager
|
||||
from openhands.storage.conversation.conversation_store import (
|
||||
ConversationMetadata,
|
||||
ConversationStore,
|
||||
)
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
|
||||
app = APIRouter(prefix='/api')
|
||||
|
||||
|
||||
class InitSessionRequest(BaseModel):
|
||||
github_token: str | None = None
|
||||
latest_event_id: int = -1
|
||||
selected_repository: str | None = None
|
||||
args: dict | None = None
|
||||
|
||||
|
||||
@app.post('/conversations')
|
||||
async def new_conversation(request: Request, data: InitSessionRequest):
|
||||
"""Initialize a new session or join an existing one.
|
||||
After successful initialization, the client should connect to the WebSocket
|
||||
using the returned conversation ID
|
||||
"""
|
||||
github_token = ''
|
||||
if data.github_token:
|
||||
github_token = data.github_token
|
||||
|
||||
settings_store = await SettingsStoreImpl.get_instance(config, github_token)
|
||||
settings = await settings_store.load()
|
||||
|
||||
session_init_args: dict = {}
|
||||
if settings:
|
||||
session_init_args = {**settings.__dict__, **session_init_args}
|
||||
if data.args:
|
||||
for key, value in data.args.items():
|
||||
session_init_args[key.lower()] = value
|
||||
|
||||
session_init_args['github_token'] = github_token
|
||||
session_init_args['selected_repository'] = data.selected_repository
|
||||
session_init_data = SessionInitData(**session_init_args)
|
||||
|
||||
conversation_store = await ConversationStore.get_instance(config)
|
||||
|
||||
conversation_id = uuid.uuid4().hex
|
||||
while await conversation_store.exists(conversation_id):
|
||||
logger.warning(f'Collision on conversation ID: {conversation_id}. Retrying...')
|
||||
conversation_id = uuid.uuid4().hex
|
||||
|
||||
user_id = ''
|
||||
if data.github_token:
|
||||
g = Github(data.github_token)
|
||||
gh_user = await call_sync_from_async(g.get_user)
|
||||
user_id = gh_user.id
|
||||
|
||||
await conversation_store.save_metadata(
|
||||
ConversationMetadata(
|
||||
conversation_id=conversation_id,
|
||||
github_user_id=user_id,
|
||||
selected_repository=data.selected_repository,
|
||||
)
|
||||
)
|
||||
|
||||
await session_manager.start_agent_loop(conversation_id, session_init_data)
|
||||
return JSONResponse(content={'status': 'ok', 'conversation_id': conversation_id})
|
||||
@ -4,7 +4,7 @@ from fastapi import (
|
||||
Request,
|
||||
)
|
||||
|
||||
app = APIRouter(prefix='/api')
|
||||
app = APIRouter(prefix='/api/conversations/{conversation_id}')
|
||||
|
||||
|
||||
@app.route('/security/{path:path}', methods=['GET', 'POST', 'PUT', 'DELETE'])
|
||||
|
||||
@ -221,6 +221,11 @@ class AgentSession:
|
||||
headless_mode=False,
|
||||
)
|
||||
|
||||
# FIXME: this sleep is a terrible hack.
|
||||
# This is to give the websocket a second to connect, so that
|
||||
# the status messages make it through to the frontend.
|
||||
# We should find a better way to plumb status messages through.
|
||||
await asyncio.sleep(1)
|
||||
try:
|
||||
await self.runtime.connect()
|
||||
except AgentRuntimeUnavailableError as e:
|
||||
|
||||
@ -19,6 +19,10 @@ _REDIS_POLL_TIMEOUT = 1.5
|
||||
_CHECK_ALIVE_INTERVAL = 15
|
||||
|
||||
|
||||
class ConversationDoesNotExistError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class SessionManager:
|
||||
sio: socketio.AsyncServer
|
||||
@ -170,6 +174,25 @@ class SessionManager:
|
||||
self._active_conversations[sid] = (c, 1)
|
||||
return c
|
||||
|
||||
async def join_conversation(self, sid: str, connection_id: str) -> EventStream:
|
||||
await self.sio.enter_room(connection_id, ROOM_KEY.format(sid=sid))
|
||||
self.local_connection_id_to_session_id[connection_id] = sid
|
||||
|
||||
# If we have a local session running, use that
|
||||
session = self.local_sessions_by_sid.get(sid)
|
||||
if session:
|
||||
logger.info(f'found_local_session:{sid}')
|
||||
return session.agent_session.event_stream
|
||||
|
||||
# If there is a remote session running, retrieve existing events for that
|
||||
redis_client = self._get_redis_client()
|
||||
if redis_client and await self._is_session_running_in_cluster(sid):
|
||||
return EventStream(sid, self.file_store)
|
||||
|
||||
raise ConversationDoesNotExistError(
|
||||
f'no_conversation_for_id:{connection_id}:{sid}'
|
||||
)
|
||||
|
||||
async def detach_from_conversation(self, conversation: Conversation):
|
||||
sid = conversation.sid
|
||||
async with self._conversations_lock:
|
||||
@ -203,25 +226,6 @@ class SessionManager:
|
||||
logger.warning('error_cleaning_detached_conversations', exc_info=True)
|
||||
await asyncio.sleep(15)
|
||||
|
||||
async def init_or_join_session(
|
||||
self, sid: str, connection_id: str, session_init_data: SessionInitData
|
||||
):
|
||||
await self.sio.enter_room(connection_id, ROOM_KEY.format(sid=sid))
|
||||
self.local_connection_id_to_session_id[connection_id] = sid
|
||||
|
||||
# If we have a local session running, use that
|
||||
session = self.local_sessions_by_sid.get(sid)
|
||||
if session:
|
||||
logger.info(f'found_local_session:{sid}')
|
||||
return session.agent_session.event_stream
|
||||
|
||||
# If there is a remote session running, retrieve existing events for that
|
||||
redis_client = self._get_redis_client()
|
||||
if redis_client and await self._is_session_running_in_cluster(sid):
|
||||
return EventStream(sid, self.file_store)
|
||||
|
||||
return await self.start_local_session(sid, session_init_data)
|
||||
|
||||
async def _is_session_running_in_cluster(self, sid: str) -> bool:
|
||||
"""As the rest of the cluster if a session is running. Wait a for a short timeout for a reply"""
|
||||
# Create a flag for the callback
|
||||
@ -275,9 +279,8 @@ class SessionManager:
|
||||
finally:
|
||||
self._has_remote_connections_flags.pop(sid)
|
||||
|
||||
async def start_local_session(self, sid: str, session_init_data: SessionInitData):
|
||||
# Start a new local session
|
||||
logger.info(f'start_new_local_session:{sid}')
|
||||
async def start_agent_loop(self, sid: str, session_init_data: SessionInitData):
|
||||
logger.info(f'start_agent_loop:{sid}')
|
||||
session = Session(
|
||||
sid=sid, file_store=self.file_store, config=self.config, sio=self.sio
|
||||
)
|
||||
|
||||
@ -35,8 +35,3 @@ class OpenhandsConfigInterface(ABC):
|
||||
async def get_config(self) -> dict[str, str]:
|
||||
"""Configure attributes for frontend"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def github_auth(self, data: dict) -> None:
|
||||
"""Handle GitHub authentication."""
|
||||
raise NotImplementedError
|
||||
|
||||
42
openhands/storage/conversation/conversation_store.py
Normal file
42
openhands/storage/conversation/conversation_store.py
Normal file
@ -0,0 +1,42 @@
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
|
||||
from openhands.core.config.app_config import AppConfig
|
||||
from openhands.storage import get_file_store
|
||||
from openhands.storage.files import FileStore
|
||||
from openhands.storage.locations import get_conversation_metadata_file
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConversationMetadata:
|
||||
conversation_id: str
|
||||
github_user_id: str
|
||||
selected_repository: str | None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConversationStore:
|
||||
file_store: FileStore
|
||||
|
||||
async def save_metadata(self, metadata: ConversationMetadata):
|
||||
json_str = json.dumps(metadata.__dict__)
|
||||
path = get_conversation_metadata_file(metadata.conversation_id)
|
||||
self.file_store.write(path, json_str)
|
||||
|
||||
async def get_metadata(self, conversation_id: str) -> ConversationMetadata:
|
||||
path = get_conversation_metadata_file(conversation_id)
|
||||
json_str = self.file_store.read(path)
|
||||
return ConversationMetadata(**json.loads(json_str))
|
||||
|
||||
async def exists(self, conversation_id: str) -> bool:
|
||||
path = get_conversation_metadata_file(conversation_id)
|
||||
try:
|
||||
self.file_store.read(path)
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
async def get_instance(cls, config: AppConfig):
|
||||
file_store = get_file_store(config.file_store, config.file_store_path)
|
||||
return ConversationStore(file_store)
|
||||
17
openhands/storage/locations.py
Normal file
17
openhands/storage/locations.py
Normal file
@ -0,0 +1,17 @@
|
||||
CONVERSATION_BASE_DIR = 'sessions'
|
||||
|
||||
|
||||
def get_conversation_dir(sid: str) -> str:
|
||||
return f'{CONVERSATION_BASE_DIR}/{sid}/'
|
||||
|
||||
|
||||
def get_conversation_events_dir(sid: str) -> str:
|
||||
return f'{get_conversation_dir(sid)}events/'
|
||||
|
||||
|
||||
def get_conversation_event_file(sid: str, id: int) -> str:
|
||||
return f'{get_conversation_events_dir(sid)}{id}.json'
|
||||
|
||||
|
||||
def get_conversation_metadata_file(sid: str) -> str:
|
||||
return f'{get_conversation_dir(sid)}metadata.json'
|
||||
@ -3,12 +3,14 @@ import os
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.storage.files import FileStore
|
||||
|
||||
IN_MEMORY_FILES: dict = {}
|
||||
|
||||
|
||||
class InMemoryFileStore(FileStore):
|
||||
files: dict[str, str]
|
||||
|
||||
def __init__(self):
|
||||
self.files = {}
|
||||
self.files = IN_MEMORY_FILES
|
||||
|
||||
def write(self, path: str, contents: str) -> None:
|
||||
self.files[path] = contents
|
||||
|
||||
@ -100,9 +100,8 @@ async def test_init_new_local_session():
|
||||
async with SessionManager(
|
||||
sio, AppConfig(), InMemoryFileStore()
|
||||
) as session_manager:
|
||||
await session_manager.init_or_join_session(
|
||||
'new-session-id', 'new-session-id', SessionInitData()
|
||||
)
|
||||
await session_manager.start_agent_loop('new-session-id', SessionInitData())
|
||||
await session_manager.join_conversation('new-session-id', 'new-session-id')
|
||||
assert session_instance.initialize_agent.call_count == 1
|
||||
assert sio.enter_room.await_count == 1
|
||||
|
||||
@ -131,14 +130,9 @@ async def test_join_local_session():
|
||||
async with SessionManager(
|
||||
sio, AppConfig(), InMemoryFileStore()
|
||||
) as session_manager:
|
||||
# First call initializes
|
||||
await session_manager.init_or_join_session(
|
||||
'new-session-id', 'new-session-id', SessionInitData()
|
||||
)
|
||||
# Second call joins
|
||||
await session_manager.init_or_join_session(
|
||||
'new-session-id', 'extra-connection-id', SessionInitData()
|
||||
)
|
||||
await session_manager.start_agent_loop('new-session-id', SessionInitData())
|
||||
await session_manager.join_conversation('new-session-id', 'new-session-id')
|
||||
await session_manager.join_conversation('new-session-id', 'new-session-id')
|
||||
assert session_instance.initialize_agent.call_count == 1
|
||||
assert sio.enter_room.await_count == 2
|
||||
|
||||
@ -167,10 +161,7 @@ async def test_join_cluster_session():
|
||||
async with SessionManager(
|
||||
sio, AppConfig(), InMemoryFileStore()
|
||||
) as session_manager:
|
||||
# First call initializes
|
||||
await session_manager.init_or_join_session(
|
||||
'new-session-id', 'new-session-id', SessionInitData()
|
||||
)
|
||||
await session_manager.join_conversation('new-session-id', 'new-session-id')
|
||||
assert session_instance.initialize_agent.call_count == 0
|
||||
assert sio.enter_room.await_count == 1
|
||||
|
||||
@ -199,9 +190,8 @@ async def test_add_to_local_event_stream():
|
||||
async with SessionManager(
|
||||
sio, AppConfig(), InMemoryFileStore()
|
||||
) as session_manager:
|
||||
await session_manager.init_or_join_session(
|
||||
'new-session-id', 'connection-id', SessionInitData()
|
||||
)
|
||||
await session_manager.start_agent_loop('new-session-id', SessionInitData())
|
||||
await session_manager.join_conversation('new-session-id', 'connection-id')
|
||||
await session_manager.send_to_event_stream(
|
||||
'connection-id', {'event_type': 'some_event'}
|
||||
)
|
||||
@ -232,9 +222,7 @@ async def test_add_to_cluster_event_stream():
|
||||
async with SessionManager(
|
||||
sio, AppConfig(), InMemoryFileStore()
|
||||
) as session_manager:
|
||||
await session_manager.init_or_join_session(
|
||||
'new-session-id', 'connection-id', SessionInitData()
|
||||
)
|
||||
await session_manager.join_conversation('new-session-id', 'connection-id')
|
||||
await session_manager.send_to_event_stream(
|
||||
'connection-id', {'event_type': 'some_event'}
|
||||
)
|
||||
|
||||
@ -26,9 +26,6 @@ def event_stream(temp_dir):
|
||||
event_stream = EventStream('asdf', file_store)
|
||||
yield event_stream
|
||||
|
||||
# clear after each test
|
||||
event_stream.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def agent_configs():
|
||||
|
||||
@ -19,6 +19,10 @@ class _StorageTest(ABC):
|
||||
store: FileStore
|
||||
|
||||
def get_store(self) -> FileStore:
|
||||
try:
|
||||
self.store.delete('')
|
||||
except Exception:
|
||||
pass
|
||||
return self.store
|
||||
|
||||
def test_basic_fileops(self):
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user