From 73c38f1163cc37048c3e31e1941fe4cd798c296e Mon Sep 17 00:00:00 2001 From: Robert Brennan Date: Fri, 20 Dec 2024 10:50:09 -0500 Subject: [PATCH] refactor: move session initialization from WebSocket to REST API (#5493) Co-authored-by: openhands Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com> --- README.md | 8 + .../current/usage/how-to/openshift-example.md | 338 -------------- .../current/usage/how-to/openshift-example.md | 343 -------------- .../modules/usage/how-to/openshift-example.md | 429 ------------------ docs/sidebars.ts | 5 - .../__tests__/components/browser.test.tsx | 30 +- .../components/feedback-form.test.tsx | 13 +- frontend/__tests__/routes/_oh.test.tsx | 1 + frontend/src/api/open-hands.ts | 100 +++- .../components/features/sidebar/sidebar.tsx | 2 +- .../shared/modals/settings/settings-form.tsx | 4 +- frontend/src/components/shared/task-form.tsx | 42 +- frontend/src/context/conversation-context.tsx | 42 ++ frontend/src/context/ws-client-provider.tsx | 71 +-- frontend/src/hooks/mutation/use-save-file.ts | 9 +- .../src/hooks/mutation/use-submit-feedback.ts | 9 +- .../src/hooks/mutation/use-upload-files.ts | 10 +- .../hooks/query/use-conversation-config.ts | 11 +- frontend/src/hooks/query/use-list-file.ts | 11 +- frontend/src/hooks/query/use-list-files.ts | 6 +- frontend/src/hooks/query/use-search-events.ts | 24 + frontend/src/hooks/query/use-vscode-url.ts | 12 +- frontend/src/hooks/use-download-progress.ts | 4 +- frontend/src/routes.ts | 2 +- frontend/src/routes/_oh._index/route.tsx | 11 +- frontend/src/routes/_oh.app/route.tsx | 24 +- frontend/src/utils/download-files.ts | 17 +- frontend/src/utils/download-workspace.ts | 4 +- frontend/test-utils.tsx | 24 +- openhands/events/stream.py | 17 +- openhands/server/app.py | 22 +- openhands/server/config/openhands_config.py | 16 +- openhands/server/listen_socket.py | 102 ++--- openhands/server/middleware.py | 40 +- openhands/server/routes/conversation.py | 4 +- openhands/server/routes/feedback.py | 20 +- openhands/server/routes/files.py | 10 +- openhands/server/routes/new_conversation.py | 74 +++ openhands/server/routes/security.py | 2 +- openhands/server/session/agent_session.py | 5 + openhands/server/session/manager.py | 47 +- openhands/server/types.py | 5 - .../conversation/conversation_store.py | 42 ++ openhands/storage/locations.py | 17 + openhands/storage/memory.py | 4 +- tests/unit/test_manager.py | 30 +- tests/unit/test_micro_agents.py | 3 - tests/unit/test_storage.py | 4 + 48 files changed, 628 insertions(+), 1442 deletions(-) delete mode 100644 docs/i18n/fr/docusaurus-plugin-content-docs/current/usage/how-to/openshift-example.md delete mode 100644 docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/how-to/openshift-example.md delete mode 100644 docs/modules/usage/how-to/openshift-example.md create mode 100644 frontend/src/context/conversation-context.tsx create mode 100644 frontend/src/hooks/query/use-search-events.ts create mode 100644 openhands/server/routes/new_conversation.py create mode 100644 openhands/storage/conversation/conversation_store.py create mode 100644 openhands/storage/locations.py diff --git a/README.md b/README.md index 1193095623..d17325e6f2 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/i18n/fr/docusaurus-plugin-content-docs/current/usage/how-to/openshift-example.md b/docs/i18n/fr/docusaurus-plugin-content-docs/current/usage/how-to/openshift-example.md deleted file mode 100644 index 4db6e0f85e..0000000000 --- a/docs/i18n/fr/docusaurus-plugin-content-docs/current/usage/how-to/openshift-example.md +++ /dev/null @@ -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 3000:30495/TCP 4s - -$ oc describe svc openhands-app-2024 -Name: openhands-app-2024 -Namespace: openhands -Labels: app=openhands-app-2024 -Annotations: -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: -``` - -6. Se connecter à l'interface utilisateur d'OpenHands, configurer l'Agent, puis tester : - -![image](https://github.com/user-attachments/assets/12f94804-a0c7-4744-b873-e003c9caf40e) - - - -## 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: - - diff --git a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/how-to/openshift-example.md b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/how-to/openshift-example.md deleted file mode 100644 index 29ce79089b..0000000000 --- a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/how-to/openshift-example.md +++ /dev/null @@ -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 3000:30495/TCP 4s - -$ oc describe svc openhands-app-2024 -Name: openhands-app-2024 -Namespace: openhands -Labels: app=openhands-app-2024 -Annotations: -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: -``` - -6. 连接到 OpenHands UI,配置 Agent,然后测试: - -![image](https://github.com/user-attachments/assets/12f94804-a0c7-4744-b873-e003c9caf40e) - - - -## 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 diff --git a/docs/modules/usage/how-to/openshift-example.md b/docs/modules/usage/how-to/openshift-example.md deleted file mode 100644 index 3170a3f2a5..0000000000 --- a/docs/modules/usage/how-to/openshift-example.md +++ /dev/null @@ -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 3000:30495/TCP 4s - -$ oc describe svc openhands-app-2024 -Name: openhands-app-2024 -Namespace: openhands -Labels: app=openhands-app-2024 -Annotations: -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: -``` - -6. Connect to OpenHands UI, configure the Agent, then test: - -![image](https://github.com/user-attachments/assets/12f94804-a0c7-4744-b873-e003c9caf40e) - - - -## 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 -``` diff --git a/docs/sidebars.ts b/docs/sidebars.ts index 436bd63e90..c8a05f5622 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -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', - }, ], }, { diff --git a/frontend/__tests__/components/browser.test.tsx b/frontend/__tests__/components/browser.test.tsx index 8fb7e0a310..6b4bfba73d 100644 --- a/frontend/__tests__/components/browser.test.tsx +++ b/frontend/__tests__/components/browser.test.tsx @@ -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(, { preloadedState: { diff --git a/frontend/__tests__/components/feedback-form.test.tsx b/frontend/__tests__/components/feedback-form.test.tsx index 4ff87da453..c9234e7374 100644 --- a/frontend/__tests__/components/feedback-form.test.tsx +++ b/frontend/__tests__/components/feedback-form.test.tsx @@ -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"; diff --git a/frontend/__tests__/routes/_oh.test.tsx b/frontend/__tests__/routes/_oh.test.tsx index e22d959446..336415f640 100644 --- a/frontend/__tests__/routes/_oh.test.tsx +++ b/frontend/__tests__/routes/_oh.test.tsx @@ -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"; diff --git a/frontend/src/api/open-hands.ts b/frontend/src/api/open-hands.ts index 1543351dd0..5c9522f07e 100644 --- a/frontend/src/api/open-hands.ts +++ b/frontend/src/api/open-hands.ts @@ -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 { - const { data } = await openHands.get("/api/list-files", { + static async getFiles( + conversationId: string, + path?: string, + ): Promise { + const url = `/api/conversations/${conversationId}/list-files`; + const { data } = await openHands.get(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 { - const { data } = await openHands.get<{ code: string }>("/api/select-file", { + static async getFile(conversationId: string, path: string): Promise { + 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 { + 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 { + static async uploadFiles( + conversationId: string, + files: File[], + ): Promise { + 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 { - const { data } = await openHands.post( - "/api/submit-feedback", - feedback, - ); + static async submitFeedback( + conversationId: string, + feedback: Feedback, + ): Promise { + const url = `/api/conversations/${conversationId}/submit-feedback`; + const { data } = await openHands.post(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 { - const response = await openHands.get("/api/zip-directory", { + static async getWorkspaceZip(conversationId: string): Promise { + 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 { - const { data } = - await openHands.get("/api/vscode-url"); + static async getVSCodeUrl( + conversationId: string, + ): Promise { + const { data } = await openHands.get( + `/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[]; has_more: boolean }> { + const { data } = await openHands.get<{ + events: Record[]; + 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; + 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; diff --git a/frontend/src/components/features/sidebar/sidebar.tsx b/frontend/src/components/features/sidebar/sidebar.tsx index fa11bc3fb3..7229ece49b 100644 --- a/frontend/src/components/features/sidebar/sidebar.tsx +++ b/frontend/src/components/features/sidebar/sidebar.tsx @@ -45,7 +45,7 @@ export function Sidebar() { }; const handleClickLogo = () => { - if (location.pathname.startsWith("/app")) + if (location.pathname.startsWith("/conversations/")) setStartNewProjectModalIsOpen(true); }; diff --git a/frontend/src/components/shared/modals/settings/settings-form.tsx b/frontend/src/components/shared/modals/settings/settings-form.tsx index b1bb66ba85..0734d25db6 100644 --- a/frontend/src/components/shared/modals/settings/settings-form.tsx +++ b/frontend/src/components/shared/modals/settings/settings-form.tsx @@ -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); diff --git a/frontend/src/components/shared/task-form.tsx b/frontend/src/components/shared/task-form.tsx index 786e6db7b4..ef97b1bfb8 100644 --- a/frontend/src/components/shared/task-form.tsx +++ b/frontend/src/components/shared/task-form.tsx @@ -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((_, 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((_, 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((_, 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((_, ref) => { showButton={!!text} className="text-[17px] leading-5 py-[17px]" buttonClassName="pb-[17px]" - disabled={navigation.state === "submitting"} + disabled={ + navigation.state === "submitting" || + newConversationMutation.isPending + } /> diff --git a/frontend/src/context/conversation-context.tsx b/frontend/src/context/conversation-context.tsx new file mode 100644 index 0000000000..748ee8ea0a --- /dev/null +++ b/frontend/src/context/conversation-context.tsx @@ -0,0 +1,42 @@ +import React, { useMemo } from "react"; +import { useParams } from "react-router"; + +interface ConversationContextType { + conversationId: string; +} + +const ConversationContext = React.createContext( + 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 ( + + {children} + + ); +} + +export function useConversation() { + const context = React.useContext(ConversationContext); + if (!context) { + throw new Error( + "useConversation must be used within a ConversationProvider", + ); + } + return context; +} diff --git a/frontend/src/context/ws-client-provider.tsx b/frontend/src/context/ws-client-provider.tsx index 75ac97a1c4..021762144a 100644 --- a/frontend/src/context/ws-client-provider.tsx +++ b/frontend/src/context/ws-client-provider.tsx @@ -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({ 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) { const sioRef = React.useRef(null); - const tokenRef = React.useRef(token); const ghTokenRef = React.useRef(ghToken); const selectedRepositoryRef = React.useRef(selectedRepository); const disconnectRef = React.useRef | null>( @@ -73,25 +69,6 @@ export function WsClientProvider({ function handleConnect() { setStatus(WsClientProviderStatus.OPENING); - - const initEvent: Record = { - 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) { @@ -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. diff --git a/frontend/src/hooks/mutation/use-save-file.ts b/frontend/src/hooks/mutation/use-save-file.ts index b62d86ff71..709b9e7489 100644 --- a/frontend/src/hooks/mutation/use-save-file.ts +++ b/frontend/src/hooks/mutation/use-save-file.ts @@ -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); }, }); +}; diff --git a/frontend/src/hooks/mutation/use-submit-feedback.ts b/frontend/src/hooks/mutation/use-submit-feedback.ts index 6948adc214..5ecf68e32c 100644 --- a/frontend/src/hooks/mutation/use-submit-feedback.ts +++ b/frontend/src/hooks/mutation/use-submit-feedback.ts @@ -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); }, }); +}; diff --git a/frontend/src/hooks/mutation/use-upload-files.ts b/frontend/src/hooks/mutation/use-upload-files.ts index c10886c150..5c8635e736 100644 --- a/frontend/src/hooks/mutation/use-upload-files.ts +++ b/frontend/src/hooks/mutation/use-upload-files.ts @@ -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), }); +}; diff --git a/frontend/src/hooks/query/use-conversation-config.ts b/frontend/src/hooks/query/use-conversation-config.ts index 729c6c969d..fb74f25f50 100644 --- a/frontend/src/hooks/query/use-conversation-config.ts +++ b/frontend/src/hooks/query/use-conversation-config.ts @@ -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(() => { diff --git a/frontend/src/hooks/query/use-list-file.ts b/frontend/src/hooks/query/use-list-file.ts index 7a498e4b88..0cedf98fae 100644 --- a/frontend/src/hooks/query/use-list-file.ts +++ b/frontend/src/hooks/query/use-list-file.ts @@ -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` }); +}; diff --git a/frontend/src/hooks/query/use-list-files.ts b/frontend/src/hooks/query/use-list-files.ts index 1a0fa1f103..66b9806f09 100644 --- a/frontend/src/hooks/query/use-list-files.ts +++ b/frontend/src/hooks/query/use-list-files.ts @@ -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), }); }; diff --git a/frontend/src/hooks/query/use-search-events.ts b/frontend/src/hooks/query/use-search-events.ts new file mode 100644 index 0000000000..2b18c2c981 --- /dev/null +++ b/frontend/src/hooks/query/use-search-events.ts @@ -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, + }); +}; diff --git a/frontend/src/hooks/query/use-vscode-url.ts b/frontend/src/hooks/query/use-vscode-url.ts index 9024876114..84669cb884 100644 --- a/frontend/src/hooks/query/use-vscode-url.ts +++ b/frontend/src/hooks/query/use-vscode-url.ts @@ -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, }); diff --git a/frontend/src/hooks/use-download-progress.ts b/frontend/src/hooks/use-download-progress.ts index 95a8bf02be..6a32d594d4 100644 --- a/frontend/src/hooks/use-download-progress.ts +++ b/frontend/src/hooks/use-download-progress.ts @@ -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(INITIAL_PROGRESS); const progressRef = useRef(INITIAL_PROGRESS); const abortController = useRef(); + 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 }; diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index 1b59fc6b17..75c88ff78a 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -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"), diff --git a/frontend/src/routes/_oh._index/route.tsx b/frontend/src/routes/_oh._index/route.tsx index 6785e390fb..64190bcd58 100644 --- a/frontend/src/routes/_oh._index/route.tsx +++ b/frontend/src/routes/_oh._index/route.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(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 (
[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 (
@@ -131,4 +135,12 @@ function App() { ); } +function App() { + return ( + + + + ); +} + export default App; diff --git a/frontend/src/utils/download-files.ts b/frontend/src/utils/download-files.ts index b22ebf47bd..a392c32eb0 100644 --- a/frontend/src/utils/download-files.ts +++ b/frontend/src/utils/download-files.ts @@ -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 { - const entries = await OpenHands.getFiles(path); + const entries = await OpenHands.getFiles(conversationID, path); const processEntry = async (entry: string): Promise => { 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 { @@ -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, diff --git a/frontend/src/utils/download-workspace.ts b/frontend/src/utils/download-workspace.ts index dc79141d5f..324a5c26ac 100644 --- a/frontend/src/utils/download-workspace.ts +++ b/frontend/src/utils/download-workspace.ts @@ -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"); diff --git a/frontend/test-utils.tsx b/frontend/test-utils.tsx index 6739e3be6e..b4b3f0b27a 100644 --- a/frontend/test-utils.tsx +++ b/frontend/test-utils.tsx @@ -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("react-router"); + return { + ...actual, + useParams: () => ({ conversationId: "test-conversation-id" }), + }; +}); // Initialize i18n for tests i18n @@ -60,11 +72,13 @@ export function renderWithProviders( - - - {children} - - + + + + {children} + + + diff --git a/openhands/events/stream.py b/openhands/events/stream.py index 3a7ef062cd..68585ba00f 100644 --- a/openhands/events/stream.py +++ b/openhands/events/stream.py @@ -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__() diff --git a/openhands/server/app.py b/openhands/server/app.py index 262c85e7d0..73f6b52885 100644 --- a/openhands/server/app.py +++ b/openhands/server/app.py @@ -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)) diff --git a/openhands/server/config/openhands_config.py b/openhands/server/config/openhands_config.py index f0675ebe22..80a577494b 100644 --- a/openhands/server/config/openhands_config.py +++ b/openhands/server/config/openhands_config.py @@ -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() diff --git a/openhands/server/listen_socket.py b/openhands/server/listen_socket.py index c98279b271..fe2ddff723 100644 --- a/openhands/server/listen_socket.py +++ b/openhands/server/listen_socket.py @@ -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}') diff --git a/openhands/server/middleware.py b/openhands/server/middleware.py index 044bc2c9a5..46fac1ead2 100644 --- a/openhands/server/middleware.py +++ b/openhands/server/middleware.py @@ -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 diff --git a/openhands/server/routes/conversation.py b/openhands/server/routes/conversation.py index a47eed68ef..690eb0673a 100644 --- a/openhands/server/routes/conversation.py +++ b/openhands/server/routes/conversation.py @@ -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. diff --git a/openhands/server/routes/feedback.py b/openhands/server/routes/feedback.py index 8489ec84e6..046b5b573e 100644 --- a/openhands/server/routes/feedback.py +++ b/openhands/server/routes/feedback.py @@ -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 diff --git a/openhands/server/routes/files.py b/openhands/server/routes/files.py index 3193376286..d048f5e617 100644 --- a/openhands/server/routes/files.py +++ b/openhands/server/routes/files.py @@ -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 diff --git a/openhands/server/routes/new_conversation.py b/openhands/server/routes/new_conversation.py new file mode 100644 index 0000000000..5d45101e9b --- /dev/null +++ b/openhands/server/routes/new_conversation.py @@ -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}) diff --git a/openhands/server/routes/security.py b/openhands/server/routes/security.py index f65fcce6aa..ef1721bafb 100644 --- a/openhands/server/routes/security.py +++ b/openhands/server/routes/security.py @@ -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']) diff --git a/openhands/server/session/agent_session.py b/openhands/server/session/agent_session.py index 7a7108c1f1..c04daa1a57 100644 --- a/openhands/server/session/agent_session.py +++ b/openhands/server/session/agent_session.py @@ -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: diff --git a/openhands/server/session/manager.py b/openhands/server/session/manager.py index 1a90cc48fd..e0d9a618f6 100644 --- a/openhands/server/session/manager.py +++ b/openhands/server/session/manager.py @@ -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 ) diff --git a/openhands/server/types.py b/openhands/server/types.py index 2deca93fe9..8ecb898a76 100644 --- a/openhands/server/types.py +++ b/openhands/server/types.py @@ -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 diff --git a/openhands/storage/conversation/conversation_store.py b/openhands/storage/conversation/conversation_store.py new file mode 100644 index 0000000000..e225e77503 --- /dev/null +++ b/openhands/storage/conversation/conversation_store.py @@ -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) diff --git a/openhands/storage/locations.py b/openhands/storage/locations.py new file mode 100644 index 0000000000..8c57e1a5ba --- /dev/null +++ b/openhands/storage/locations.py @@ -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' diff --git a/openhands/storage/memory.py b/openhands/storage/memory.py index 4792b88f41..195d76dea8 100644 --- a/openhands/storage/memory.py +++ b/openhands/storage/memory.py @@ -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 diff --git a/tests/unit/test_manager.py b/tests/unit/test_manager.py index 43e8672ef1..71bc30c5db 100644 --- a/tests/unit/test_manager.py +++ b/tests/unit/test_manager.py @@ -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'} ) diff --git a/tests/unit/test_micro_agents.py b/tests/unit/test_micro_agents.py index 8cff14fdd4..5910582e4e 100644 --- a/tests/unit/test_micro_agents.py +++ b/tests/unit/test_micro_agents.py @@ -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(): diff --git a/tests/unit/test_storage.py b/tests/unit/test_storage.py index 2e6bd67db2..46915c1814 100644 --- a/tests/unit/test_storage.py +++ b/tests/unit/test_storage.py @@ -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):