Merge remote-tracking branch 'upstream/main' into codex/shell
# Conflicts: # backend/internal/services/instance_service.go # backend/internal/services/k8s/pod_service.go # backend/internal/services/k8s/pod_service_test.go # frontend/src/components/InstanceAccess.tsx
This commit is contained in:
+28
-3
@@ -27,6 +27,7 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="#product-tour">Produktueberblick</a> |
|
||||
<a href="#team-workspaces">Team Workspaces</a> |
|
||||
<a href="#ai-gateway">AI Gateway</a> |
|
||||
<a href="#agent-control-plane">Agent Control Plane</a> |
|
||||
<a href="#runtime-integrations">Runtime-Integrationen</a> |
|
||||
@@ -54,6 +55,7 @@
|
||||
|
||||
Wichtige aktuelle Produkt- und Dokumentations-Updates.
|
||||
|
||||
- [2026-05-18] Team-Workspace-MVP mit Einfuehrung und Vorschau hinzugefuegt, inklusive One-Click-Team-Erstellung, OpenClaw-Member-Orchestrierung, Redis-Team-Bus-Injection, Shared Storage, Member-Status, Task-Dispatch sowie Event- und Ergebnisansichten.
|
||||
- [2026-04-29] Hermes-Runtime-Integration hinzugefuegt, inklusive Webtop-basierter Instanzbereitstellung, Agent-Control-Plane-Registrierung, AI-Gateway-Injection, channel- und skill-Bootstrap sowie `.hermes` Import/Export. Siehe [Hermes Runtime Guide](./docs/hermes-runtime-agent-development.md).
|
||||
- [2026-04-08] Skill-Verwaltung und Skill-Scanning wurden der Plattform hinzugefuegt. Details siehe [Merged PR #52](https://github.com/Yuan-lab-LLM/ClawManager/pull/52).
|
||||
- [2026-03-26] Die AI-Gateway-Dokumentation wurde erweitert und deckt nun Modell-Governance, Audit und Trace, Kostenrechnung sowie Risikokontrolle genauer ab. Siehe [AI Gateway Guide](./docs/aigateway.md).
|
||||
@@ -86,6 +88,20 @@ Es eignet sich besonders fuer:
|
||||
- Betriebsteams, die Runtime-Sichtbarkeit, Command-Dispatch und Desired-State-Kontrolle benoetigen
|
||||
- Entwicklungsteams, die Agent-Workspaces ueber wiederverwendbare Ressourcen statt ueber manuelle Konfiguration bereitstellen wollen
|
||||
|
||||
<a id="team-workspaces"></a>
|
||||
## Team Workspaces
|
||||
|
||||
Team Workspaces erweitern ClawManager von Einzelinstanz-Betrieb zu koordinierter Multi-Agent-Runtime-Verwaltung. Nutzer koennen ein Team erstellen, einen Leader und mehrere Member zuweisen und ClawManager die Bereitstellung der Member-Runtimes, die Injection der Kollaborationskonfiguration und die Sichtbarkeit von Tasks und Events in der Control Plane ueberlassen.
|
||||
|
||||
Das aktuelle MVP konzentriert sich auf OpenClaw-Member-Orchestrierung und den Redis-Team-Bus-Loop:
|
||||
|
||||
- One-Click-Team-Erstellung mit validiertem Leader/Member-Roster
|
||||
- Member-Runtime-Pods mit Team-Rolle, Member-ID, Control-Plane-URL und Shared-Mount-Konfiguration
|
||||
- Redis-basierte inbox-, events-, presence- und DLQ-Keys ueber kontrollierte Umgebungsvariablen und Secret-Referenzen
|
||||
- Shared PVC unter `/team` fuer Kontext, Artefakte, Snapshots und Task-Ergebnisse
|
||||
- Team-Detailansicht mit Leader-Desktop, Team-Chat, Member-Liste, Dispatch-Panel, Task-Fortschritt und Event-/Ergebnis-Historie
|
||||
- DB-gestuetzte Team-, Member-, Task- und Event-Datensaetze, sodass Redis Message Bus bleibt und nicht zur Source of Truth wird
|
||||
|
||||
<a id="runtime-integrations"></a>
|
||||
## Runtime-Integrationen
|
||||
|
||||
@@ -161,6 +177,14 @@ Siehe [Resource Management Guide (English)](./docs/resource-management.md) und [
|
||||
|
||||
ClawManager ist so gestaltet, dass Administration, Zugriff und AI-Governance nicht wie getrennte Werkzeuge wirken, sondern wie eine zusammenhaengende Produkterfahrung.
|
||||
|
||||
### Team Workspace
|
||||
|
||||
Die Team-Workspace-Seite bringt Leader-Desktop, Team-Chat, Member-Tabelle und Dispatch-Workflow in eine gemeinsame Betriebsansicht, damit Nutzer den Kollaborationsfortschritt direkt in ClawManager verfolgen koennen.
|
||||
|
||||
<p align="center">
|
||||
<img src="./docs/main/team-workspace.png" alt="ClawManager Team Workspace" width="100%" />
|
||||
</p>
|
||||
|
||||
### Admin Console
|
||||
|
||||
Die Admin-Konsole vereint Nutzer, Quotas, Runtime-Operationen, Security-Kontrollen und plattformweite Richtlinien in einer Oberflaeche. Sie ist die zentrale Arbeitsflaeche fuer Teams, die AI-Agent-Infrastruktur im grossen Massstab betreiben.
|
||||
@@ -189,9 +213,10 @@ AI Gateway integriert Modell-Governance direkt in die Workspace-Erfahrung. Audit
|
||||
|
||||
1. Administratoren definieren Governance-Richtlinien und wiederverwendbare Ressourcen.
|
||||
2. Nutzer erstellen oder betreten verwaltete AI-Agent-Workspaces auf Kubernetes.
|
||||
3. Agents verbinden sich mit der Control Plane und melden Runtime-Zustaende.
|
||||
4. Channel, skill und bundle werden kompiliert und auf Instanzen angewendet.
|
||||
5. AI-Traffic fliesst ueber das AI Gateway und erhaelt Audit-, Risiko- und Kostenkontrollen.
|
||||
3. Team Workspaces koennen mehrere Member-Runtimes mit Redis Team Bus und Shared-Storage-Konfiguration bereitstellen.
|
||||
4. Agents verbinden sich mit der Control Plane und melden Runtime-Zustaende.
|
||||
5. Channel, skill und bundle werden kompiliert und auf Instanzen angewendet.
|
||||
6. AI-Traffic fliesst ueber das AI Gateway und erhaelt Audit-, Risiko- und Kostenkontrollen.
|
||||
|
||||
## Entwicklerueberblick
|
||||
|
||||
|
||||
+28
-3
@@ -27,6 +27,7 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="#product-tour">製品紹介</a> |
|
||||
<a href="#team-workspaces">Team ワークスペース</a> |
|
||||
<a href="#ai-gateway">AI Gateway</a> |
|
||||
<a href="#agent-control-plane">Agent Control Plane</a> |
|
||||
<a href="#runtime-integrations">Runtime 連携</a> |
|
||||
@@ -54,6 +55,7 @@
|
||||
|
||||
最近の重要な製品アップデートとドキュメント更新です。
|
||||
|
||||
- [2026-05-18] Team ワークスペース MVP の紹介とプレビューを追加しました。ワンクリック Team 作成、OpenClaw メンバーのオーケストレーション、Redis Team Bus 注入、共有ストレージ、メンバー状態、タスク配布、イベント/結果ビューをカバーします。
|
||||
- [2026-04-29] Hermes Runtime 連携を追加しました。Webtop ベースのインスタンス作成、Agent Control Plane 登録、AI Gateway 注入、channel と skill のブートストラップ、`.hermes` のインポート/エクスポートに対応しています。詳しくは [Hermes Runtime Guide](./docs/hermes-runtime-agent-development.md) を参照してください。
|
||||
- [2026-04-08] プラットフォームに Skill 管理と Skill スキャンのワークフローを追加しました。詳細は [Merged PR #52](https://github.com/Yuan-lab-LLM/ClawManager/pull/52) を参照してください。
|
||||
- [2026-03-26] AI Gateway ドキュメントを更新し、モデルガバナンス、監査とトレース、コスト計算、リスク制御の説明を強化しました。詳しくは [AI Gateway Guide](./docs/aigateway.md) を参照してください。
|
||||
@@ -86,6 +88,20 @@ ClawManager は、AI エージェントインスタンスの運用を Kubernetes
|
||||
- ランタイムの可観測性、コマンド配布、 desired state 管理が必要な運用チーム
|
||||
- 手作業の設定ではなく、再利用可能なリソースで Agent ワークスペースを届けたい開発チーム
|
||||
|
||||
<a id="team-workspaces"></a>
|
||||
## Team ワークスペース
|
||||
|
||||
Team ワークスペースは、ClawManager を単一インスタンス運用から複数 Agent の協調ランタイム管理へ拡張します。ユーザーは Team を作成し、1 人の Leader と複数のメンバーを割り当て、ClawManager にメンバー Runtime のプロビジョニング、協調設定の注入、タスクとイベント状態の可視化を任せることができます。
|
||||
|
||||
現在の MVP は、OpenClaw メンバーのオーケストレーションと Redis Team Bus のループに焦点を当てています。
|
||||
|
||||
- 検証済みの Leader / メンバー roster によるワンクリック Team 作成
|
||||
- Team ロール、メンバー ID、コントロールプレーン URL、共有マウント設定を持つメンバー Runtime Pod の作成
|
||||
- 管理された環境変数と Secret 参照による Redis inbox、events、presence、DLQ key の注入
|
||||
- コンテキスト、成果物、スナップショット、タスク結果のための共有 PVC を `/team` にマウント
|
||||
- Leader デスクトップ、Team チャット、メンバー一覧、配布パネル、タスク進捗、イベント/結果履歴をまとめた Team 詳細ビュー
|
||||
- Team、メンバー、タスク、イベントを DB の権威状態として保持し、Redis はメッセージバスとして扱う設計
|
||||
|
||||
<a id="runtime-integrations"></a>
|
||||
## Runtime 連携
|
||||
|
||||
@@ -161,6 +177,14 @@ Agent Control Plane は、管理対象 AI エージェントインスタンス
|
||||
|
||||
ClawManager は、管理、アクセス、AI ガバナンスを別々のツールとして扱うのではなく、ひとつの製品体験としてまとめるよう設計されています。
|
||||
|
||||
### Team ワークスペース
|
||||
|
||||
Team ワークスペース画面は、Leader デスクトップ、Team チャット、メンバー表、配布ワークフローを 1 つの運用ビューにまとめ、ClawManager から離れずに協調作業の進捗を追えるようにします。
|
||||
|
||||
<p align="center">
|
||||
<img src="./docs/main/team-workspace.png" alt="ClawManager Team ワークスペース" width="100%" />
|
||||
</p>
|
||||
|
||||
### 管理コンソール
|
||||
|
||||
管理コンソールでは、ユーザー、クォータ、ランタイム操作、セキュリティ制御、プラットフォームレベルのポリシーをひとつの画面に集約します。大規模な AI エージェント基盤を運用するチームの中心となる作業面です。
|
||||
@@ -189,9 +213,10 @@ AI Gateway は、モデル利用のガバナンスをワークスペース体験
|
||||
|
||||
1. 管理者がガバナンスポリシーと再利用可能なリソースを定義します。
|
||||
2. ユーザーが Kubernetes 上で管理対象の AI エージェントワークスペースを作成または利用します。
|
||||
3. Agent がコントロールプレーンへ接続し、ランタイム状態を報告します。
|
||||
4. Channel、skill、bundle がコンパイルされ、インスタンスへ適用されます。
|
||||
5. AI トラフィックは AI Gateway を経由し、監査、リスク、コスト制御が付与されます。
|
||||
3. Team ワークスペースは、複数のメンバー Runtime を Redis Team Bus と共有ストレージ設定付きでプロビジョニングできます。
|
||||
4. Agent がコントロールプレーンへ接続し、ランタイム状態を報告します。
|
||||
5. Channel、skill、bundle がコンパイルされ、インスタンスへ適用されます。
|
||||
6. AI トラフィックは AI Gateway を経由し、監査、リスク、コスト制御が付与されます。
|
||||
|
||||
## 開発者向け概要
|
||||
|
||||
|
||||
+28
-3
@@ -27,6 +27,7 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="#product-tour">제품 소개</a> |
|
||||
<a href="#team-workspaces">Team 워크스페이스</a> |
|
||||
<a href="#ai-gateway">AI Gateway</a> |
|
||||
<a href="#agent-control-plane">Agent Control Plane</a> |
|
||||
<a href="#runtime-integrations">Runtime 연동</a> |
|
||||
@@ -54,6 +55,7 @@
|
||||
|
||||
최근의 중요한 제품 및 문서 업데이트입니다.
|
||||
|
||||
- [2026-05-18] Team 워크스페이스 MVP 소개와 미리보기를 추가했습니다. 원클릭 Team 생성, OpenClaw 멤버 오케스트레이션, Redis Team Bus 주입, 공유 스토리지, 멤버 상태, 작업 배포, 이벤트/결과 보기를 포함합니다.
|
||||
- [2026-04-29] Hermes Runtime 연동을 추가했습니다. Webtop 기반 인스턴스 생성, Agent Control Plane 등록, AI Gateway 주입, channel 및 skill 부트스트랩, `.hermes` 가져오기/내보내기 흐름을 지원합니다. 자세한 내용은 [Hermes Runtime Guide](./docs/hermes-runtime-agent-development.md)를 참고하세요.
|
||||
- [2026-04-08] 플랫폼에 Skill 관리와 Skill 스캔 워크플로우가 추가되었습니다. 자세한 내용은 [Merged PR #52](https://github.com/Yuan-lab-LLM/ClawManager/pull/52)를 참고하세요.
|
||||
- [2026-03-26] AI Gateway 문서를 업데이트하여 모델 거버넌스, 감사와 추적, 비용 계산, 리스크 제어 설명을 강화했습니다. 자세한 내용은 [AI Gateway Guide](./docs/aigateway.md)를 참고하세요.
|
||||
@@ -86,6 +88,20 @@ ClawManager는 AI Agent 인스턴스 운영을 Kubernetes 위로 확장하고,
|
||||
- 런타임 가시성, 명령 배포, desired state 제어가 필요한 운영 팀
|
||||
- 수동 설정 대신 재사용 가능한 리소스로 Agent 워크스페이스를 제공하고 싶은 개발 팀
|
||||
|
||||
<a id="team-workspaces"></a>
|
||||
## Team 워크스페이스
|
||||
|
||||
Team 워크스페이스는 ClawManager를 단일 인스턴스 운영에서 여러 Agent의 협업 런타임 관리로 확장합니다. 사용자는 Team을 생성하고 한 명의 Leader와 여러 멤버를 지정할 수 있으며, ClawManager는 멤버 Runtime 프로비저닝, 협업 설정 주입, 작업 및 이벤트 상태 가시화를 담당합니다.
|
||||
|
||||
현재 MVP는 OpenClaw 멤버 오케스트레이션과 Redis Team Bus 루프에 초점을 맞춥니다.
|
||||
|
||||
- 검증된 Leader / 멤버 roster 기반 원클릭 Team 생성
|
||||
- Team 역할, 멤버 ID, 컨트롤 플레인 URL, 공유 마운트 설정이 포함된 멤버 Runtime Pod 생성
|
||||
- 관리되는 환경 변수와 Secret 참조를 통한 Redis inbox, events, presence, DLQ key 주입
|
||||
- 컨텍스트, 산출물, 스냅샷, 작업 결과를 위한 공유 PVC를 `/team`에 마운트
|
||||
- Leader 데스크톱, Team 채팅, 멤버 목록, 배포 패널, 작업 진행률, 이벤트/결과 이력을 모아 보여주는 Team 상세 화면
|
||||
- Team, 멤버, 작업, 이벤트를 DB의 권위 상태로 유지하고 Redis는 메시지 버스로 사용하는 설계
|
||||
|
||||
<a id="runtime-integrations"></a>
|
||||
## Runtime 연동
|
||||
|
||||
@@ -161,6 +177,14 @@ Agent Control Plane은 관리되는 AI Agent 인스턴스를 위한 런타임
|
||||
|
||||
ClawManager는 관리, 접근, AI 거버넌스를 서로 분리된 도구로 다루지 않고, 하나의 일관된 제품 경험으로 묶도록 설계되었습니다.
|
||||
|
||||
### Team 워크스페이스
|
||||
|
||||
Team 워크스페이스 화면은 Leader 데스크톱, Team 채팅, 멤버 테이블, 배포 워크플로우를 하나의 운영 화면에 모아 ClawManager 안에서 협업 진행 상황을 따라갈 수 있게 합니다.
|
||||
|
||||
<p align="center">
|
||||
<img src="./docs/main/team-workspace.png" alt="ClawManager Team 워크스페이스" width="100%" />
|
||||
</p>
|
||||
|
||||
### 관리 콘솔
|
||||
|
||||
관리 콘솔은 사용자, 쿼터, 런타임 작업, 보안 제어, 플랫폼 수준 정책을 하나의 화면으로 묶습니다. 대규모 AI Agent 인프라를 운영하는 팀의 핵심 작업 공간입니다.
|
||||
@@ -189,9 +213,10 @@ AI Gateway는 모델 사용 거버넌스를 워크스페이스 경험 자체에
|
||||
|
||||
1. 관리자가 거버넌스 정책과 재사용 가능한 리소스를 정의합니다.
|
||||
2. 사용자가 Kubernetes에서 관리되는 AI Agent 워크스페이스를 생성하거나 진입합니다.
|
||||
3. Agent가 컨트롤 플레인에 연결해 런타임 상태를 보고합니다.
|
||||
4. Channel, skill, bundle이 컴파일되어 인스턴스에 적용됩니다.
|
||||
5. AI 트래픽은 AI Gateway를 통해 전달되며, 감사, 리스크, 비용 제어가 함께 적용됩니다.
|
||||
3. Team 워크스페이스는 여러 멤버 Runtime을 Redis Team Bus와 공유 스토리지 설정과 함께 프로비저닝할 수 있습니다.
|
||||
4. Agent가 컨트롤 플레인에 연결해 런타임 상태를 보고합니다.
|
||||
5. Channel, skill, bundle이 컴파일되어 인스턴스에 적용됩니다.
|
||||
6. AI 트래픽은 AI Gateway를 통해 전달되며, 감사, 리스크, 비용 제어가 함께 적용됩니다.
|
||||
|
||||
## 개발자 개요
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="#product-tour">Explore the Product</a> |
|
||||
<a href="#team-workspaces">Team Workspaces</a> |
|
||||
<a href="#ai-gateway">AI Gateway</a> |
|
||||
<a href="#agent-control-plane">Agent Control Plane</a> |
|
||||
<a href="#runtime-integrations">Runtime Integrations</a> |
|
||||
@@ -54,6 +55,7 @@
|
||||
|
||||
Recent highlights from the latest product and documentation updates.
|
||||
|
||||
- [2026-05-18] Added the Team workspace MVP introduction and preview, covering one-click Team creation, OpenClaw member orchestration, Redis Team Bus injection, shared storage, member status, task dispatch, and event/result views.
|
||||
- [2026-04-29] Added Hermes runtime integration support, including Webtop-based instance provisioning, Agent Control Plane registration, AI Gateway injection, channel and skill bootstrap, and `.hermes` import/export workflows. See the [Hermes Runtime Guide](./docs/hermes-runtime-agent-development.md).
|
||||
- [2026-04-08] Added skill management and skill scanning workflows to the platform, via [Merged PR #52](https://github.com/Yuan-lab-LLM/ClawManager/pull/52).
|
||||
- [2026-03-26] AI Gateway documentation was refreshed with stronger coverage for model governance, audit and trace, cost accounting, and risk control. See the [AI Gateway Guide](./docs/aigateway.md).
|
||||
@@ -86,6 +88,20 @@ It is designed for:
|
||||
- operators who need runtime visibility, command dispatch, and desired-state control
|
||||
- builders who want governed AI access and reusable resource injection instead of manual per-instance setup
|
||||
|
||||
<a id="team-workspaces"></a>
|
||||
## Team Workspaces
|
||||
|
||||
Team workspaces extend ClawManager from single-instance operations into coordinated multi-agent runtime management. A user can create a Team, assign one leader and multiple members, and let ClawManager provision the member runtimes, inject collaboration configuration, and keep task and event state visible from the control plane.
|
||||
|
||||
The current MVP focuses on OpenClaw member orchestration and the Redis Team Bus loop:
|
||||
|
||||
- one-click Team creation with a validated leader/member roster
|
||||
- member runtime Pods created with Team role, member id, control-plane URL, and shared mount configuration
|
||||
- Redis-backed inbox, events, presence, and DLQ keys injected through controlled environment and Secret references
|
||||
- shared PVC mounted at `/team` for context, artifacts, snapshots, and task results
|
||||
- Team detail view with leader desktop access, team chat, member roster, dispatch panel, task progress, and event/result history
|
||||
- DB-backed Team, member, task, and event records so Redis remains the message bus, not the source of truth
|
||||
|
||||
<a id="runtime-integrations"></a>
|
||||
## Runtime Integrations
|
||||
|
||||
@@ -157,6 +173,14 @@ See the [Resource Management Guide](./docs/resource-management.md) and the [Secu
|
||||
|
||||
The product is designed to feel coherent across administration, workspace access, and AI governance. Instead of treating these as separate tools, ClawManager brings them into one control surface.
|
||||
|
||||
### Team Workspace
|
||||
|
||||
The Team workspace page brings the leader desktop, Team chat, member table, and dispatch workflow into one operational view, so users can follow collaboration progress without leaving ClawManager.
|
||||
|
||||
<p align="center">
|
||||
<img src="./docs/main/team-workspace.png" alt="ClawManager Team workspace" width="100%" />
|
||||
</p>
|
||||
|
||||
### Admin Console
|
||||
|
||||
The admin console brings together users, quotas, runtime operations, security controls, and platform-level policies in one place. It is the operational center for teams running AI agent infrastructure at scale.
|
||||
@@ -185,9 +209,10 @@ AI Gateway extends the workspace experience with governed model access, audit tr
|
||||
|
||||
1. Admins define governance policies and reusable resources.
|
||||
2. Users create or enter managed AI agent workspaces on Kubernetes.
|
||||
3. Agents connect back to the control plane and report runtime state.
|
||||
4. Channels, skills, and bundles are compiled and applied to instances.
|
||||
5. AI traffic flows through AI Gateway with audit, risk, and cost controls.
|
||||
3. Team workspaces can provision multiple member runtimes with Redis Team Bus and shared storage configuration.
|
||||
4. Agents connect back to the control plane and report runtime state.
|
||||
5. Channels, skills, and bundles are compiled and applied to instances.
|
||||
6. AI traffic flows through AI Gateway with audit, risk, and cost controls.
|
||||
|
||||
## Developer Snapshot
|
||||
|
||||
|
||||
+28
-3
@@ -27,6 +27,7 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="#product-tour">了解产品</a> |
|
||||
<a href="#team-workspaces">Team 协作</a> |
|
||||
<a href="#ai-gateway">AI Gateway</a> |
|
||||
<a href="#agent-control-plane">Agent Control Plane</a> |
|
||||
<a href="#runtime-integrations">Runtime 接入</a> |
|
||||
@@ -54,6 +55,7 @@
|
||||
|
||||
这里展示最近的重要产品与文档更新。
|
||||
|
||||
- [2026-05-18] 新增 Team 工作空间 MVP 介绍与界面预览,覆盖一键创建 Team、OpenClaw 成员编排、Redis Team Bus 配置注入、共享存储、成员状态、任务派发,以及事件和结果查看。
|
||||
- [2026-04-29] 新增 Hermes Runtime 接入支持,覆盖基于 Webtop 的实例创建、Agent Control Plane 注册、AI Gateway 注入、channel 与 skill 引导注入,以及 `.hermes` 导入导出流程。见 [Hermes Runtime Guide](./docs/hermes-runtime-agent-development.md)。
|
||||
- [2026-04-08] 平台新增了 Skill 管理与 Skill 扫描工作流,见 [Merged PR #52](https://github.com/Yuan-lab-LLM/ClawManager/pull/52)。
|
||||
- [2026-03-26] AI Gateway 文档已更新,补充了模型治理、审计追踪、成本核算与风险控制能力,见 [AI Gateway Guide](./docs/aigateway.md)。
|
||||
@@ -86,6 +88,20 @@ ClawManager 将 AI Agent 实例的运行、治理与运维能力带到 Kubernete
|
||||
- 需要运行时可观测性、命令下发与期望态控制的运维团队
|
||||
- 希望以可复用资源而不是手工配置方式交付 Agent 工作空间的开发团队
|
||||
|
||||
<a id="team-workspaces"></a>
|
||||
## Team 工作空间
|
||||
|
||||
Team 工作空间让 ClawManager 从单实例运维扩展到多 Agent 协作编排。用户可以创建一个 Team,指定一个 Leader 和多个成员,由 ClawManager 负责创建成员 Runtime、注入协作配置,并在控制面持续展示任务、事件和结果状态。
|
||||
|
||||
当前 MVP 聚焦 OpenClaw 成员编排与 Redis Team Bus 闭环:
|
||||
|
||||
- 一键创建 Team,并校验 Leader / 成员 roster
|
||||
- 为成员 Runtime Pod 注入 Team 角色、成员 ID、控制面地址和共享目录配置
|
||||
- 通过受控环境变量和 Secret 引用注入 Redis inbox、events、presence 与 DLQ key
|
||||
- 将共享 PVC 挂载到 `/team`,用于上下文、产物、快照和任务结果
|
||||
- Team 详情页集中展示 Leader 桌面、团队群聊、成员列表、调试派发、任务进度与事件结果
|
||||
- Team、成员、任务和事件以 DB 为权威状态,Redis 仅作为消息总线与短期 presence 通道
|
||||
|
||||
<a id="runtime-integrations"></a>
|
||||
## Runtime 接入
|
||||
|
||||
@@ -161,6 +177,14 @@ Agent Control Plane 是受管 AI Agent 实例的运行时编排层。它让每
|
||||
|
||||
ClawManager 的设计目标,是让管理、访问与 AI 治理体验形成统一的产品界面,而不是分散在多个孤立工具中。
|
||||
|
||||
### Team 工作空间
|
||||
|
||||
Team 工作空间页面把 Leader 桌面、团队群聊、成员表格和调试派发流程集中在同一个操作视图中,用户可以直接观察协作进度、成员反馈和任务结果。
|
||||
|
||||
<p align="center">
|
||||
<img src="./docs/main/team-workspace.png" alt="ClawManager Team 工作空间" width="100%" />
|
||||
</p>
|
||||
|
||||
### 管理控制台
|
||||
|
||||
管理控制台将用户、配额、运行时操作、安全控制与平台级策略集中到一起,是团队管理 AI Agent 基础设施的核心工作台。
|
||||
@@ -189,9 +213,10 @@ AI Gateway 将模型访问治理纳入工作空间体验本身,提供审计记
|
||||
|
||||
1. 管理员先定义治理策略与可复用资源。
|
||||
2. 用户在 Kubernetes 上创建或进入受管 AI Agent 工作空间。
|
||||
3. Agent 回连控制平面并上报运行时状态。
|
||||
4. Channel、skill 与 bundle 被编译并应用到实例中。
|
||||
5. AI 流量通过 AI Gateway 进入上游服务,并附带审计、风险与成本控制。
|
||||
3. Team 工作空间可以一次编排多个成员 Runtime,并注入 Redis Team Bus 与共享存储配置。
|
||||
4. Agent 回连控制平面并上报运行时状态。
|
||||
5. Channel、skill 与 bundle 被编译并应用到实例中。
|
||||
6. AI 流量通过 AI Gateway 进入上游服务,并附带审计、风险与成本控制。
|
||||
|
||||
## 开发者概览
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ func main() {
|
||||
instanceDesiredStateRepo := repository.NewInstanceDesiredStateRepository(database)
|
||||
instanceCommandRepo := repository.NewInstanceCommandRepository(database)
|
||||
instanceConfigRevisionRepo := repository.NewInstanceConfigRevisionRepository(database)
|
||||
teamRepo := repository.NewTeamRepository(database)
|
||||
skillRepo := repository.NewSkillRepository(database)
|
||||
securityScanRepo := repository.NewSecurityScanRepository(database)
|
||||
|
||||
@@ -108,6 +109,7 @@ func main() {
|
||||
instanceRuntimeStatusService := services.NewInstanceRuntimeStatusService(instanceRuntimeStatusRepo, instanceAgentRepo, instanceDesiredStateRepo)
|
||||
instanceCommandService := services.NewInstanceCommandService(instanceCommandRepo, instanceRuntimeStatusRepo, instanceDesiredStateRepo)
|
||||
instanceConfigRevisionService := services.NewInstanceConfigRevisionService(instanceConfigRevisionRepo)
|
||||
teamService := services.NewTeamService(teamRepo, instanceService)
|
||||
skillService := services.NewSkillService(skillRepo, instanceRepo, instanceCommandService, objectStorageService, skillScannerClient)
|
||||
securityScanService := services.NewSecurityScanService(securityScanRepo, skillRepo, objectStorageService, skillScannerClient)
|
||||
aiGatewayService := aigateway.NewService(llmModelRepo, modelInvocationService, auditEventService, costRecordService, riskDetectionService, riskHitService, chatSessionService, chatMessageService)
|
||||
@@ -127,6 +129,7 @@ func main() {
|
||||
skillHandler := handlers.NewSkillHandler(skillService, instanceService)
|
||||
securityHandler := handlers.NewSecurityHandler(securityScanService)
|
||||
agentHandler := handlers.NewAgentHandler(instanceAgentService, instanceCommandService, instanceRuntimeStatusService, instanceConfigRevisionService, skillService)
|
||||
teamHandler := handlers.NewTeamHandler(teamService)
|
||||
|
||||
// Initialize WebSocket hub and handler
|
||||
wsHub := services.GetHub()
|
||||
@@ -135,6 +138,7 @@ func main() {
|
||||
// Start sync service to keep instance status in sync with K8s
|
||||
syncService := services.NewSyncService(instanceRepo, instanceRuntimeStatusService)
|
||||
syncService.Start()
|
||||
teamService.Start()
|
||||
|
||||
// Setup router
|
||||
r := gin.Default()
|
||||
@@ -225,6 +229,18 @@ func main() {
|
||||
adminInstances.GET("", instanceHandler.ListAllInstances)
|
||||
}
|
||||
|
||||
teams := api.Group("/teams")
|
||||
teams.Use(middleware.Auth())
|
||||
teams.Use(middleware.SetUserInfo(userRepo))
|
||||
{
|
||||
teams.GET("", teamHandler.ListTeams)
|
||||
teams.POST("", teamHandler.CreateTeam)
|
||||
teams.GET("/:id", teamHandler.GetTeam)
|
||||
teams.DELETE("/:id", teamHandler.DeleteTeam)
|
||||
teams.POST("/:id/tasks", teamHandler.DispatchTask)
|
||||
teams.DELETE("/:id/members/:memberID", teamHandler.DeleteMember)
|
||||
}
|
||||
|
||||
openClawConfigs := api.Group("/openclaw-configs")
|
||||
openClawConfigs.Use(middleware.Auth())
|
||||
openClawConfigs.Use(middleware.SetUserInfo(userRepo))
|
||||
@@ -406,6 +422,7 @@ func main() {
|
||||
|
||||
// Stop background services
|
||||
syncService.Stop()
|
||||
teamService.Stop()
|
||||
wsHub.Stop()
|
||||
instanceHandler.Shutdown()
|
||||
|
||||
|
||||
@@ -314,7 +314,7 @@ data:
|
||||
UPDATE system_image_settings
|
||||
SET image = 'ghcr.io/yuan-lab-llm/agentsruntime/hermes:latest'
|
||||
WHERE image = 'ghcr.io/Yuan-lab-LLM/AgentsRuntime/hermes:latest';
|
||||
014_add_instance_runtime_type.sql: |
|
||||
019_add_instance_runtime_type.sql: |
|
||||
USE clawreef;
|
||||
SET @instance_runtime_type_column_exists = (
|
||||
SELECT COUNT(*)
|
||||
@@ -331,7 +331,7 @@ data:
|
||||
PREPARE instance_runtime_type_column_stmt FROM @instance_runtime_type_column_sql;
|
||||
EXECUTE instance_runtime_type_column_stmt;
|
||||
DEALLOCATE PREPARE instance_runtime_type_column_stmt;
|
||||
015_add_system_image_runtime_type.sql: |
|
||||
020_add_system_image_runtime_type.sql: |
|
||||
USE clawreef;
|
||||
SET @system_image_runtime_type_column_exists = (
|
||||
SELECT COUNT(*)
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
CREATE TABLE IF NOT EXISTS teams (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT NULL,
|
||||
status VARCHAR(30) NOT NULL DEFAULT 'creating',
|
||||
communication_mode VARCHAR(50) NOT NULL DEFAULT 'leader_only',
|
||||
redis_url_secret_name VARCHAR(255) NULL,
|
||||
redis_url_secret_key VARCHAR(255) NULL,
|
||||
team_token_secret_name VARCHAR(255) NULL,
|
||||
team_token_secret_key VARCHAR(255) NULL,
|
||||
redis_events_last_id VARCHAR(128) NOT NULL DEFAULT '0-0',
|
||||
shared_pvc_name VARCHAR(255) NULL,
|
||||
shared_pvc_namespace VARCHAR(255) NULL,
|
||||
shared_mount_path VARCHAR(255) NOT NULL DEFAULT '/team',
|
||||
storage_class VARCHAR(100) NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
UNIQUE KEY uk_teams_user_name (user_id, name),
|
||||
INDEX idx_teams_user_status (user_id, status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS team_members (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
team_id INT NOT NULL,
|
||||
instance_id INT NULL,
|
||||
user_id INT NOT NULL,
|
||||
member_key VARCHAR(100) NOT NULL,
|
||||
display_name VARCHAR(255) NOT NULL,
|
||||
role VARCHAR(100) NOT NULL,
|
||||
description TEXT NULL,
|
||||
status VARCHAR(30) NOT NULL DEFAULT 'creating',
|
||||
current_task_id INT NULL,
|
||||
progress INT NOT NULL DEFAULT 0,
|
||||
last_seen_at TIMESTAMP NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (instance_id) REFERENCES instances(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
UNIQUE KEY uk_team_members_key (team_id, member_key),
|
||||
UNIQUE KEY uk_team_members_instance (team_id, instance_id),
|
||||
INDEX idx_team_members_team_status (team_id, status),
|
||||
INDEX idx_team_members_instance (instance_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS team_tasks (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
team_id INT NOT NULL,
|
||||
target_member_id INT NOT NULL,
|
||||
created_by INT NULL,
|
||||
message_id VARCHAR(255) NOT NULL,
|
||||
status VARCHAR(30) NOT NULL DEFAULT 'pending',
|
||||
payload_json LONGTEXT NOT NULL,
|
||||
result_json LONGTEXT NULL,
|
||||
error_message TEXT NULL,
|
||||
redis_stream_id VARCHAR(128) NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
dispatched_at TIMESTAMP NULL,
|
||||
started_at TIMESTAMP NULL,
|
||||
finished_at TIMESTAMP NULL,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (target_member_id) REFERENCES team_members(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||
UNIQUE KEY uk_team_tasks_message (team_id, message_id),
|
||||
INDEX idx_team_tasks_team_status (team_id, status),
|
||||
INDEX idx_team_tasks_member_status (target_member_id, status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS team_events (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
team_id INT NOT NULL,
|
||||
member_id INT NULL,
|
||||
task_id INT NULL,
|
||||
message_id VARCHAR(255) NULL,
|
||||
event_type VARCHAR(100) NOT NULL,
|
||||
payload_json LONGTEXT NULL,
|
||||
redis_stream_id VARCHAR(128) NULL,
|
||||
occurred_at TIMESTAMP NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (member_id) REFERENCES team_members(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (task_id) REFERENCES team_tasks(id) ON DELETE SET NULL,
|
||||
UNIQUE KEY uk_team_events_stream_id (team_id, redis_stream_id),
|
||||
INDEX idx_team_events_team_created (team_id, created_at),
|
||||
INDEX idx_team_events_task (task_id),
|
||||
INDEX idx_team_events_member (member_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,15 @@
|
||||
SET @team_member_description_column_exists = (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'team_members'
|
||||
AND COLUMN_NAME = 'description'
|
||||
);
|
||||
SET @team_member_description_column_sql = IF(
|
||||
@team_member_description_column_exists = 0,
|
||||
'ALTER TABLE team_members ADD COLUMN description TEXT NULL AFTER role',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE team_member_description_column_stmt FROM @team_member_description_column_sql;
|
||||
EXECUTE team_member_description_column_stmt;
|
||||
DEALLOCATE PREPARE team_member_description_column_stmt;
|
||||
@@ -0,0 +1,8 @@
|
||||
UPDATE teams
|
||||
SET name = CONCAT(
|
||||
LEFT(name, GREATEST(1, 255 - CHAR_LENGTH(CONCAT('__deleted_', id)))),
|
||||
'__deleted_',
|
||||
id
|
||||
)
|
||||
WHERE status = 'deleted'
|
||||
AND name NOT REGEXP '__deleted_[0-9]+$';
|
||||
@@ -0,0 +1,95 @@
|
||||
SET @team_member_availability_column_exists = (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'team_members'
|
||||
AND COLUMN_NAME = 'availability'
|
||||
);
|
||||
SET @team_member_availability_column_sql = IF(
|
||||
@team_member_availability_column_exists = 0,
|
||||
'ALTER TABLE team_members ADD COLUMN availability VARCHAR(30) NOT NULL DEFAULT ''unknown'' AFTER last_seen_at',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE team_member_availability_column_stmt FROM @team_member_availability_column_sql;
|
||||
EXECUTE team_member_availability_column_stmt;
|
||||
DEALLOCATE PREPARE team_member_availability_column_stmt;
|
||||
|
||||
SET @team_member_runtime_status_column_exists = (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'team_members'
|
||||
AND COLUMN_NAME = 'runtime_status'
|
||||
);
|
||||
SET @team_member_runtime_status_column_sql = IF(
|
||||
@team_member_runtime_status_column_exists = 0,
|
||||
'ALTER TABLE team_members ADD COLUMN runtime_status VARCHAR(50) NULL AFTER availability',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE team_member_runtime_status_column_stmt FROM @team_member_runtime_status_column_sql;
|
||||
EXECUTE team_member_runtime_status_column_stmt;
|
||||
DEALLOCATE PREPARE team_member_runtime_status_column_stmt;
|
||||
|
||||
SET @team_member_runtime_task_column_exists = (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'team_members'
|
||||
AND COLUMN_NAME = 'runtime_task_id'
|
||||
);
|
||||
SET @team_member_runtime_task_column_sql = IF(
|
||||
@team_member_runtime_task_column_exists = 0,
|
||||
'ALTER TABLE team_members ADD COLUMN runtime_task_id VARCHAR(255) NULL AFTER runtime_status',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE team_member_runtime_task_column_stmt FROM @team_member_runtime_task_column_sql;
|
||||
EXECUTE team_member_runtime_task_column_stmt;
|
||||
DEALLOCATE PREPARE team_member_runtime_task_column_stmt;
|
||||
|
||||
SET @team_member_runtime_intent_column_exists = (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'team_members'
|
||||
AND COLUMN_NAME = 'runtime_intent'
|
||||
);
|
||||
SET @team_member_runtime_intent_column_sql = IF(
|
||||
@team_member_runtime_intent_column_exists = 0,
|
||||
'ALTER TABLE team_members ADD COLUMN runtime_intent VARCHAR(100) NULL AFTER runtime_task_id',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE team_member_runtime_intent_column_stmt FROM @team_member_runtime_intent_column_sql;
|
||||
EXECUTE team_member_runtime_intent_column_stmt;
|
||||
DEALLOCATE PREPARE team_member_runtime_intent_column_stmt;
|
||||
|
||||
SET @team_member_blocked_reason_column_exists = (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'team_members'
|
||||
AND COLUMN_NAME = 'blocked_reason'
|
||||
);
|
||||
SET @team_member_blocked_reason_column_sql = IF(
|
||||
@team_member_blocked_reason_column_exists = 0,
|
||||
'ALTER TABLE team_members ADD COLUMN blocked_reason TEXT NULL AFTER runtime_intent',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE team_member_blocked_reason_column_stmt FROM @team_member_blocked_reason_column_sql;
|
||||
EXECUTE team_member_blocked_reason_column_stmt;
|
||||
DEALLOCATE PREPARE team_member_blocked_reason_column_stmt;
|
||||
|
||||
SET @team_member_last_summary_column_exists = (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'team_members'
|
||||
AND COLUMN_NAME = 'last_summary'
|
||||
);
|
||||
SET @team_member_last_summary_column_sql = IF(
|
||||
@team_member_last_summary_column_exists = 0,
|
||||
'ALTER TABLE team_members ADD COLUMN last_summary TEXT NULL AFTER blocked_reason',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE team_member_last_summary_column_stmt FROM @team_member_last_summary_column_sql;
|
||||
EXECUTE team_member_last_summary_column_stmt;
|
||||
DEALLOCATE PREPARE team_member_last_summary_column_stmt;
|
||||
@@ -0,0 +1,15 @@
|
||||
SET @team_member_runtime_type_column_exists = (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'team_members'
|
||||
AND COLUMN_NAME = 'runtime_type'
|
||||
);
|
||||
SET @team_member_runtime_type_column_sql = IF(
|
||||
@team_member_runtime_type_column_exists = 0,
|
||||
'ALTER TABLE team_members ADD COLUMN runtime_type VARCHAR(30) NOT NULL DEFAULT ''openclaw'' AFTER role',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE team_member_runtime_type_column_stmt FROM @team_member_runtime_type_column_sql;
|
||||
EXECUTE team_member_runtime_type_column_stmt;
|
||||
DEALLOCATE PREPARE team_member_runtime_type_column_stmt;
|
||||
@@ -0,0 +1,128 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"clawreef/internal/services"
|
||||
"clawreef/internal/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type TeamHandler struct {
|
||||
teamService services.TeamService
|
||||
}
|
||||
|
||||
func NewTeamHandler(teamService services.TeamService) *TeamHandler {
|
||||
return &TeamHandler{teamService: teamService}
|
||||
}
|
||||
|
||||
func (h *TeamHandler) CreateTeam(c *gin.Context) {
|
||||
userID, _ := c.Get("userID")
|
||||
var req services.CreateTeamRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.ValidationError(c, err)
|
||||
return
|
||||
}
|
||||
team, err := h.teamService.CreateTeam(userID.(int), req)
|
||||
if err != nil {
|
||||
utils.HandleError(c, err)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusCreated, "Team created successfully", team)
|
||||
}
|
||||
|
||||
func (h *TeamHandler) ListTeams(c *gin.Context) {
|
||||
userID, _ := c.Get("userID")
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
teams, err := h.teamService.ListTeams(userID.(int), (page-1)*limit, limit)
|
||||
if err != nil {
|
||||
utils.HandleError(c, err)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "Teams retrieved successfully", gin.H{
|
||||
"teams": teams.Teams,
|
||||
"total": teams.Total,
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *TeamHandler) GetTeam(c *gin.Context) {
|
||||
userID, _ := c.Get("userID")
|
||||
teamID, ok := parseTeamID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
team, err := h.teamService.GetTeam(userID.(int), teamID)
|
||||
if err != nil {
|
||||
utils.HandleError(c, err)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "Team retrieved successfully", team)
|
||||
}
|
||||
|
||||
func (h *TeamHandler) DispatchTask(c *gin.Context) {
|
||||
userID, _ := c.Get("userID")
|
||||
teamID, ok := parseTeamID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req services.DispatchTeamTaskRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil && err != io.EOF {
|
||||
utils.ValidationError(c, err)
|
||||
return
|
||||
}
|
||||
task, err := h.teamService.DispatchTask(userID.(int), teamID, req)
|
||||
if err != nil {
|
||||
utils.HandleError(c, err)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusCreated, "Team task dispatched successfully", task)
|
||||
}
|
||||
|
||||
func (h *TeamHandler) DeleteTeam(c *gin.Context) {
|
||||
userID, _ := c.Get("userID")
|
||||
teamID, ok := parseTeamID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.teamService.DeleteTeam(userID.(int), teamID); err != nil {
|
||||
utils.HandleError(c, err)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "Team deleted successfully", gin.H{"id": teamID})
|
||||
}
|
||||
|
||||
func (h *TeamHandler) DeleteMember(c *gin.Context) {
|
||||
userID, _ := c.Get("userID")
|
||||
teamID, ok := parseTeamID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
memberID := c.Param("memberID")
|
||||
if err := h.teamService.DeleteMember(userID.(int), teamID, memberID); err != nil {
|
||||
utils.HandleError(c, err)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "Team member deleted successfully", gin.H{"member_id": memberID})
|
||||
}
|
||||
|
||||
func parseTeamID(c *gin.Context) (int, bool) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
utils.Error(c, http.StatusBadRequest, "Invalid Team ID")
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
TeamStatusCreating = "creating"
|
||||
TeamStatusRunning = "running"
|
||||
TeamStatusFailed = "failed"
|
||||
|
||||
TeamMemberStatusCreating = "creating"
|
||||
TeamMemberStatusIdle = "idle"
|
||||
TeamMemberStatusBusy = "busy"
|
||||
TeamMemberStatusFailed = "failed"
|
||||
TeamMemberStatusOffline = "offline"
|
||||
TeamMemberStatusDeleting = "deleting"
|
||||
TeamMemberStatusDeleted = "deleted"
|
||||
|
||||
TeamMemberAvailabilityUnknown = "unknown"
|
||||
TeamMemberAvailabilityIdle = "idle"
|
||||
TeamMemberAvailabilityBusy = "busy"
|
||||
TeamMemberAvailabilityBlocked = "blocked"
|
||||
TeamMemberAvailabilityOffline = "offline"
|
||||
|
||||
TeamStatusDeleting = "deleting"
|
||||
TeamStatusDeleted = "deleted"
|
||||
|
||||
TeamTaskStatusPending = "pending"
|
||||
TeamTaskStatusDispatched = "dispatched"
|
||||
TeamTaskStatusRunning = "running"
|
||||
TeamTaskStatusSucceeded = "succeeded"
|
||||
TeamTaskStatusFailed = "failed"
|
||||
TeamTaskStatusStale = "stale"
|
||||
)
|
||||
|
||||
type Team struct {
|
||||
ID int `db:"id,primarykey,autoincrement" json:"id"`
|
||||
UserID int `db:"user_id" json:"user_id"`
|
||||
Name string `db:"name" json:"name"`
|
||||
Description *string `db:"description" json:"description,omitempty"`
|
||||
Status string `db:"status" json:"status"`
|
||||
CommunicationMode string `db:"communication_mode" json:"communication_mode"`
|
||||
RedisURLSecretName *string `db:"redis_url_secret_name" json:"-"`
|
||||
RedisURLSecretKey *string `db:"redis_url_secret_key" json:"-"`
|
||||
TeamTokenSecretName *string `db:"team_token_secret_name" json:"-"`
|
||||
TeamTokenSecretKey *string `db:"team_token_secret_key" json:"-"`
|
||||
RedisEventsLastID string `db:"redis_events_last_id" json:"redis_events_last_id"`
|
||||
SharedPVCName *string `db:"shared_pvc_name" json:"shared_pvc_name,omitempty"`
|
||||
SharedPVCNamespace *string `db:"shared_pvc_namespace" json:"shared_pvc_namespace,omitempty"`
|
||||
SharedMountPath string `db:"shared_mount_path" json:"shared_mount_path"`
|
||||
StorageClass *string `db:"storage_class" json:"storage_class,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (Team) TableName() string {
|
||||
return "teams"
|
||||
}
|
||||
|
||||
type TeamMember struct {
|
||||
ID int `db:"id,primarykey,autoincrement" json:"id"`
|
||||
TeamID int `db:"team_id" json:"team_id"`
|
||||
InstanceID *int `db:"instance_id" json:"instance_id,omitempty"`
|
||||
UserID int `db:"user_id" json:"user_id"`
|
||||
MemberKey string `db:"member_key" json:"member_key"`
|
||||
DisplayName string `db:"display_name" json:"display_name"`
|
||||
Role string `db:"role" json:"role"`
|
||||
RuntimeType string `db:"runtime_type" json:"runtime_type"`
|
||||
Description *string `db:"description" json:"description,omitempty"`
|
||||
Status string `db:"status" json:"status"`
|
||||
CurrentTaskID *int `db:"current_task_id" json:"current_task_id,omitempty"`
|
||||
Progress int `db:"progress" json:"progress"`
|
||||
LastSeenAt *time.Time `db:"last_seen_at" json:"last_seen_at,omitempty"`
|
||||
Availability string `db:"availability" json:"availability"`
|
||||
RuntimeStatus *string `db:"runtime_status" json:"runtime_status,omitempty"`
|
||||
RuntimeTaskID *string `db:"runtime_task_id" json:"runtime_task_id,omitempty"`
|
||||
RuntimeIntent *string `db:"runtime_intent" json:"runtime_intent,omitempty"`
|
||||
BlockedReason *string `db:"blocked_reason" json:"blocked_reason,omitempty"`
|
||||
LastSummary *string `db:"last_summary" json:"last_summary,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (TeamMember) TableName() string {
|
||||
return "team_members"
|
||||
}
|
||||
|
||||
type TeamTask struct {
|
||||
ID int `db:"id,primarykey,autoincrement" json:"id"`
|
||||
TeamID int `db:"team_id" json:"team_id"`
|
||||
TargetMemberID int `db:"target_member_id" json:"target_member_id"`
|
||||
CreatedBy *int `db:"created_by" json:"created_by,omitempty"`
|
||||
MessageID string `db:"message_id" json:"message_id"`
|
||||
Status string `db:"status" json:"status"`
|
||||
PayloadJSON string `db:"payload_json" json:"-"`
|
||||
ResultJSON *string `db:"result_json" json:"-"`
|
||||
ErrorMessage *string `db:"error_message" json:"error_message,omitempty"`
|
||||
RedisStreamID *string `db:"redis_stream_id" json:"redis_stream_id,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
DispatchedAt *time.Time `db:"dispatched_at" json:"dispatched_at,omitempty"`
|
||||
StartedAt *time.Time `db:"started_at" json:"started_at,omitempty"`
|
||||
FinishedAt *time.Time `db:"finished_at" json:"finished_at,omitempty"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (TeamTask) TableName() string {
|
||||
return "team_tasks"
|
||||
}
|
||||
|
||||
type TeamEvent struct {
|
||||
ID int `db:"id,primarykey,autoincrement" json:"id"`
|
||||
TeamID int `db:"team_id" json:"team_id"`
|
||||
MemberID *int `db:"member_id" json:"member_id,omitempty"`
|
||||
TaskID *int `db:"task_id" json:"task_id,omitempty"`
|
||||
MessageID *string `db:"message_id" json:"message_id,omitempty"`
|
||||
EventType string `db:"event_type" json:"event_type"`
|
||||
PayloadJSON *string `db:"payload_json" json:"-"`
|
||||
RedisStreamID *string `db:"redis_stream_id" json:"redis_stream_id,omitempty"`
|
||||
OccurredAt *time.Time `db:"occurred_at" json:"occurred_at,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
func (TeamEvent) TableName() string {
|
||||
return "team_events"
|
||||
}
|
||||
@@ -105,7 +105,7 @@ func (r *instanceRepository) CountAll() (int, error) {
|
||||
// GetByUserID gets instances by user ID with pagination
|
||||
func (r *instanceRepository) GetByUserID(userID int, offset, limit int) ([]models.Instance, error) {
|
||||
var instances []models.Instance
|
||||
err := r.sess.Collection("instances").Find(db.Cond{"user_id": userID}).Offset(offset).Limit(limit).All(&instances)
|
||||
err := r.sess.Collection("instances").Find(db.Cond{"user_id": userID}).OrderBy("-created_at", "-id").Offset(offset).Limit(limit).All(&instances)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get instances: %w", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"clawreef/internal/models"
|
||||
|
||||
"github.com/upper/db/v4"
|
||||
)
|
||||
|
||||
type TeamRepository interface {
|
||||
CreateTeam(team *models.Team) error
|
||||
UpdateTeam(team *models.Team) error
|
||||
GetTeamByID(id int) (*models.Team, error)
|
||||
GetTeamByUserIDAndName(userID int, name string) (*models.Team, error)
|
||||
ExistsByUserIDAndName(userID int, name string) (bool, error)
|
||||
ListTeamsByUserID(userID int, offset, limit int) ([]models.Team, error)
|
||||
ListActiveTeams() ([]models.Team, error)
|
||||
CountTeamsByUserID(userID int) (int, error)
|
||||
|
||||
CreateMember(member *models.TeamMember) error
|
||||
UpdateMember(member *models.TeamMember) error
|
||||
GetMemberByID(id int) (*models.TeamMember, error)
|
||||
GetMemberByTeamKey(teamID int, memberKey string) (*models.TeamMember, error)
|
||||
ListMembersByTeamID(teamID int) ([]models.TeamMember, error)
|
||||
|
||||
CreateTask(task *models.TeamTask) error
|
||||
UpdateTask(task *models.TeamTask) error
|
||||
GetTaskByID(id int) (*models.TeamTask, error)
|
||||
GetTaskByMessageID(teamID int, messageID string) (*models.TeamTask, error)
|
||||
ListTasksByTeamID(teamID int, limit int) ([]models.TeamTask, error)
|
||||
ListStaleCandidateTasks(cutoff time.Time, limit int) ([]models.TeamTask, error)
|
||||
|
||||
CreateEvent(event *models.TeamEvent) error
|
||||
EventExistsByStreamID(teamID int, streamID string) (bool, error)
|
||||
ListEventsByTeamID(teamID int, limit int) ([]models.TeamEvent, error)
|
||||
}
|
||||
|
||||
type teamRepository struct {
|
||||
sess db.Session
|
||||
}
|
||||
|
||||
func NewTeamRepository(sess db.Session) TeamRepository {
|
||||
return &teamRepository{sess: sess}
|
||||
}
|
||||
|
||||
func (r *teamRepository) CreateTeam(team *models.Team) error {
|
||||
ensureTimestamps(&team.CreatedAt, &team.UpdatedAt)
|
||||
res, err := r.sess.Collection("teams").Insert(team)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "Duplicate entry") && strings.Contains(err.Error(), "uk_teams_user_name") {
|
||||
return fmt.Errorf("team name already exists")
|
||||
}
|
||||
return fmt.Errorf("failed to create team: %w", err)
|
||||
}
|
||||
if id, ok := res.ID().(int64); ok {
|
||||
team.ID = int(id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *teamRepository) UpdateTeam(team *models.Team) error {
|
||||
if team.UpdatedAt.IsZero() {
|
||||
team.UpdatedAt = time.Now().UTC()
|
||||
}
|
||||
if err := r.sess.Collection("teams").Find(db.Cond{"id": team.ID}).Update(team); err != nil {
|
||||
return fmt.Errorf("failed to update team: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *teamRepository) GetTeamByID(id int) (*models.Team, error) {
|
||||
var team models.Team
|
||||
if err := r.sess.Collection("teams").Find(db.Cond{"id": id}).One(&team); err != nil {
|
||||
if err == db.ErrNoMoreRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get team: %w", err)
|
||||
}
|
||||
return &team, nil
|
||||
}
|
||||
|
||||
func (r *teamRepository) GetTeamByUserIDAndName(userID int, name string) (*models.Team, error) {
|
||||
var team models.Team
|
||||
if err := r.sess.Collection("teams").Find(db.Cond{"user_id": userID, "name": name}).One(&team); err != nil {
|
||||
if err == db.ErrNoMoreRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get team by name: %w", err)
|
||||
}
|
||||
return &team, nil
|
||||
}
|
||||
|
||||
func (r *teamRepository) ExistsByUserIDAndName(userID int, name string) (bool, error) {
|
||||
count, err := r.sess.Collection("teams").Find(db.Cond{"user_id": userID, "name": name}).Count()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to check team name: %w", err)
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (r *teamRepository) ListTeamsByUserID(userID int, offset, limit int) ([]models.Team, error) {
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
var teams []models.Team
|
||||
if err := r.sess.Collection("teams").Find(db.Cond{"user_id": userID}).OrderBy("-created_at", "-id").Offset(offset).Limit(limit).All(&teams); err != nil {
|
||||
return nil, fmt.Errorf("failed to list teams: %w", err)
|
||||
}
|
||||
return teams, nil
|
||||
}
|
||||
|
||||
func (r *teamRepository) ListActiveTeams() ([]models.Team, error) {
|
||||
var teams []models.Team
|
||||
if err := r.sess.Collection("teams").Find(db.Cond{"status IN": []string{models.TeamStatusCreating, models.TeamStatusRunning}}).All(&teams); err != nil {
|
||||
return nil, fmt.Errorf("failed to list active teams: %w", err)
|
||||
}
|
||||
return teams, nil
|
||||
}
|
||||
|
||||
func (r *teamRepository) CountTeamsByUserID(userID int) (int, error) {
|
||||
count, err := r.sess.Collection("teams").Find(db.Cond{"user_id": userID}).Count()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to count teams: %w", err)
|
||||
}
|
||||
return int(count), nil
|
||||
}
|
||||
|
||||
func (r *teamRepository) CreateMember(member *models.TeamMember) error {
|
||||
ensureTimestamps(&member.CreatedAt, &member.UpdatedAt)
|
||||
res, err := r.sess.Collection("team_members").Insert(member)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create team member: %w", err)
|
||||
}
|
||||
if id, ok := res.ID().(int64); ok {
|
||||
member.ID = int(id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *teamRepository) UpdateMember(member *models.TeamMember) error {
|
||||
if member.UpdatedAt.IsZero() {
|
||||
member.UpdatedAt = time.Now().UTC()
|
||||
}
|
||||
if err := r.sess.Collection("team_members").Find(db.Cond{"id": member.ID}).Update(member); err != nil {
|
||||
return fmt.Errorf("failed to update team member: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *teamRepository) GetMemberByID(id int) (*models.TeamMember, error) {
|
||||
var member models.TeamMember
|
||||
if err := r.sess.Collection("team_members").Find(db.Cond{"id": id}).One(&member); err != nil {
|
||||
if err == db.ErrNoMoreRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get team member: %w", err)
|
||||
}
|
||||
return &member, nil
|
||||
}
|
||||
|
||||
func (r *teamRepository) GetMemberByTeamKey(teamID int, memberKey string) (*models.TeamMember, error) {
|
||||
var member models.TeamMember
|
||||
if err := r.sess.Collection("team_members").Find(db.Cond{"team_id": teamID, "member_key": memberKey}).One(&member); err != nil {
|
||||
if err == db.ErrNoMoreRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get team member by key: %w", err)
|
||||
}
|
||||
return &member, nil
|
||||
}
|
||||
|
||||
func (r *teamRepository) ListMembersByTeamID(teamID int) ([]models.TeamMember, error) {
|
||||
var members []models.TeamMember
|
||||
if err := r.sess.Collection("team_members").Find(db.Cond{"team_id": teamID}).OrderBy("id").All(&members); err != nil {
|
||||
return nil, fmt.Errorf("failed to list team members: %w", err)
|
||||
}
|
||||
return members, nil
|
||||
}
|
||||
|
||||
func (r *teamRepository) CreateTask(task *models.TeamTask) error {
|
||||
ensureTimestamps(&task.CreatedAt, &task.UpdatedAt)
|
||||
res, err := r.sess.Collection("team_tasks").Insert(task)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create team task: %w", err)
|
||||
}
|
||||
if id, ok := res.ID().(int64); ok {
|
||||
task.ID = int(id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *teamRepository) UpdateTask(task *models.TeamTask) error {
|
||||
if task.UpdatedAt.IsZero() {
|
||||
task.UpdatedAt = time.Now().UTC()
|
||||
}
|
||||
if err := r.sess.Collection("team_tasks").Find(db.Cond{"id": task.ID}).Update(task); err != nil {
|
||||
return fmt.Errorf("failed to update team task: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *teamRepository) GetTaskByID(id int) (*models.TeamTask, error) {
|
||||
var task models.TeamTask
|
||||
if err := r.sess.Collection("team_tasks").Find(db.Cond{"id": id}).One(&task); err != nil {
|
||||
if err == db.ErrNoMoreRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get team task: %w", err)
|
||||
}
|
||||
return &task, nil
|
||||
}
|
||||
|
||||
func (r *teamRepository) GetTaskByMessageID(teamID int, messageID string) (*models.TeamTask, error) {
|
||||
var task models.TeamTask
|
||||
if err := r.sess.Collection("team_tasks").Find(db.Cond{"team_id": teamID, "message_id": messageID}).One(&task); err != nil {
|
||||
if err == db.ErrNoMoreRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get team task by message id: %w", err)
|
||||
}
|
||||
return &task, nil
|
||||
}
|
||||
|
||||
func (r *teamRepository) ListTasksByTeamID(teamID int, limit int) ([]models.TeamTask, error) {
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
var tasks []models.TeamTask
|
||||
if err := r.sess.Collection("team_tasks").Find(db.Cond{"team_id": teamID}).OrderBy("-created_at", "-id").Limit(limit).All(&tasks); err != nil {
|
||||
return nil, fmt.Errorf("failed to list team tasks: %w", err)
|
||||
}
|
||||
return tasks, nil
|
||||
}
|
||||
|
||||
func (r *teamRepository) ListStaleCandidateTasks(cutoff time.Time, limit int) ([]models.TeamTask, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
var tasks []models.TeamTask
|
||||
statuses := []string{
|
||||
models.TeamTaskStatusDispatched,
|
||||
models.TeamTaskStatusRunning,
|
||||
}
|
||||
if err := r.sess.Collection("team_tasks").Find(db.Cond{
|
||||
"status IN": statuses,
|
||||
"updated_at <": cutoff,
|
||||
}).OrderBy("updated_at", "id").Limit(limit).All(&tasks); err != nil {
|
||||
return nil, fmt.Errorf("failed to list stale candidate team tasks: %w", err)
|
||||
}
|
||||
return tasks, nil
|
||||
}
|
||||
|
||||
func (r *teamRepository) CreateEvent(event *models.TeamEvent) error {
|
||||
if event.CreatedAt.IsZero() {
|
||||
event.CreatedAt = time.Now().UTC()
|
||||
}
|
||||
res, err := r.sess.Collection("team_events").Insert(event)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create team event: %w", err)
|
||||
}
|
||||
if id, ok := res.ID().(int64); ok {
|
||||
event.ID = int(id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *teamRepository) EventExistsByStreamID(teamID int, streamID string) (bool, error) {
|
||||
if streamID == "" {
|
||||
return false, nil
|
||||
}
|
||||
count, err := r.sess.Collection("team_events").Find(db.Cond{"team_id": teamID, "redis_stream_id": streamID}).Count()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to check team event stream id: %w", err)
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (r *teamRepository) ListEventsByTeamID(teamID int, limit int) ([]models.TeamEvent, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
var events []models.TeamEvent
|
||||
if err := r.sess.Collection("team_events").Find(db.Cond{"team_id": teamID}).OrderBy("-created_at", "-id").Limit(limit).All(&events); err != nil {
|
||||
return nil, fmt.Errorf("failed to list team events: %w", err)
|
||||
}
|
||||
return events, nil
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
// InstanceService defines the interface for instance operations
|
||||
type InstanceService interface {
|
||||
Create(userID int, req CreateInstanceRequest) (*models.Instance, error)
|
||||
ValidateCreateRequests(userID int, requests []CreateInstanceRequest) error
|
||||
GetByID(id int) (*models.Instance, error)
|
||||
GetByUserID(userID int, offset, limit int) ([]models.Instance, int, error)
|
||||
GetAllInstances(offset, limit int) ([]models.Instance, int, error)
|
||||
@@ -33,6 +34,98 @@ type InstanceService interface {
|
||||
ForceSyncInstance(instanceID int) error
|
||||
}
|
||||
|
||||
func (s *instanceService) ValidateCreateRequests(userID int, requests []CreateInstanceRequest) error {
|
||||
if len(requests) == 0 {
|
||||
return nil
|
||||
}
|
||||
for idx := range requests {
|
||||
requests[idx].Name = strings.TrimSpace(requests[idx].Name)
|
||||
if requests[idx].Name == "" {
|
||||
return fmt.Errorf("instance name is required")
|
||||
}
|
||||
environmentOverrides, err := normalizeEnvironmentOverrides(requests[idx].EnvironmentOverrides)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := marshalEnvironmentOverrides(environmentOverrides); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
quota, err := s.quotaRepo.GetByUserID(userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get user quota: %w", err)
|
||||
}
|
||||
if quota == nil {
|
||||
return fmt.Errorf("user quota not found")
|
||||
}
|
||||
|
||||
currentCount, err := s.instanceRepo.CountByUserID(userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to count instances: %w", err)
|
||||
}
|
||||
if currentCount+len(requests) > quota.MaxInstances {
|
||||
return fmt.Errorf("instance limit reached: %d/%d", currentCount+len(requests), quota.MaxInstances)
|
||||
}
|
||||
|
||||
existingInstances, err := s.instanceRepo.GetByUserID(userID, 0, 1000)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list user instances for quota validation: %w", err)
|
||||
}
|
||||
|
||||
currentCPU := 0.0
|
||||
currentMemory := 0
|
||||
currentStorage := 0
|
||||
currentGPU := 0
|
||||
existingNames := map[string]struct{}{}
|
||||
for _, existing := range existingInstances {
|
||||
currentCPU += existing.CPUCores
|
||||
currentMemory += existing.MemoryGB
|
||||
currentStorage += existing.DiskGB
|
||||
if existing.GPUEnabled {
|
||||
currentGPU += existing.GPUCount
|
||||
}
|
||||
existingNames[strings.TrimSpace(strings.ToLower(existing.Name))] = struct{}{}
|
||||
}
|
||||
|
||||
requestedCPU := 0.0
|
||||
requestedMemory := 0
|
||||
requestedStorage := 0
|
||||
requestedGPU := 0
|
||||
requestNames := map[string]struct{}{}
|
||||
for _, req := range requests {
|
||||
normalizedName := strings.TrimSpace(strings.ToLower(req.Name))
|
||||
if _, exists := existingNames[normalizedName]; exists {
|
||||
return fmt.Errorf("instance name already exists")
|
||||
}
|
||||
if _, exists := requestNames[normalizedName]; exists {
|
||||
return fmt.Errorf("instance name already exists")
|
||||
}
|
||||
requestNames[normalizedName] = struct{}{}
|
||||
requestedCPU += req.CPUCores
|
||||
requestedMemory += req.MemoryGB
|
||||
requestedStorage += req.DiskGB
|
||||
if req.GPUEnabled {
|
||||
requestedGPU += req.GPUCount
|
||||
}
|
||||
}
|
||||
|
||||
if currentCPU+requestedCPU > quota.MaxCPUCores {
|
||||
return fmt.Errorf("CPU cores exceed quota: current %v, requested %v, max %v", currentCPU, requestedCPU, quota.MaxCPUCores)
|
||||
}
|
||||
if currentMemory+requestedMemory > quota.MaxMemoryGB {
|
||||
return fmt.Errorf("memory exceed quota: current %dGB, requested %dGB, max %dGB", currentMemory, requestedMemory, quota.MaxMemoryGB)
|
||||
}
|
||||
if currentStorage+requestedStorage > quota.MaxStorageGB {
|
||||
return fmt.Errorf("storage exceed quota: current %dGB, requested %dGB, max %dGB", currentStorage, requestedStorage, quota.MaxStorageGB)
|
||||
}
|
||||
if currentGPU+requestedGPU > quota.MaxGPUCount {
|
||||
return fmt.Errorf("GPU count exceed quota: current %d, requested %d, max %d", currentGPU, requestedGPU, quota.MaxGPUCount)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateInstanceRequest holds data for creating an instance
|
||||
type CreateInstanceRequest struct {
|
||||
Name string `json:"name" validate:"required,min=3,max=50"`
|
||||
@@ -51,6 +144,16 @@ type CreateInstanceRequest struct {
|
||||
EnvironmentOverrides map[string]string `json:"environment_overrides,omitempty"`
|
||||
StorageClass string `json:"storage_class"`
|
||||
OpenClawConfigPlan *OpenClawConfigPlan `json:"openclaw_config_plan,omitempty"`
|
||||
Team *TeamInstanceConfig `json:"-"`
|
||||
}
|
||||
|
||||
type TeamInstanceConfig struct {
|
||||
Environment map[string]string
|
||||
SecretName string
|
||||
SharedPVCName string
|
||||
SharedMountPath string
|
||||
ConfigMapName string
|
||||
ConfigMountPath string
|
||||
}
|
||||
|
||||
// UpdateInstanceRequest holds data for updating an instance
|
||||
@@ -273,6 +376,9 @@ func (s *instanceService) Create(userID int, req CreateInstanceRequest) (*models
|
||||
s.instanceRepo.Delete(instance.ID)
|
||||
return nil, fmt.Errorf("failed to resolve instance environment: %w", err)
|
||||
}
|
||||
if req.Team != nil {
|
||||
extraEnv = mergeEnvMaps(extraEnv, req.Team.Environment)
|
||||
}
|
||||
|
||||
var bootstrapSnapshot *models.OpenClawInjectionSnapshot
|
||||
var bootstrapSecretName string
|
||||
@@ -327,24 +433,51 @@ func (s *instanceService) Create(userID int, req CreateInstanceRequest) (*models
|
||||
|
||||
// Create Pod
|
||||
shmSizeGB := popSHMSizeGB(extraEnv)
|
||||
envFromSecretNames := []string{bootstrapSecretName}
|
||||
extraPVCMounts := []k8s.PVCMount{}
|
||||
configMapFileMounts := []k8s.ConfigMapFileMount{}
|
||||
if req.Team != nil {
|
||||
if strings.TrimSpace(req.Team.SecretName) != "" {
|
||||
envFromSecretNames = append(envFromSecretNames, strings.TrimSpace(req.Team.SecretName))
|
||||
}
|
||||
if strings.TrimSpace(req.Team.SharedPVCName) != "" && strings.TrimSpace(req.Team.SharedMountPath) != "" {
|
||||
extraPVCMounts = append(extraPVCMounts, k8s.PVCMount{
|
||||
Name: "team-shared",
|
||||
ClaimName: strings.TrimSpace(req.Team.SharedPVCName),
|
||||
MountPath: strings.TrimSpace(req.Team.SharedMountPath),
|
||||
})
|
||||
}
|
||||
if strings.TrimSpace(req.Team.ConfigMapName) != "" && strings.TrimSpace(req.Team.ConfigMountPath) != "" {
|
||||
configMapFileMounts = append(configMapFileMounts, k8s.ConfigMapFileMount{
|
||||
Name: "team-config",
|
||||
ConfigMapName: strings.TrimSpace(req.Team.ConfigMapName),
|
||||
Key: "team.json",
|
||||
MountPath: strings.TrimSpace(req.Team.ConfigMountPath),
|
||||
ReadOnly: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
podConfig := k8s.PodConfig{
|
||||
InstanceID: instance.ID,
|
||||
InstanceName: instance.Name,
|
||||
UserID: userID,
|
||||
Type: instance.Type,
|
||||
RuntimeType: runtimeType,
|
||||
CPUCores: instance.CPUCores,
|
||||
MemoryGB: instance.MemoryGB,
|
||||
GPUEnabled: instance.GPUEnabled,
|
||||
GPUCount: instance.GPUCount,
|
||||
Image: runtimeConfig.Image,
|
||||
MountPath: runtimeConfig.MountPath,
|
||||
ContainerPort: runtimeConfig.Port,
|
||||
ImagePullPolicy: corev1.PullPolicy(defaultImagePullPolicy()),
|
||||
ExtraEnv: extraEnv,
|
||||
EnvFromSecretNames: []string{bootstrapSecretName},
|
||||
SHMSizeGB: shmSizeGB,
|
||||
SecurityMode: s.securityModeForInstance(instance.Type),
|
||||
InstanceID: instance.ID,
|
||||
InstanceName: instance.Name,
|
||||
UserID: userID,
|
||||
Type: instance.Type,
|
||||
RuntimeType: runtimeType,
|
||||
CPUCores: instance.CPUCores,
|
||||
MemoryGB: instance.MemoryGB,
|
||||
GPUEnabled: instance.GPUEnabled,
|
||||
GPUCount: instance.GPUCount,
|
||||
Image: runtimeConfig.Image,
|
||||
MountPath: runtimeConfig.MountPath,
|
||||
ContainerPort: runtimeConfig.Port,
|
||||
ImagePullPolicy: corev1.PullPolicy(defaultImagePullPolicy()),
|
||||
ExtraEnv: extraEnv,
|
||||
EnvFromSecretNames: envFromSecretNames,
|
||||
ExtraPVCMounts: extraPVCMounts,
|
||||
ConfigMapFileMounts: configMapFileMounts,
|
||||
SHMSizeGB: shmSizeGB,
|
||||
SecurityMode: s.securityModeForInstance(instance.Type),
|
||||
}
|
||||
|
||||
pod, err := s.podService.CreatePod(ctx, podConfig)
|
||||
|
||||
@@ -240,6 +240,21 @@ func (c *Client) GetPVCName(instanceID int) string {
|
||||
return sanitizeK8sName(fmt.Sprintf("clawreef-%d-pvc", instanceID))
|
||||
}
|
||||
|
||||
// GetTeamSharedPVCName returns the PVC name used for a Team shared workspace.
|
||||
func (c *Client) GetTeamSharedPVCName(teamID int) string {
|
||||
return sanitizeK8sName(fmt.Sprintf("clawreef-team-%d-shared", teamID))
|
||||
}
|
||||
|
||||
// GetTeamSecretName returns the Secret name used for Team Redis URL and token env.
|
||||
func (c *Client) GetTeamSecretName(teamID int) string {
|
||||
return sanitizeK8sName(fmt.Sprintf("clawreef-team-%d-bus", teamID))
|
||||
}
|
||||
|
||||
// GetTeamConfigMapName returns the ConfigMap name used for Team roster/config.
|
||||
func (c *Client) GetTeamConfigMapName(teamID int) string {
|
||||
return sanitizeK8sName(fmt.Sprintf("clawreef-team-%d-config", teamID))
|
||||
}
|
||||
|
||||
// GetServiceName returns the service name for an instance
|
||||
func (c *Client) GetServiceName(instanceID int, instanceName string) string {
|
||||
return sanitizeK8sName(fmt.Sprintf("clawreef-%d-%s-svc", instanceID, instanceName))
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package k8s
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
"k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
// ConfigMapService handles Kubernetes ConfigMap writes used for non-sensitive runtime config.
|
||||
type ConfigMapService struct {
|
||||
client *Client
|
||||
namespaceService *NamespaceService
|
||||
}
|
||||
|
||||
// NewConfigMapService creates a new ConfigMap service.
|
||||
func NewConfigMapService() *ConfigMapService {
|
||||
return &ConfigMapService{
|
||||
client: globalClient,
|
||||
namespaceService: NewNamespaceService(),
|
||||
}
|
||||
}
|
||||
|
||||
// UpsertConfigMap creates or updates a ConfigMap in the user's namespace.
|
||||
func (s *ConfigMapService) UpsertConfigMap(ctx context.Context, userID int, name string, data map[string]string, labels map[string]string) error {
|
||||
if s.client == nil || s.client.Clientset == nil {
|
||||
return fmt.Errorf("k8s client not initialized")
|
||||
}
|
||||
|
||||
if _, err := s.namespaceService.EnsureNamespace(ctx, userID); err != nil {
|
||||
return fmt.Errorf("failed to ensure namespace: %w", err)
|
||||
}
|
||||
|
||||
namespace := s.client.GetNamespace(userID)
|
||||
configMap := &corev1.ConfigMap{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: name,
|
||||
Namespace: namespace,
|
||||
Labels: labels,
|
||||
},
|
||||
Data: data,
|
||||
}
|
||||
|
||||
existing, err := s.client.Clientset.CoreV1().ConfigMaps(namespace).Get(ctx, name, metav1.GetOptions{})
|
||||
if err == nil && existing != nil {
|
||||
existing.Data = data
|
||||
if existing.Labels == nil {
|
||||
existing.Labels = map[string]string{}
|
||||
}
|
||||
for key, value := range labels {
|
||||
existing.Labels[key] = value
|
||||
}
|
||||
if _, err := s.client.Clientset.CoreV1().ConfigMaps(namespace).Update(ctx, existing, metav1.UpdateOptions{}); err != nil {
|
||||
return fmt.Errorf("failed to update configmap %s/%s: %w", namespace, name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err != nil && !errors.IsNotFound(err) {
|
||||
return fmt.Errorf("failed to inspect configmap %s/%s: %w", namespace, name, err)
|
||||
}
|
||||
|
||||
if _, err := s.client.Clientset.CoreV1().ConfigMaps(namespace).Create(ctx, configMap, metav1.CreateOptions{}); err != nil {
|
||||
return fmt.Errorf("failed to create configmap %s/%s: %w", namespace, name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteConfigMap deletes a ConfigMap from the user's namespace. Missing maps are treated as already deleted.
|
||||
func (s *ConfigMapService) DeleteConfigMap(ctx context.Context, userID int, name string) error {
|
||||
if s.client == nil || s.client.Clientset == nil {
|
||||
return fmt.Errorf("k8s client not initialized")
|
||||
}
|
||||
namespace := s.client.GetNamespace(userID)
|
||||
if err := s.client.Clientset.CoreV1().ConfigMaps(namespace).Delete(ctx, name, metav1.DeleteOptions{}); err != nil {
|
||||
if errors.IsNotFound(err) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("failed to delete configmap %s/%s: %w", namespace, name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -42,23 +42,40 @@ func (s *PodService) GetClient() *Client {
|
||||
|
||||
// PodConfig holds configuration for creating a pod
|
||||
type PodConfig struct {
|
||||
InstanceID int
|
||||
InstanceName string
|
||||
UserID int
|
||||
Type string
|
||||
RuntimeType string
|
||||
CPUCores float64
|
||||
MemoryGB int
|
||||
GPUEnabled bool
|
||||
GPUCount int
|
||||
Image string
|
||||
MountPath string
|
||||
ContainerPort int32
|
||||
ImagePullPolicy corev1.PullPolicy
|
||||
ExtraEnv map[string]string
|
||||
EnvFromSecretNames []string
|
||||
SHMSizeGB int
|
||||
SecurityMode PodSecurityMode
|
||||
InstanceID int
|
||||
InstanceName string
|
||||
UserID int
|
||||
Type string
|
||||
RuntimeType string
|
||||
CPUCores float64
|
||||
MemoryGB int
|
||||
GPUEnabled bool
|
||||
GPUCount int
|
||||
Image string
|
||||
MountPath string
|
||||
ContainerPort int32
|
||||
ImagePullPolicy corev1.PullPolicy
|
||||
ExtraEnv map[string]string
|
||||
EnvFromSecretNames []string
|
||||
ExtraPVCMounts []PVCMount
|
||||
ConfigMapFileMounts []ConfigMapFileMount
|
||||
SHMSizeGB int
|
||||
SecurityMode PodSecurityMode
|
||||
}
|
||||
|
||||
type PVCMount struct {
|
||||
Name string
|
||||
ClaimName string
|
||||
MountPath string
|
||||
ReadOnly bool
|
||||
}
|
||||
|
||||
type ConfigMapFileMount struct {
|
||||
Name string
|
||||
ConfigMapName string
|
||||
Key string
|
||||
MountPath string
|
||||
ReadOnly bool
|
||||
}
|
||||
|
||||
// CreatePod creates a new pod for an instance
|
||||
@@ -215,6 +232,52 @@ func (s *PodService) CreatePod(ctx context.Context, config PodConfig) (*corev1.P
|
||||
})
|
||||
}
|
||||
|
||||
for _, mount := range config.ExtraPVCMounts {
|
||||
if mount.Name == "" || mount.ClaimName == "" || mount.MountPath == "" {
|
||||
continue
|
||||
}
|
||||
pod.Spec.Volumes = append(pod.Spec.Volumes, corev1.Volume{
|
||||
Name: mount.Name,
|
||||
VolumeSource: corev1.VolumeSource{
|
||||
PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{
|
||||
ClaimName: mount.ClaimName,
|
||||
ReadOnly: mount.ReadOnly,
|
||||
},
|
||||
},
|
||||
})
|
||||
pod.Spec.Containers[0].VolumeMounts = append(pod.Spec.Containers[0].VolumeMounts, corev1.VolumeMount{
|
||||
Name: mount.Name,
|
||||
MountPath: mount.MountPath,
|
||||
ReadOnly: mount.ReadOnly,
|
||||
})
|
||||
}
|
||||
|
||||
for _, mount := range config.ConfigMapFileMounts {
|
||||
if mount.Name == "" || mount.ConfigMapName == "" || mount.Key == "" || mount.MountPath == "" {
|
||||
continue
|
||||
}
|
||||
pod.Spec.Volumes = append(pod.Spec.Volumes, corev1.Volume{
|
||||
Name: mount.Name,
|
||||
VolumeSource: corev1.VolumeSource{
|
||||
ConfigMap: &corev1.ConfigMapVolumeSource{
|
||||
LocalObjectReference: corev1.LocalObjectReference{Name: mount.ConfigMapName},
|
||||
Items: []corev1.KeyToPath{
|
||||
{
|
||||
Key: mount.Key,
|
||||
Path: mount.Key,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
pod.Spec.Containers[0].VolumeMounts = append(pod.Spec.Containers[0].VolumeMounts, corev1.VolumeMount{
|
||||
Name: mount.Name,
|
||||
MountPath: mount.MountPath,
|
||||
SubPath: mount.Key,
|
||||
ReadOnly: true,
|
||||
})
|
||||
}
|
||||
|
||||
if config.SHMSizeGB > 0 {
|
||||
shmLimit := resource.MustParse(fmt.Sprintf("%dGi", config.SHMSizeGB))
|
||||
pod.Spec.Volumes = append(pod.Spec.Volumes, corev1.Volume{
|
||||
|
||||
@@ -117,3 +117,64 @@ func TestCreatePodShellRuntimeSkipsDesktopNetworkProbes(t *testing.T) {
|
||||
t.Fatalf("expected shell runtime to skip desktop TCP probes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePodAppliesExtraPVCMountsAndSecretEnv(t *testing.T) {
|
||||
previousClient := globalClient
|
||||
t.Cleanup(func() {
|
||||
globalClient = previousClient
|
||||
})
|
||||
|
||||
globalClient = &Client{
|
||||
Clientset: fake.NewSimpleClientset(),
|
||||
Namespace: "clawreef",
|
||||
StorageClass: "standard",
|
||||
}
|
||||
|
||||
service := NewPodService()
|
||||
pod, err := service.CreatePod(context.Background(), PodConfig{
|
||||
InstanceID: 43,
|
||||
InstanceName: "openclaw-team",
|
||||
UserID: 7,
|
||||
Type: "openclaw",
|
||||
CPUCores: 1,
|
||||
MemoryGB: 2,
|
||||
Image: "openclaw:test",
|
||||
MountPath: "/config",
|
||||
ContainerPort: 3001,
|
||||
EnvFromSecretNames: []string{"clawreef-team-1-bus"},
|
||||
ExtraPVCMounts: []PVCMount{
|
||||
{Name: "team-shared", ClaimName: "clawreef-team-1-shared", MountPath: "/team"},
|
||||
},
|
||||
ConfigMapFileMounts: []ConfigMapFileMount{
|
||||
{Name: "team-config", ConfigMapName: "clawreef-team-1-config", Key: "team.json", MountPath: "/team/team.json", ReadOnly: true},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreatePod returned error: %v", err)
|
||||
}
|
||||
|
||||
container := pod.Spec.Containers[0]
|
||||
if len(container.EnvFrom) != 1 || container.EnvFrom[0].SecretRef == nil || container.EnvFrom[0].SecretRef.Name != "clawreef-team-1-bus" {
|
||||
t.Fatalf("expected Team secret envFrom, got %#v", container.EnvFrom)
|
||||
}
|
||||
|
||||
foundMount := false
|
||||
for _, mount := range container.VolumeMounts {
|
||||
if mount.Name == "team-shared" && mount.MountPath == "/team" {
|
||||
foundMount = true
|
||||
}
|
||||
}
|
||||
if !foundMount {
|
||||
t.Fatalf("expected /team shared PVC mount, got %#v", container.VolumeMounts)
|
||||
}
|
||||
|
||||
foundConfig := false
|
||||
for _, mount := range container.VolumeMounts {
|
||||
if mount.Name == "team-config" && mount.MountPath == "/team/team.json" && mount.SubPath == "team.json" && mount.ReadOnly {
|
||||
foundConfig = true
|
||||
}
|
||||
}
|
||||
if !foundConfig {
|
||||
t.Fatalf("expected /team/team.json ConfigMap file mount, got %#v", container.VolumeMounts)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +115,189 @@ func (s *PVCService) CreatePVC(ctx context.Context, userID, instanceID int, stor
|
||||
return createdPVC, nil
|
||||
}
|
||||
|
||||
// CreateTeamSharedPVC creates the RWX PVC mounted by every member of a Team.
|
||||
func (s *PVCService) CreateTeamSharedPVC(ctx context.Context, userID, teamID, storageSizeGB int, storageClass string) (*corev1.PersistentVolumeClaim, error) {
|
||||
if s.client == nil {
|
||||
return nil, fmt.Errorf("k8s client not initialized")
|
||||
}
|
||||
if storageSizeGB <= 0 {
|
||||
storageSizeGB = 10
|
||||
}
|
||||
|
||||
if _, err := s.namespaceService.EnsureNamespace(ctx, userID); err != nil {
|
||||
return nil, fmt.Errorf("failed to ensure namespace: %w", err)
|
||||
}
|
||||
|
||||
pvcName := s.client.GetTeamSharedPVCName(teamID)
|
||||
namespace := s.client.GetNamespace(userID)
|
||||
if storageClass == "" {
|
||||
storageClass = s.client.StorageClass
|
||||
}
|
||||
storageSize := resource.MustParse(fmt.Sprintf("%dGi", storageSizeGB))
|
||||
|
||||
pvc := &corev1.PersistentVolumeClaim{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: pvcName,
|
||||
Namespace: namespace,
|
||||
Labels: map[string]string{
|
||||
"app": "clawreef",
|
||||
"team-id": fmt.Sprintf("%d", teamID),
|
||||
"managed-by": "clawreef",
|
||||
},
|
||||
},
|
||||
Spec: corev1.PersistentVolumeClaimSpec{
|
||||
AccessModes: []corev1.PersistentVolumeAccessMode{
|
||||
corev1.ReadWriteMany,
|
||||
},
|
||||
Resources: corev1.VolumeResourceRequirements{
|
||||
Requests: corev1.ResourceList{
|
||||
corev1.ResourceStorage: storageSize,
|
||||
},
|
||||
},
|
||||
StorageClassName: &storageClass,
|
||||
},
|
||||
}
|
||||
|
||||
createdPVC, err := s.client.Clientset.CoreV1().PersistentVolumeClaims(namespace).Create(ctx, pvc, metav1.CreateOptions{})
|
||||
if err != nil {
|
||||
if errors.IsAlreadyExists(err) {
|
||||
existingPVC, getErr := s.client.Clientset.CoreV1().PersistentVolumeClaims(namespace).Get(ctx, pvcName, metav1.GetOptions{})
|
||||
if getErr == nil && existingPVC != nil && existingPVC.Labels["team-id"] == fmt.Sprintf("%d", teamID) {
|
||||
if existingPVC.Status.Phase != corev1.ClaimBound {
|
||||
go s.monitorTeamSharedPVCBinding(context.Background(), namespace, pvcName, userID, teamID, storageSizeGB, storageClass, 15*time.Second)
|
||||
}
|
||||
return existingPVC, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("failed to create Team shared PVC %s: %w", pvcName, err)
|
||||
}
|
||||
|
||||
go s.monitorTeamSharedPVCBinding(context.Background(), namespace, pvcName, userID, teamID, storageSizeGB, storageClass, 15*time.Second)
|
||||
return createdPVC, nil
|
||||
}
|
||||
|
||||
func (s *PVCService) monitorTeamSharedPVCBinding(ctx context.Context, namespace, pvcName string, userID, teamID, storageSizeGB int, storageClass string, timeout time.Duration) {
|
||||
if _, err := s.waitForTeamSharedPVCBinding(ctx, namespace, pvcName, userID, teamID, storageSizeGB, storageClass, timeout); err != nil {
|
||||
fmt.Printf("Async Team shared PVC binding monitor failed for %s: %v\n", pvcName, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PVCService) waitForTeamSharedPVCBinding(ctx context.Context, namespace, pvcName string, userID, teamID, storageSizeGB int, storageClass string, timeout time.Duration) (*corev1.PersistentVolumeClaim, error) {
|
||||
pvc, err := s.client.Clientset.CoreV1().PersistentVolumeClaims(namespace).Get(ctx, pvcName, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get Team shared PVC %s: %w", pvcName, err)
|
||||
}
|
||||
if pvc.Status.Phase == corev1.ClaimBound {
|
||||
return pvc, nil
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(2 * time.Second)
|
||||
defer ticker.Stop()
|
||||
timeoutChan := time.After(timeout)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-timeoutChan:
|
||||
fmt.Printf("Team shared PVC %s binding timeout, creating hostPath RWX PV manually\n", pvcName)
|
||||
return s.createPVForTeamSharedPVC(ctx, namespace, pvcName, userID, teamID, storageSizeGB, storageClass)
|
||||
case <-ticker.C:
|
||||
pvc, err := s.client.Clientset.CoreV1().PersistentVolumeClaims(namespace).Get(ctx, pvcName, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get Team shared PVC %s during wait: %w", pvcName, err)
|
||||
}
|
||||
if pvc.Status.Phase == corev1.ClaimBound {
|
||||
fmt.Printf("Team shared PVC %s bound successfully to %s\n", pvcName, pvc.Spec.VolumeName)
|
||||
return pvc, nil
|
||||
}
|
||||
fmt.Printf("Waiting for Team shared PVC %s binding, current status: %s\n", pvcName, pvc.Status.Phase)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PVCService) createPVForTeamSharedPVC(ctx context.Context, namespace, pvcName string, userID, teamID, storageSizeGB int, storageClass string) (*corev1.PersistentVolumeClaim, error) {
|
||||
pvName := fmt.Sprintf("clawreef-pv-user-%d-team-%d-shared", userID, teamID)
|
||||
hostPathPrefix := "/data/clawreef"
|
||||
if s.client != nil && s.client.HostPathPrefix != "" {
|
||||
hostPathPrefix = s.client.HostPathPrefix
|
||||
}
|
||||
hostPath := fmt.Sprintf("%s/user-%d/team-%d-shared", hostPathPrefix, userID, teamID)
|
||||
|
||||
existingPV, err := s.client.Clientset.CoreV1().PersistentVolumes().Get(ctx, pvName, metav1.GetOptions{})
|
||||
if err == nil && existingPV != nil {
|
||||
if existingPV.Status.Phase == corev1.VolumeReleased {
|
||||
if deleteErr := s.client.Clientset.CoreV1().PersistentVolumes().Delete(ctx, pvName, metav1.DeleteOptions{}); deleteErr != nil && !errors.IsNotFound(deleteErr) {
|
||||
return nil, fmt.Errorf("failed to delete released Team shared PV %s: %w", pvName, deleteErr)
|
||||
}
|
||||
time.Sleep(3 * time.Second)
|
||||
} else if existingPV.Spec.ClaimRef != nil &&
|
||||
existingPV.Spec.ClaimRef.Namespace == namespace &&
|
||||
existingPV.Spec.ClaimRef.Name == pvcName {
|
||||
time.Sleep(2 * time.Second)
|
||||
return s.client.Clientset.CoreV1().PersistentVolumeClaims(namespace).Get(ctx, pvcName, metav1.GetOptions{})
|
||||
} else if existingPV.Spec.ClaimRef != nil {
|
||||
return nil, fmt.Errorf("Team shared PV %s already belongs to %s/%s", pvName, existingPV.Spec.ClaimRef.Namespace, existingPV.Spec.ClaimRef.Name)
|
||||
}
|
||||
}
|
||||
|
||||
pvc, err := s.client.Clientset.CoreV1().PersistentVolumeClaims(namespace).Get(ctx, pvcName, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get Team shared PVC %s for UID: %w", pvcName, err)
|
||||
}
|
||||
storageSize := resource.MustParse(fmt.Sprintf("%dGi", storageSizeGB))
|
||||
pv := &corev1.PersistentVolume{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: pvName,
|
||||
Labels: map[string]string{
|
||||
"app": "clawreef",
|
||||
"user-id": fmt.Sprintf("%d", userID),
|
||||
"team-id": fmt.Sprintf("%d", teamID),
|
||||
"managed-by": "clawreef",
|
||||
},
|
||||
},
|
||||
Spec: corev1.PersistentVolumeSpec{
|
||||
Capacity: corev1.ResourceList{
|
||||
corev1.ResourceStorage: storageSize,
|
||||
},
|
||||
AccessModes: []corev1.PersistentVolumeAccessMode{
|
||||
corev1.ReadWriteMany,
|
||||
},
|
||||
PersistentVolumeReclaimPolicy: corev1.PersistentVolumeReclaimRetain,
|
||||
StorageClassName: storageClass,
|
||||
PersistentVolumeSource: corev1.PersistentVolumeSource{
|
||||
HostPath: &corev1.HostPathVolumeSource{
|
||||
Path: hostPath,
|
||||
Type: func() *corev1.HostPathType {
|
||||
t := corev1.HostPathDirectoryOrCreate
|
||||
return &t
|
||||
}(),
|
||||
},
|
||||
},
|
||||
ClaimRef: &corev1.ObjectReference{
|
||||
Kind: "PersistentVolumeClaim",
|
||||
APIVersion: "v1",
|
||||
Namespace: namespace,
|
||||
Name: pvcName,
|
||||
UID: pvc.UID,
|
||||
ResourceVersion: pvc.ResourceVersion,
|
||||
},
|
||||
},
|
||||
}
|
||||
if _, err := s.client.Clientset.CoreV1().PersistentVolumes().Create(ctx, pv, metav1.CreateOptions{}); err != nil && !errors.IsAlreadyExists(err) {
|
||||
return nil, fmt.Errorf("failed to create Team shared PV %s: %w", pvName, err)
|
||||
}
|
||||
|
||||
time.Sleep(3 * time.Second)
|
||||
pvc, err = s.client.Clientset.CoreV1().PersistentVolumeClaims(namespace).Get(ctx, pvcName, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get Team shared PVC after PV creation: %w", err)
|
||||
}
|
||||
if pvc.Status.Phase != corev1.ClaimBound {
|
||||
return nil, fmt.Errorf("Team shared PVC %s is still not bound after PV creation, status: %s", pvcName, pvc.Status.Phase)
|
||||
}
|
||||
fmt.Printf("Team shared PVC %s successfully bound to PV %s\n", pvcName, pvName)
|
||||
return pvc, nil
|
||||
}
|
||||
|
||||
func (s *PVCService) monitorPVCBinding(ctx context.Context, namespace, pvcName string, userID, instanceID, storageSizeGB int, storageClass string, timeout time.Duration) {
|
||||
if _, err := s.waitForPVCBinding(ctx, namespace, pvcName, userID, instanceID, storageSizeGB, storageClass, timeout); err != nil {
|
||||
fmt.Printf("Async PVC binding monitor failed for %s: %v\n", pvcName, err)
|
||||
@@ -375,6 +558,31 @@ func (s *PVCService) DeletePVC(ctx context.Context, userID, instanceID int) erro
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteTeamSharedPVC deletes a Team shared PVC and the predictable hostPath PV
|
||||
// used by the single-node manual storage fallback. It does not delete an
|
||||
// arbitrary bound PV because that may belong to a real RWX provisioner.
|
||||
func (s *PVCService) DeleteTeamSharedPVC(ctx context.Context, userID, teamID int) error {
|
||||
if s.client == nil {
|
||||
return fmt.Errorf("k8s client not initialized")
|
||||
}
|
||||
|
||||
pvcName := s.client.GetTeamSharedPVCName(teamID)
|
||||
namespace := s.client.GetNamespace(userID)
|
||||
pvName := fmt.Sprintf("clawreef-pv-user-%d-team-%d-shared", userID, teamID)
|
||||
|
||||
if err := s.client.Clientset.CoreV1().PersistentVolumeClaims(namespace).Delete(ctx, pvcName, metav1.DeleteOptions{}); err != nil {
|
||||
if !errors.IsNotFound(err) {
|
||||
return fmt.Errorf("failed to delete Team shared PVC %s/%s: %w", namespace, pvcName, err)
|
||||
}
|
||||
}
|
||||
if err := s.client.Clientset.CoreV1().PersistentVolumes().Delete(ctx, pvName, metav1.DeleteOptions{}); err != nil {
|
||||
if !errors.IsNotFound(err) {
|
||||
return fmt.Errorf("failed to delete Team shared PV %s: %w", pvName, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PVCExists checks if a PVC exists
|
||||
func (s *PVCService) PVCExists(ctx context.Context, userID, instanceID int) (bool, error) {
|
||||
_, err := s.GetPVC(ctx, userID, instanceID)
|
||||
|
||||
@@ -91,3 +91,18 @@ func (s *SecretService) UpsertSecret(ctx context.Context, userID int, name strin
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteSecret deletes a Secret from the user's namespace. Missing secrets are treated as already deleted.
|
||||
func (s *SecretService) DeleteSecret(ctx context.Context, userID int, name string) error {
|
||||
if s.client == nil || s.client.Clientset == nil {
|
||||
return fmt.Errorf("k8s client not initialized")
|
||||
}
|
||||
namespace := s.client.GetNamespace(userID)
|
||||
if err := s.client.Clientset.CoreV1().Secrets(namespace).Delete(ctx, name, metav1.DeleteOptions{}); err != nil {
|
||||
if errors.IsNotFound(err) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("failed to delete secret %s/%s: %w", namespace, name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type redisStreamMessage struct {
|
||||
ID string
|
||||
Fields map[string]string
|
||||
}
|
||||
|
||||
type redisBus struct {
|
||||
address string
|
||||
password string
|
||||
db int
|
||||
useTLS bool
|
||||
}
|
||||
|
||||
func newRedisBus(rawURL string) (*redisBus, error) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(rawURL))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("redis url is invalid: %w", err)
|
||||
}
|
||||
if parsed.Scheme != "redis" && parsed.Scheme != "rediss" {
|
||||
return nil, fmt.Errorf("redis url scheme must be redis or rediss")
|
||||
}
|
||||
address := parsed.Host
|
||||
if !strings.Contains(address, ":") {
|
||||
address += ":6379"
|
||||
}
|
||||
password, _ := parsed.User.Password()
|
||||
dbIndex := 0
|
||||
if path := strings.Trim(parsed.Path, "/"); path != "" {
|
||||
parsedDB, err := strconv.Atoi(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("redis db index is invalid: %w", err)
|
||||
}
|
||||
dbIndex = parsedDB
|
||||
}
|
||||
return &redisBus{
|
||||
address: address,
|
||||
password: password,
|
||||
db: dbIndex,
|
||||
useTLS: parsed.Scheme == "rediss",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (b *redisBus) XAdd(ctx context.Context, key string, fields map[string]string) (string, error) {
|
||||
args := []string{"XADD", key, "*"}
|
||||
for field, value := range fields {
|
||||
args = append(args, field, value)
|
||||
}
|
||||
reply, err := b.do(ctx, args...)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
id, ok := reply.(string)
|
||||
if !ok || strings.TrimSpace(id) == "" {
|
||||
return "", fmt.Errorf("unexpected redis XADD response")
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (b *redisBus) XRead(ctx context.Context, key, lastID string, block time.Duration) ([]redisStreamMessage, error) {
|
||||
blockMillis := int(block / time.Millisecond)
|
||||
if blockMillis <= 0 {
|
||||
blockMillis = 5000
|
||||
}
|
||||
reply, err := b.do(ctx, "XREAD", "BLOCK", strconv.Itoa(blockMillis), "STREAMS", key, lastID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if reply == nil {
|
||||
return nil, nil
|
||||
}
|
||||
root, ok := reply.([]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected redis XREAD response")
|
||||
}
|
||||
var messages []redisStreamMessage
|
||||
for _, streamRaw := range root {
|
||||
stream, ok := streamRaw.([]interface{})
|
||||
if !ok || len(stream) != 2 {
|
||||
continue
|
||||
}
|
||||
messageList, ok := stream[1].([]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, messageRaw := range messageList {
|
||||
message, ok := messageRaw.([]interface{})
|
||||
if !ok || len(message) != 2 {
|
||||
continue
|
||||
}
|
||||
id, ok := message[0].(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
fieldList, ok := message[1].([]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
fields := map[string]string{}
|
||||
for i := 0; i+1 < len(fieldList); i += 2 {
|
||||
field, okField := fieldList[i].(string)
|
||||
value, okValue := fieldList[i+1].(string)
|
||||
if okField && okValue {
|
||||
fields[field] = value
|
||||
}
|
||||
}
|
||||
messages = append(messages, redisStreamMessage{ID: id, Fields: fields})
|
||||
}
|
||||
}
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
func (b *redisBus) XRevRange(ctx context.Context, key string, count int) ([]redisStreamMessage, error) {
|
||||
if count <= 0 {
|
||||
count = 100
|
||||
}
|
||||
reply, err := b.do(ctx, "XREVRANGE", key, "+", "-", "COUNT", strconv.Itoa(count))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
root, ok := reply.([]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected redis XREVRANGE response")
|
||||
}
|
||||
return parseRedisStreamEntries(root), nil
|
||||
}
|
||||
|
||||
func parseRedisStreamEntries(entries []interface{}) []redisStreamMessage {
|
||||
messages := make([]redisStreamMessage, 0, len(entries))
|
||||
for _, messageRaw := range entries {
|
||||
message, ok := messageRaw.([]interface{})
|
||||
if !ok || len(message) != 2 {
|
||||
continue
|
||||
}
|
||||
id, ok := message[0].(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
fieldList, ok := message[1].([]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
fields := map[string]string{}
|
||||
for i := 0; i+1 < len(fieldList); i += 2 {
|
||||
field, okField := fieldList[i].(string)
|
||||
value, okValue := fieldList[i+1].(string)
|
||||
if okField && okValue {
|
||||
fields[field] = value
|
||||
}
|
||||
}
|
||||
messages = append(messages, redisStreamMessage{ID: id, Fields: fields})
|
||||
}
|
||||
return messages
|
||||
}
|
||||
|
||||
func (b *redisBus) do(ctx context.Context, args ...string) (interface{}, error) {
|
||||
conn, reader, err := b.connect(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if err := writeRedisCommand(conn, args...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return readRedisReply(reader)
|
||||
}
|
||||
|
||||
func (b *redisBus) connect(ctx context.Context) (net.Conn, *bufio.Reader, error) {
|
||||
dialer := &net.Dialer{Timeout: 5 * time.Second}
|
||||
var conn net.Conn
|
||||
var err error
|
||||
if b.useTLS {
|
||||
conn, err = tls.DialWithDialer(dialer, "tcp", b.address, &tls.Config{MinVersion: tls.VersionTLS12})
|
||||
} else {
|
||||
conn, err = dialer.DialContext(ctx, "tcp", b.address)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to connect redis: %w", err)
|
||||
}
|
||||
reader := bufio.NewReader(conn)
|
||||
if b.password != "" {
|
||||
if err := writeRedisCommand(conn, "AUTH", b.password); err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
if _, err := readRedisReply(reader); err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, nil, fmt.Errorf("redis auth failed: %w", err)
|
||||
}
|
||||
}
|
||||
if b.db > 0 {
|
||||
if err := writeRedisCommand(conn, "SELECT", strconv.Itoa(b.db)); err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
if _, err := readRedisReply(reader); err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, nil, fmt.Errorf("redis select db failed: %w", err)
|
||||
}
|
||||
}
|
||||
return conn, reader, nil
|
||||
}
|
||||
|
||||
func writeRedisCommand(conn net.Conn, args ...string) error {
|
||||
var builder strings.Builder
|
||||
builder.WriteString("*")
|
||||
builder.WriteString(strconv.Itoa(len(args)))
|
||||
builder.WriteString("\r\n")
|
||||
for _, arg := range args {
|
||||
builder.WriteString("$")
|
||||
builder.WriteString(strconv.Itoa(len(arg)))
|
||||
builder.WriteString("\r\n")
|
||||
builder.WriteString(arg)
|
||||
builder.WriteString("\r\n")
|
||||
}
|
||||
if _, err := conn.Write([]byte(builder.String())); err != nil {
|
||||
return fmt.Errorf("failed to write redis command: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readRedisReply(reader *bufio.Reader) (interface{}, error) {
|
||||
prefix, err := reader.ReadByte()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch prefix {
|
||||
case '+':
|
||||
line, err := readRedisLine(reader)
|
||||
return line, err
|
||||
case '-':
|
||||
line, _ := readRedisLine(reader)
|
||||
return nil, fmt.Errorf("redis error: %s", line)
|
||||
case ':':
|
||||
line, err := readRedisLine(reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return strconv.ParseInt(line, 10, 64)
|
||||
case '$':
|
||||
line, err := readRedisLine(reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
size, err := strconv.Atoi(line)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if size < 0 {
|
||||
return nil, nil
|
||||
}
|
||||
buf := make([]byte, size+2)
|
||||
if _, err := io.ReadFull(reader, buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return string(buf[:size]), nil
|
||||
case '*':
|
||||
line, err := readRedisLine(reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
count, err := strconv.Atoi(line)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if count < 0 {
|
||||
return nil, nil
|
||||
}
|
||||
items := make([]interface{}, 0, count)
|
||||
for i := 0; i < count; i++ {
|
||||
item, err := readRedisReply(reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected redis reply prefix %q", prefix)
|
||||
}
|
||||
}
|
||||
|
||||
func readRedisLine(reader *bufio.Reader) (string, error) {
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSuffix(strings.TrimSuffix(line, "\n"), "\r"), nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,288 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"clawreef/internal/models"
|
||||
)
|
||||
|
||||
func TestTeamMemberEnvUsesSecretBackedRedisAndToken(t *testing.T) {
|
||||
t.Setenv("CLAWMANAGER_TEAM_MANAGER_BASE_URL", "http://manager.example")
|
||||
|
||||
service := &teamService{}
|
||||
env := service.teamMemberEnv(&models.Team{
|
||||
ID: 12,
|
||||
SharedMountPath: "/team",
|
||||
}, "leader", "lead")
|
||||
|
||||
if env["CLAWMANAGER_TEAM_ID"] != "12" {
|
||||
t.Fatalf("expected Team id env, got %q", env["CLAWMANAGER_TEAM_ID"])
|
||||
}
|
||||
if env["CLAWMANAGER_TEAM_MEMBER_ID"] != "leader" {
|
||||
t.Fatalf("expected member id env, got %q", env["CLAWMANAGER_TEAM_MEMBER_ID"])
|
||||
}
|
||||
if env["CLAWMANAGER_TEAM_ROLE"] != "lead" {
|
||||
t.Fatalf("expected Team role env, got %q", env["CLAWMANAGER_TEAM_ROLE"])
|
||||
}
|
||||
if env["CLAWMANAGER_TEAM_INBOX_KEY"] != "claw:team:12:inbox:leader" {
|
||||
t.Fatalf("unexpected inbox key: %q", env["CLAWMANAGER_TEAM_INBOX_KEY"])
|
||||
}
|
||||
if env["CLAWMANAGER_TEAM_EVENTS_KEY"] != "claw:team:12:events" {
|
||||
t.Fatalf("unexpected events key: %q", env["CLAWMANAGER_TEAM_EVENTS_KEY"])
|
||||
}
|
||||
if env["CLAWMANAGER_TEAM_MANAGER_URL"] != "http://manager.example" {
|
||||
t.Fatalf("unexpected manager url: %q", env["CLAWMANAGER_TEAM_MANAGER_URL"])
|
||||
}
|
||||
if env["CLAWMANAGER_TEAM_CONFIG_PATH"] != "/team/team.json" {
|
||||
t.Fatalf("unexpected Team config path: %q", env["CLAWMANAGER_TEAM_CONFIG_PATH"])
|
||||
}
|
||||
if env["CLAWMANAGER_TEAM_AUTORUN"] != "true" || env["CLAWMANAGER_TEAM_CONSUMER_GROUP"] != "team-members" {
|
||||
t.Fatalf("expected Team autorun and consumer group env, got %#v", env)
|
||||
}
|
||||
for key := range env {
|
||||
if strings.Contains(key, "REDIS_URL") || strings.Contains(key, "TOKEN") {
|
||||
t.Fatalf("sensitive Team env %s must come from Secret, not plain env", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRedisBusParsesURLWithoutNetwork(t *testing.T) {
|
||||
bus, err := newRedisBus("redis://:pass@redis.example:6380/3")
|
||||
if err != nil {
|
||||
t.Fatalf("newRedisBus returned error: %v", err)
|
||||
}
|
||||
if bus.address != "redis.example:6380" || bus.password != "pass" || bus.db != 3 || bus.useTLS {
|
||||
t.Fatalf("unexpected redis bus config: %#v", bus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanTeamMembersRequiresExactlyOneLeader(t *testing.T) {
|
||||
_, err := planTeamMembers("team", []CreateTeamMemberRequest{
|
||||
{MemberID: "worker", Role: "developer"},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "exactly one leader") {
|
||||
t.Fatalf("expected exactly one leader validation error, got %v", err)
|
||||
}
|
||||
|
||||
plans, err := planTeamMembers("team", []CreateTeamMemberRequest{
|
||||
{MemberID: "lead", Role: "team leader"},
|
||||
{MemberID: "worker", Role: "developer"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("planTeamMembers returned error: %v", err)
|
||||
}
|
||||
if len(plans) != 2 || !plans[0].IsLeader || plans[0].Role != "leader" {
|
||||
t.Fatalf("expected first member to be normalized as leader, got %#v", plans)
|
||||
}
|
||||
if plans[1].RuntimeType != "openclaw" {
|
||||
t.Fatalf("expected default runtime type openclaw, got %#v", plans[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanTeamMembersSupportsHermesRuntime(t *testing.T) {
|
||||
plans, err := planTeamMembers("team", []CreateTeamMemberRequest{
|
||||
{MemberID: "lead", Role: "leader"},
|
||||
{MemberID: "hermes-writer", Role: "writer", RuntimeType: "Hermes"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("planTeamMembers returned error: %v", err)
|
||||
}
|
||||
if plans[1].RuntimeType != "hermes" {
|
||||
t.Fatalf("expected Hermes runtime to be normalized, got %#v", plans[1])
|
||||
}
|
||||
|
||||
_, err = planTeamMembers("team", []CreateTeamMemberRequest{
|
||||
{MemberID: "lead", Role: "leader"},
|
||||
{MemberID: "worker", Role: "developer", RuntimeType: "ubuntu"},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "unsupported team member runtime type") {
|
||||
t.Fatalf("expected unsupported runtime validation error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTeamMemberInstanceNameUsesTeamIDAndMemberKey(t *testing.T) {
|
||||
name := teamMemberInstanceName("Software Engineering Team", 42, "code-reviewer")
|
||||
if name != "software-engineering-team-42-code-reviewer" {
|
||||
t.Fatalf("unexpected Team member instance name: %q", name)
|
||||
}
|
||||
|
||||
longName := teamMemberInstanceName("very-long-software-engineering-platform-team", 12345, "extremely-long-code-reviewer-member-key")
|
||||
if len(longName) > 50 {
|
||||
t.Fatalf("expected instance name to stay within 50 chars, got %d: %q", len(longName), longName)
|
||||
}
|
||||
if !strings.Contains(longName, "-12345-") {
|
||||
t.Fatalf("expected instance name to include Team ID, got %q", longName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTeamRosterConfigOmitsSecrets(t *testing.T) {
|
||||
description := "reviews implementation and validates results"
|
||||
plans, err := planTeamMembers("team", []CreateTeamMemberRequest{
|
||||
{MemberID: "leader", Role: "leader"},
|
||||
{MemberID: "worker", Role: "developer", Description: &description},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("planTeamMembers returned error: %v", err)
|
||||
}
|
||||
roster, err := buildTeamRosterConfig(&models.Team{
|
||||
ID: 9,
|
||||
CommunicationMode: "leader_mediated",
|
||||
SharedMountPath: "/team",
|
||||
}, plans)
|
||||
if err != nil {
|
||||
t.Fatalf("buildTeamRosterConfig returned error: %v", err)
|
||||
}
|
||||
for _, forbidden := range []string{"REDIS_URL", "TOKEN", "OPENAI_API_KEY", "secret"} {
|
||||
if strings.Contains(roster, forbidden) {
|
||||
t.Fatalf("roster must not contain sensitive value marker %q: %s", forbidden, roster)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(roster, `"leaderMemberId":"leader"`) || !strings.Contains(roster, `"eventsKey":"claw:team:9:events"`) {
|
||||
t.Fatalf("roster missing expected leader or redis keys: %s", roster)
|
||||
}
|
||||
if !strings.Contains(roster, description) {
|
||||
t.Fatalf("roster missing member description: %s", roster)
|
||||
}
|
||||
if !strings.Contains(roster, `"runtimeType":"openclaw"`) {
|
||||
t.Fatalf("roster missing member runtime type: %s", roster)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildInitialLeaderTaskPayloadDescribesRosterAndTeamSend(t *testing.T) {
|
||||
payload := buildInitialLeaderTaskPayload("Software Engineering Team")
|
||||
|
||||
if payload["intent"] != initialLeaderTaskIntent {
|
||||
t.Fatalf("unexpected bootstrap intent: %#v", payload)
|
||||
}
|
||||
if payload["title"] == "" {
|
||||
t.Fatalf("expected bootstrap task title: %#v", payload)
|
||||
}
|
||||
prompt, ok := payload["prompt"].(string)
|
||||
if !ok {
|
||||
t.Fatalf("expected prompt string: %#v", payload)
|
||||
}
|
||||
for _, expected := range []string{
|
||||
"`team Software Engineering Team`",
|
||||
"Redis Team成员构成",
|
||||
"运行状态与技术能力边界",
|
||||
"协作与通信机制(team_send)",
|
||||
"任务流转方式",
|
||||
"消息同步方式",
|
||||
"上下文共享方式",
|
||||
"可调用的方法、工具与操作能力",
|
||||
} {
|
||||
if !strings.Contains(prompt, expected) {
|
||||
t.Fatalf("bootstrap prompt missing %q: %s", expected, prompt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestActiveTeamMembersFiltersDeletedMembers(t *testing.T) {
|
||||
members := activeTeamMembers([]models.TeamMember{
|
||||
{MemberKey: "leader", Status: models.TeamMemberStatusIdle},
|
||||
{MemberKey: "old", Status: models.TeamMemberStatusDeleted},
|
||||
{MemberKey: "gone", Status: models.TeamMemberStatusDeleting},
|
||||
})
|
||||
if len(members) != 1 || members[0].MemberKey != "leader" {
|
||||
t.Fatalf("unexpected active members: %#v", members)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeletedTeamNameReleasesUniqueName(t *testing.T) {
|
||||
name := deletedTeamName("DeepResearch", 42)
|
||||
if name != "DeepResearch__deleted_42" {
|
||||
t.Fatalf("unexpected deleted Team name: %q", name)
|
||||
}
|
||||
if again := deletedTeamName(name, 42); again != name {
|
||||
t.Fatalf("deleted Team name should be idempotent, got %q", again)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTeamTaskStaleTimeoutUsesEnvironment(t *testing.T) {
|
||||
t.Setenv("CLAWMANAGER_TEAM_TASK_STALE_SECONDS", "60")
|
||||
if got := teamTaskStaleTimeout(); got != time.Minute {
|
||||
t.Fatalf("expected one minute stale timeout, got %s", got)
|
||||
}
|
||||
|
||||
t.Setenv("CLAWMANAGER_TEAM_TASK_STALE_SECONDS", "0")
|
||||
if got := teamTaskStaleTimeout(); got != 0 {
|
||||
t.Fatalf("expected disabled stale timeout, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyTeamMemberRuntimeProjectionSetsBlockedAvailability(t *testing.T) {
|
||||
member := &models.TeamMember{Availability: models.TeamMemberAvailabilityBusy}
|
||||
payload := map[string]interface{}{
|
||||
"availability": "blocked",
|
||||
"lastSummary": "Task failed: LLM request failed: network connection error.",
|
||||
"currentTaskId": "task_cb1062da-dff2-46ff-836f-86490583d944",
|
||||
"currentIntent": "weather_query_beijing",
|
||||
}
|
||||
|
||||
applyTeamMemberRuntimeProjection(member, payload, "status")
|
||||
|
||||
if member.Availability != models.TeamMemberAvailabilityBlocked {
|
||||
t.Fatalf("expected blocked availability, got %q", member.Availability)
|
||||
}
|
||||
if member.LastSummary == nil || !strings.Contains(*member.LastSummary, "LLM request failed") {
|
||||
t.Fatalf("expected last summary projection, got %#v", member.LastSummary)
|
||||
}
|
||||
if member.RuntimeTaskID == nil || *member.RuntimeTaskID != "task_cb1062da-dff2-46ff-836f-86490583d944" {
|
||||
t.Fatalf("expected runtime task id projection, got %#v", member.RuntimeTaskID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyTeamMemberRuntimeProjectionClearsStaleBlockedOnCompletion(t *testing.T) {
|
||||
reason := "previous task failed"
|
||||
member := &models.TeamMember{
|
||||
Availability: models.TeamMemberAvailabilityBlocked,
|
||||
BlockedReason: &reason,
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"lastSummary": "Redis Team task processing completed",
|
||||
"currentTaskId": "task_001",
|
||||
}
|
||||
|
||||
applyTeamMemberRuntimeProjection(member, payload, "task_completed")
|
||||
|
||||
if member.Availability != models.TeamMemberAvailabilityIdle {
|
||||
t.Fatalf("expected idle availability after task completion, got %q", member.Availability)
|
||||
}
|
||||
if member.BlockedReason != nil {
|
||||
t.Fatalf("expected stale blocked reason to be cleared, got %#v", *member.BlockedReason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeMissingEventFieldsEnrichesOutboundPayload(t *testing.T) {
|
||||
base := map[string]interface{}{
|
||||
"event": "outbound",
|
||||
"messageId": "msg_123",
|
||||
"from": "leader",
|
||||
"to": "worker",
|
||||
}
|
||||
extra := map[string]interface{}{
|
||||
"messageId": "msg_123",
|
||||
"title": "Check date",
|
||||
"text": "Check today's date and send the result back.",
|
||||
"metadata": map[string]interface{}{
|
||||
"prompt": "metadata prompt should also be available",
|
||||
},
|
||||
}
|
||||
|
||||
merged := mergeMissingEventFields(base, extra)
|
||||
|
||||
if merged["messageId"] != "msg_123" || merged["from"] != "leader" || merged["to"] != "worker" {
|
||||
t.Fatalf("base fields should be preserved, got %#v", merged)
|
||||
}
|
||||
if merged["title"] != "Check date" || merged["text"] == "" {
|
||||
t.Fatalf("expected outbound payload to be enriched with title/text, got %#v", merged)
|
||||
}
|
||||
if merged["prompt"] != "metadata prompt should also be available" {
|
||||
t.Fatalf("expected metadata prompt to be merged, got %#v", merged)
|
||||
}
|
||||
if !teamEventHasBody(merged) {
|
||||
t.Fatalf("expected enriched event to have displayable body: %#v", merged)
|
||||
}
|
||||
}
|
||||
@@ -52,15 +52,19 @@ func HandleError(c *gin.Context, err error) {
|
||||
Error(c, http.StatusBadRequest, errStr)
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(errStr, "duplicate team member id:") || strings.HasPrefix(errStr, "redis url is invalid:") || strings.HasPrefix(errStr, "redis db index is invalid:") || strings.HasPrefix(errStr, "instance limit reached:") || strings.HasPrefix(errStr, "CPU cores exceed quota:") || strings.HasPrefix(errStr, "memory exceed quota:") || strings.HasPrefix(errStr, "storage exceed quota:") || strings.HasPrefix(errStr, "GPU count exceed quota:") {
|
||||
Error(c, http.StatusBadRequest, errStr)
|
||||
return
|
||||
}
|
||||
|
||||
switch errStr {
|
||||
case "username already exists", "email already exists", "instance name already exists", "openclaw config resource key already exists":
|
||||
case "username already exists", "email already exists", "instance name already exists", "team name already exists", "openclaw config resource key already exists", "team task message id already exists":
|
||||
Error(c, http.StatusConflict, errStr)
|
||||
case "display name already exists":
|
||||
Error(c, http.StatusConflict, errStr)
|
||||
case "unsupported instance type", "image is required", "display name is required", "provider type is required", "base URL is required", "provider model name is required", "input price must be non-negative", "output price must be non-negative", "base URL is invalid", "automatic model discovery for azure-openai is not supported yet", "provider discovery is not supported", "model is required", "messages are required", "streaming is not supported yet", "provider type is not supported yet", "trace id is required", "event type is required", "message is required", "risk hit record is incomplete", "rule id is required", "rule display name is required", "rule pattern is required", "rule pattern is invalid", "risk severity is invalid", "risk action is invalid", "sample text is required", "secret ref format is invalid", "secret namespace is required in secret ref", "invalid openclaw resource type", "invalid openclaw config plan mode", "openclaw config resource name is required", "openclaw config resource key is invalid", "openclaw config schemaVersion is required", "openclaw config kind does not match resource type", "openclaw config format is required", "openclaw config content is required", "openclaw config content must be valid JSON", "openclaw config config payload is required", "openclaw config dependency type is invalid", "openclaw config dependency key is required", "openclaw config dependency is invalid", "openclaw config bundle name is required", "openclaw config bundle must include at least one resource", "openclaw config bundle resource id is required", "openclaw config bundle contains duplicate resources", "openclaw config bundle is required", "openclaw config bundle is disabled", "openclaw config bundle is empty", "at least one openclaw config resource must be selected", "openclaw config resource id is invalid", "openclaw config resource is disabled", "openclaw config bundle contains a disabled resource", "openclaw bootstrap payload is too large", "agent bootstrap token is required", "agent id is required", "unsupported agent protocol version", "invalid agent bootstrap token", "invalid instance command type", "invalid instance command finish status":
|
||||
case "unsupported instance type", "instance name is required", "image is required", "display name is required", "provider type is required", "base URL is required", "provider model name is required", "input price must be non-negative", "output price must be non-negative", "base URL is invalid", "automatic model discovery for azure-openai is not supported yet", "provider discovery is not supported", "model is required", "messages are required", "streaming is not supported yet", "provider type is not supported yet", "trace id is required", "event type is required", "message is required", "risk hit record is incomplete", "rule id is required", "rule display name is required", "rule pattern is required", "rule pattern is invalid", "risk severity is invalid", "risk action is invalid", "sample text is required", "secret ref format is invalid", "secret namespace is required in secret ref", "invalid openclaw resource type", "invalid openclaw config plan mode", "openclaw config resource name is required", "openclaw config resource key is invalid", "openclaw config schemaVersion is required", "openclaw config kind does not match resource type", "openclaw config format is required", "openclaw config content is required", "openclaw config content must be valid JSON", "openclaw config config payload is required", "openclaw config dependency type is invalid", "openclaw config dependency key is required", "openclaw config dependency is invalid", "openclaw config bundle name is required", "openclaw config bundle must include at least one resource", "openclaw config bundle resource id is required", "openclaw config bundle contains duplicate resources", "openclaw config bundle is required", "openclaw config bundle is disabled", "openclaw config bundle is empty", "at least one openclaw config resource must be selected", "openclaw config resource id is invalid", "openclaw config resource is disabled", "openclaw config bundle contains a disabled resource", "openclaw bootstrap payload is too large", "agent bootstrap token is required", "agent id is required", "unsupported agent protocol version", "invalid agent bootstrap token", "invalid instance command type", "invalid instance command finish status", "team name is required", "team must include at least one member", "team must include exactly one leader", "team redis url is required", "redis url scheme must be redis or rediss", "team member id is invalid", "target member id is required", "task payload is required", "team leader cannot be deleted before assigning a new leader":
|
||||
Error(c, http.StatusBadRequest, errStr)
|
||||
case "model is not active or does not exist", "openclaw config resource not found", "openclaw config bundle not found", "openclaw injection snapshot not found", "instance command not found", "instance config revision not found":
|
||||
case "model is not active or does not exist", "openclaw config resource not found", "openclaw config bundle not found", "openclaw injection snapshot not found", "instance command not found", "instance config revision not found", "team not found", "team member not found":
|
||||
Error(c, http.StatusNotFound, errStr)
|
||||
case "risk rule not found":
|
||||
Error(c, http.StatusNotFound, errStr)
|
||||
|
||||
@@ -536,7 +536,7 @@ data:
|
||||
UPDATE system_image_settings
|
||||
SET image = 'ghcr.io/yuan-lab-llm/agentsruntime/hermes:latest'
|
||||
WHERE image = 'ghcr.io/Yuan-lab-LLM/AgentsRuntime/hermes:latest';
|
||||
014_add_instance_runtime_type.sql: |
|
||||
019_add_instance_runtime_type.sql: |
|
||||
USE clawmanager;
|
||||
SET @instance_runtime_type_column_exists = (
|
||||
SELECT COUNT(*)
|
||||
@@ -553,7 +553,7 @@ data:
|
||||
PREPARE instance_runtime_type_column_stmt FROM @instance_runtime_type_column_sql;
|
||||
EXECUTE instance_runtime_type_column_stmt;
|
||||
DEALLOCATE PREPARE instance_runtime_type_column_stmt;
|
||||
015_add_system_image_runtime_type.sql: |
|
||||
020_add_system_image_runtime_type.sql: |
|
||||
USE clawmanager;
|
||||
SET @system_image_runtime_type_column_exists = (
|
||||
SELECT COUNT(*)
|
||||
|
||||
@@ -540,7 +540,7 @@ data:
|
||||
UPDATE system_image_settings
|
||||
SET image = 'ghcr.io/yuan-lab-llm/agentsruntime/hermes:latest'
|
||||
WHERE image = 'ghcr.io/Yuan-lab-LLM/AgentsRuntime/hermes:latest';
|
||||
014_add_instance_runtime_type.sql: |
|
||||
019_add_instance_runtime_type.sql: |
|
||||
USE clawmanager;
|
||||
SET @instance_runtime_type_column_exists = (
|
||||
SELECT COUNT(*)
|
||||
@@ -557,7 +557,7 @@ data:
|
||||
PREPARE instance_runtime_type_column_stmt FROM @instance_runtime_type_column_sql;
|
||||
EXECUTE instance_runtime_type_column_stmt;
|
||||
DEALLOCATE PREPARE instance_runtime_type_column_stmt;
|
||||
015_add_system_image_runtime_type.sql: |
|
||||
020_add_system_image_runtime_type.sql: |
|
||||
USE clawmanager;
|
||||
SET @system_image_runtime_type_column_exists = (
|
||||
SELECT COUNT(*)
|
||||
@@ -771,6 +771,57 @@ spec:
|
||||
port: 9001
|
||||
targetPort: console
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: clawmanager-team-redis
|
||||
namespace: clawmanager-system
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: clawmanager-team-redis
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: clawmanager-team-redis
|
||||
spec:
|
||||
containers:
|
||||
- name: redis
|
||||
image: redis:7-alpine
|
||||
imagePullPolicy: IfNotPresent
|
||||
args:
|
||||
- redis-server
|
||||
- --appendonly
|
||||
- "yes"
|
||||
ports:
|
||||
- name: redis
|
||||
containerPort: 6379
|
||||
readinessProbe:
|
||||
tcpSocket:
|
||||
port: redis
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
volumeMounts:
|
||||
- name: redis-data
|
||||
mountPath: /data
|
||||
volumes:
|
||||
- name: redis-data
|
||||
emptyDir: {}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: clawmanager-team-redis
|
||||
namespace: clawmanager-system
|
||||
spec:
|
||||
selector:
|
||||
app: clawmanager-team-redis
|
||||
ports:
|
||||
- name: redis
|
||||
port: 6379
|
||||
targetPort: redis
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
@@ -923,6 +974,10 @@ spec:
|
||||
value: "manual"
|
||||
- name: K8S_PV_HOST_PATH_PREFIX
|
||||
value: "/data/clawreef"
|
||||
- name: CLAWMANAGER_TEAM_REDIS_URL
|
||||
value: "redis://clawmanager-team-redis.clawmanager-system.svc.cluster.local:6379/0"
|
||||
- name: CLAWMANAGER_TEAM_MANAGER_BASE_URL
|
||||
value: "http://clawmanager-gateway.clawmanager-system.svc.cluster.local:9001"
|
||||
- name: SKILL_SCANNER_ENABLED
|
||||
value: "true"
|
||||
- name: SKILL_SCANNER_BASE_URL
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 287 KiB After Width: | Height: | Size: 84 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 670 KiB |
@@ -10,6 +10,8 @@ interface InstanceAccessProps {
|
||||
instanceName: string;
|
||||
isRunning: boolean;
|
||||
runtimeType?: "desktop" | "shell";
|
||||
frameHeightClassName?: string;
|
||||
containerClassName?: string;
|
||||
overlay?: {
|
||||
gatewayStatus: string;
|
||||
canControl: boolean;
|
||||
@@ -57,6 +59,7 @@ export function InstanceAccess(props: InstanceAccessProps) {
|
||||
instanceId={props.instanceId}
|
||||
instanceName={props.instanceName}
|
||||
isRunning={props.isRunning}
|
||||
className={props.containerClassName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -68,6 +71,8 @@ function DesktopInstanceAccess({
|
||||
instanceId,
|
||||
instanceName,
|
||||
isRunning,
|
||||
frameHeightClassName,
|
||||
containerClassName,
|
||||
overlay,
|
||||
}: InstanceAccessProps) {
|
||||
const { t } = useI18n();
|
||||
@@ -156,7 +161,8 @@ function DesktopInstanceAccess({
|
||||
|
||||
const frameHeightClass = isFullscreen
|
||||
? "min-h-0 flex-1"
|
||||
: "h-[54vh] min-h-[420px] max-h-[720px] md:h-[58vh] xl:h-[60vh]";
|
||||
: frameHeightClassName ||
|
||||
"h-[54vh] min-h-[420px] max-h-[720px] md:h-[58vh] xl:h-[60vh]";
|
||||
const showStartScreen = !embedUrl;
|
||||
const hasDesktopSession =
|
||||
shouldConnect || Boolean(embedUrl) || loading || reconnecting;
|
||||
@@ -200,7 +206,7 @@ function DesktopInstanceAccess({
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative overflow-hidden rounded-[28px] border border-[#1f2937] bg-[radial-gradient(circle_at_top,rgba(59,130,246,0.2),transparent_28%),linear-gradient(180deg,#111827_0%,#0f172a_100%)] shadow-[0_30px_90px_-56px_rgba(17,24,39,0.9)]"
|
||||
className={`relative overflow-hidden rounded-[28px] border border-[#1f2937] bg-[radial-gradient(circle_at_top,rgba(59,130,246,0.2),transparent_28%),linear-gradient(180deg,#111827_0%,#0f172a_100%)] shadow-[0_30px_90px_-56px_rgba(17,24,39,0.9)] ${containerClassName || ""}`}
|
||||
>
|
||||
{overlay ? (
|
||||
<OpenClawDesktopOverlay
|
||||
@@ -258,7 +264,7 @@ function DesktopInstanceAccess({
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`relative overflow-hidden bg-[#111827] ${isFullscreen ? "flex h-screen flex-col rounded-none" : "rounded-[28px] border border-[#1f2937] shadow-[0_30px_90px_-56px_rgba(17,24,39,0.9)]"}`}
|
||||
className={`relative overflow-hidden bg-[#111827] ${isFullscreen ? "flex h-screen flex-col rounded-none" : "rounded-[28px] border border-[#1f2937] shadow-[0_30px_90px_-56px_rgba(17,24,39,0.9)]"} ${containerClassName || ""}`}
|
||||
>
|
||||
{overlay ? (
|
||||
<OpenClawDesktopOverlay
|
||||
|
||||
@@ -39,6 +39,7 @@ const UserLayout: React.FC<UserLayoutProps> = ({ children, title }) => {
|
||||
const navItems: UserNavItem[] = [
|
||||
{ path: '/dashboard', label: t('nav.userDashboard'), icon: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6', exact: true },
|
||||
{ path: '/instances', label: t('nav.myInstances'), icon: 'M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01' },
|
||||
{ path: '/teams', label: 'Teams', icon: 'M17 20h5v-2a4 4 0 00-4-4h-1M9 20H4v-2a4 4 0 014-4h1m8-4a4 4 0 10-8 0 4 4 0 008 0z' },
|
||||
{ path: '/openclaw-configs', label: t('nav.openClawConfigs'), icon: 'M4 6h16M4 12h16M4 18h9' },
|
||||
{ path: '/settings', label: t('nav.settings'), icon: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z' },
|
||||
];
|
||||
|
||||
@@ -165,6 +165,32 @@ export function useInstanceDesktopAccess({
|
||||
setReconnecting(false);
|
||||
}, [clearRefreshTimeout, clearRetryTimeout, instanceId]);
|
||||
|
||||
useEffect(() => {
|
||||
clearRetryTimeout();
|
||||
clearRefreshTimeout();
|
||||
requestIdRef.current += 1;
|
||||
|
||||
const cachedSession = instanceId
|
||||
? desktopSessionStore.get(instanceId)
|
||||
: null;
|
||||
const nextEmbedUrl = cachedSession?.embedUrl ?? null;
|
||||
const nextExpiresAt = cachedSession?.expiresAt
|
||||
? new Date(cachedSession.expiresAt)
|
||||
: null;
|
||||
|
||||
embedUrlRef.current = nextEmbedUrl;
|
||||
expiresAtRef.current = nextExpiresAt;
|
||||
hasEstablishedSessionRef.current =
|
||||
cachedSession?.hasEstablishedSession ?? false;
|
||||
retryAttemptRef.current = 0;
|
||||
|
||||
setEmbedUrl(nextEmbedUrl);
|
||||
setExpiresAt(nextExpiresAt);
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
setReconnecting(false);
|
||||
}, [clearRefreshTimeout, clearRetryTimeout, instanceId]);
|
||||
|
||||
const shouldPreserveSession = useCallback(() => {
|
||||
return (
|
||||
retainSessionOnStop &&
|
||||
|
||||
@@ -53,6 +53,32 @@ const mergeInstances = (current: Instance[], incoming: Instance[]) => {
|
||||
});
|
||||
};
|
||||
|
||||
const INSTANCE_LIST_PAGE_SIZE = 100;
|
||||
|
||||
const instanceTimeValue = (instance: Instance) => {
|
||||
const value = Date.parse(instance.created_at || instance.updated_at || '');
|
||||
return Number.isFinite(value) ? value : 0;
|
||||
};
|
||||
|
||||
const sortInstances = (items: Instance[]) =>
|
||||
[...items].sort(
|
||||
(left, right) => instanceTimeValue(right) - instanceTimeValue(left) || right.id - left.id,
|
||||
);
|
||||
|
||||
const loadAllInstances = async () => {
|
||||
const firstPage = await instanceService.getInstances(1, INSTANCE_LIST_PAGE_SIZE);
|
||||
const instances = [...(firstPage.instances || [])];
|
||||
const total = firstPage.total || instances.length;
|
||||
const totalPages = Math.ceil(total / INSTANCE_LIST_PAGE_SIZE);
|
||||
|
||||
for (let page = 2; page <= totalPages; page += 1) {
|
||||
const nextPage = await instanceService.getInstances(page, INSTANCE_LIST_PAGE_SIZE);
|
||||
instances.push(...(nextPage.instances || []));
|
||||
}
|
||||
|
||||
return sortInstances(instances);
|
||||
};
|
||||
|
||||
interface InstanceItemProps {
|
||||
instance: Instance;
|
||||
actionLoading: number | null;
|
||||
@@ -253,8 +279,8 @@ const InstanceListPage: React.FC = () => {
|
||||
setLoading(true);
|
||||
}
|
||||
setError(null);
|
||||
const data = await instanceService.getInstances();
|
||||
setInstances((prevInstances) => mergeInstances(prevInstances, data.instances));
|
||||
const data = await loadAllInstances();
|
||||
setInstances((prevInstances) => mergeInstances(prevInstances, data));
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || t('instances.failedToLoad'));
|
||||
} finally {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,202 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import UserLayout from "../../components/UserLayout";
|
||||
import { teamService } from "../../services/teamService";
|
||||
import type { Team } from "../../types/team";
|
||||
|
||||
const statusStyle = (status: string) => {
|
||||
switch (status) {
|
||||
case "running":
|
||||
return "border-green-200 bg-green-50 text-green-700";
|
||||
case "creating":
|
||||
case "deleting":
|
||||
return "border-yellow-200 bg-yellow-50 text-yellow-700";
|
||||
case "failed":
|
||||
case "deleted":
|
||||
return "border-red-200 bg-red-50 text-red-700";
|
||||
default:
|
||||
return "border-gray-200 bg-gray-50 text-gray-700";
|
||||
}
|
||||
};
|
||||
|
||||
const formatDateTime = (value: string) => {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
return date.toLocaleString();
|
||||
};
|
||||
|
||||
const TeamListPage: React.FC = () => {
|
||||
const [teams, setTeams] = useState<Team[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
const loadTeams = useCallback(async (options?: { silent?: boolean }) => {
|
||||
try {
|
||||
if (!options?.silent) {
|
||||
setLoading(true);
|
||||
}
|
||||
setError(null);
|
||||
const data = await teamService.listTeams(1, 100);
|
||||
setTeams(data.teams || []);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || "加载 Team 失败");
|
||||
} finally {
|
||||
if (!options?.silent) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadTeams();
|
||||
}, [loadTeams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!teams.some((team) => team.status === "creating")) {
|
||||
return;
|
||||
}
|
||||
const timer = window.setInterval(() => {
|
||||
void loadTeams({ silent: true });
|
||||
}, 5000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [loadTeams, teams]);
|
||||
|
||||
const filteredTeams = useMemo(() => {
|
||||
const query = searchQuery.trim().toLowerCase();
|
||||
if (!query) {
|
||||
return teams;
|
||||
}
|
||||
return teams.filter((team) =>
|
||||
[team.name, team.status, team.communication_mode]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(query),
|
||||
);
|
||||
}, [searchQuery, teams]);
|
||||
|
||||
return (
|
||||
<UserLayout title="Teams">
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="text-sm text-gray-600">{teams.length} Teams</div>
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<div className="relative">
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<svg
|
||||
className="h-5 w-5 text-gray-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
value={searchQuery}
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
placeholder="搜索 Team"
|
||||
className="block w-full rounded-xl border border-[#eadfd8] bg-white py-2 pl-10 pr-3 text-sm placeholder-[#9c938e] focus:border-[#ef4444] focus:outline-none focus:ring-1 focus:ring-[#f3d2c2] sm:w-64"
|
||||
/>
|
||||
</div>
|
||||
<Link to="/teams/new" className="app-button-primary">
|
||||
<svg
|
||||
className="mr-2 h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
创建 Team
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex h-64 items-center justify-center text-lg text-gray-600">
|
||||
正在加载...
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
) : filteredTeams.length === 0 ? (
|
||||
<div className="app-panel border-dashed p-12 text-center">
|
||||
<svg
|
||||
className="mx-auto h-12 w-12 text-gray-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17 20h5v-2a4 4 0 00-4-4h-1M9 20H4v-2a4 4 0 014-4h1m8-4a4 4 0 10-8 0 4 4 0 008 0z"
|
||||
/>
|
||||
</svg>
|
||||
<h3 className="mt-3 text-sm font-medium text-gray-900">
|
||||
还没有 Team
|
||||
</h3>
|
||||
<div className="mt-6">
|
||||
<Link to="/teams/new" className="app-button-primary">
|
||||
创建 Team
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="app-panel overflow-hidden">
|
||||
<ul className="divide-y divide-[#f1e7e1]">
|
||||
{filteredTeams.map((team) => (
|
||||
<li key={team.id} className="px-5 py-5 hover:bg-[#fffaf6]">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Link
|
||||
to={`/teams/${team.id}`}
|
||||
className="truncate text-lg font-semibold text-[#dc2626]"
|
||||
>
|
||||
{team.name}
|
||||
</Link>
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium ${statusStyle(team.status)}`}
|
||||
>
|
||||
{team.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-x-5 gap-y-1 text-sm text-gray-500">
|
||||
<span>#{team.id}</span>
|
||||
<span>{team.communication_mode}</span>
|
||||
<span>{team.shared_pvc_name || "PVC 待创建"}</span>
|
||||
<span>{formatDateTime(team.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
to={`/teams/${team.id}`}
|
||||
className="inline-flex items-center justify-center rounded-xl border border-[#eadfd8] bg-white px-4 py-2 text-sm font-medium text-[#5f5957] hover:bg-[#fff8f5]"
|
||||
>
|
||||
查看详情
|
||||
</Link>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</UserLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default TeamListPage;
|
||||
@@ -31,6 +31,9 @@ import InstanceListPage from '../pages/instances/InstanceListPage';
|
||||
import CreateInstancePage from '../pages/instances/CreateInstancePage';
|
||||
import InstanceDetailPage from '../pages/instances/InstanceDetailPage';
|
||||
import InstancePortalPage from '../pages/instances/InstancePortalPage';
|
||||
import TeamListPage from '../pages/teams/TeamListPage';
|
||||
import CreateTeamPage from '../pages/teams/CreateTeamPage';
|
||||
import TeamDetailPage from '../pages/teams/TeamDetailPage';
|
||||
|
||||
// Protected Route Component
|
||||
const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
@@ -183,6 +186,30 @@ function AppRoutes() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/teams"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<TeamListPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/teams/new"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<CreateTeamPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/teams/:id"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<TeamDetailPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/openclaw-configs"
|
||||
element={
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import api from "./api";
|
||||
import type {
|
||||
CreateTeamRequest,
|
||||
DispatchTeamTaskRequest,
|
||||
TeamDetails,
|
||||
TeamListResponse,
|
||||
TeamTask,
|
||||
} from "../types/team";
|
||||
|
||||
export const teamService = {
|
||||
listTeams: async (
|
||||
page: number = 1,
|
||||
limit: number = 20,
|
||||
): Promise<TeamListResponse> => {
|
||||
const response = await api.get("/teams", {
|
||||
params: { page, limit },
|
||||
});
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
createTeam: async (data: CreateTeamRequest): Promise<TeamDetails> => {
|
||||
const response = await api.post("/teams", data);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
getTeam: async (id: number): Promise<TeamDetails> => {
|
||||
const response = await api.get(`/teams/${id}`);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
dispatchTask: async (
|
||||
id: number,
|
||||
data: DispatchTeamTaskRequest,
|
||||
): Promise<TeamTask> => {
|
||||
const response = await api.post(`/teams/${id}/tasks`, data);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
deleteTeam: async (id: number): Promise<void> => {
|
||||
await api.delete(`/teams/${id}`);
|
||||
},
|
||||
|
||||
deleteMember: async (teamId: number, memberId: number | string): Promise<void> => {
|
||||
await api.delete(`/teams/${teamId}/members/${memberId}`);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { OpenClawConfigPlan } from "./openclawConfig";
|
||||
|
||||
export interface Team {
|
||||
id: number;
|
||||
user_id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
status: "creating" | "running" | "failed" | "deleting" | "deleted";
|
||||
communication_mode: string;
|
||||
redis_events_last_id: string;
|
||||
shared_pvc_name?: string;
|
||||
shared_pvc_namespace?: string;
|
||||
shared_mount_path: string;
|
||||
storage_class?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface TeamMember {
|
||||
id: number;
|
||||
team_id: number;
|
||||
instance_id?: number;
|
||||
user_id: number;
|
||||
member_key: string;
|
||||
display_name: string;
|
||||
role: string;
|
||||
runtime_type?: "openclaw" | "hermes";
|
||||
description?: string;
|
||||
status:
|
||||
| "creating"
|
||||
| "idle"
|
||||
| "busy"
|
||||
| "failed"
|
||||
| "offline"
|
||||
| "deleting"
|
||||
| "deleted";
|
||||
current_task_id?: number;
|
||||
progress: number;
|
||||
last_seen_at?: string;
|
||||
availability?: "unknown" | "idle" | "busy" | "blocked" | "offline";
|
||||
runtime_status?: string;
|
||||
runtime_task_id?: string;
|
||||
runtime_intent?: string;
|
||||
blocked_reason?: string;
|
||||
last_summary?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface TeamTask {
|
||||
id: number;
|
||||
team_id: number;
|
||||
target_member_id: number;
|
||||
created_by?: number;
|
||||
message_id: string;
|
||||
status:
|
||||
| "pending"
|
||||
| "dispatched"
|
||||
| "running"
|
||||
| "succeeded"
|
||||
| "failed"
|
||||
| "stale";
|
||||
redis_stream_id?: string;
|
||||
error_message?: string;
|
||||
created_at: string;
|
||||
dispatched_at?: string;
|
||||
started_at?: string;
|
||||
finished_at?: string;
|
||||
updated_at: string;
|
||||
payload?: Record<string, unknown>;
|
||||
result?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface TeamEvent {
|
||||
id: number;
|
||||
team_id: number;
|
||||
member_id?: number;
|
||||
task_id?: number;
|
||||
message_id?: string;
|
||||
event_type: string;
|
||||
redis_stream_id?: string;
|
||||
occurred_at?: string;
|
||||
created_at: string;
|
||||
payload?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface CreateTeamMemberRequest {
|
||||
member_id?: string;
|
||||
name?: string;
|
||||
role: string;
|
||||
runtime_type?: "openclaw" | "hermes";
|
||||
description?: string;
|
||||
cpu_cores?: number;
|
||||
memory_gb?: number;
|
||||
disk_gb?: number;
|
||||
gpu_enabled?: boolean;
|
||||
gpu_count?: number;
|
||||
image_registry?: string;
|
||||
image_tag?: string;
|
||||
environment_overrides?: Record<string, string>;
|
||||
openclaw_config_plan?: OpenClawConfigPlan;
|
||||
is_leader?: boolean;
|
||||
}
|
||||
|
||||
export interface CreateTeamRequest {
|
||||
name: string;
|
||||
description?: string;
|
||||
communication_mode?: string;
|
||||
shared_storage_gb?: number;
|
||||
storage_class?: string;
|
||||
members: CreateTeamMemberRequest[];
|
||||
}
|
||||
|
||||
export interface TeamDetails {
|
||||
team: Team;
|
||||
leader_member_id?: string;
|
||||
leader?: TeamMember;
|
||||
members: TeamMember[];
|
||||
tasks?: TeamTask[];
|
||||
events?: TeamEvent[];
|
||||
}
|
||||
|
||||
export interface TeamListResponse {
|
||||
teams: Team[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface DispatchTeamTaskRequest {
|
||||
target_member_id: string;
|
||||
message_id?: string;
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
Reference in New Issue
Block a user