Produktueberblick |
+ Team Workspaces |
AI Gateway |
Agent Control Plane |
Runtime-Integrationen |
@@ -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
+
+## 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
+
## 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.
+
+
+
+
+
### 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
diff --git a/README.ja.md b/README.ja.md
index 63133f5..3bd16ca 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -27,6 +27,7 @@
製品紹介 |
+ Team ワークスペース |
AI Gateway |
Agent Control Plane |
Runtime 連携 |
@@ -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 ワークスペースを届けたい開発チーム
+
+## 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 はメッセージバスとして扱う設計
+
## Runtime 連携
@@ -161,6 +177,14 @@ Agent Control Plane は、管理対象 AI エージェントインスタンス
ClawManager は、管理、アクセス、AI ガバナンスを別々のツールとして扱うのではなく、ひとつの製品体験としてまとめるよう設計されています。
+### Team ワークスペース
+
+Team ワークスペース画面は、Leader デスクトップ、Team チャット、メンバー表、配布ワークフローを 1 つの運用ビューにまとめ、ClawManager から離れずに協調作業の進捗を追えるようにします。
+
+
+
+
+
### 管理コンソール
管理コンソールでは、ユーザー、クォータ、ランタイム操作、セキュリティ制御、プラットフォームレベルのポリシーをひとつの画面に集約します。大規模な 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 を経由し、監査、リスク、コスト制御が付与されます。
## 開発者向け概要
diff --git a/README.ko.md b/README.ko.md
index 32c1d8a..99ca8e6 100644
--- a/README.ko.md
+++ b/README.ko.md
@@ -27,6 +27,7 @@
제품 소개 |
+ Team 워크스페이스 |
AI Gateway |
Agent Control Plane |
Runtime 연동 |
@@ -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 워크스페이스를 제공하고 싶은 개발 팀
+
+## 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는 메시지 버스로 사용하는 설계
+
## Runtime 연동
@@ -161,6 +177,14 @@ Agent Control Plane은 관리되는 AI Agent 인스턴스를 위한 런타임
ClawManager는 관리, 접근, AI 거버넌스를 서로 분리된 도구로 다루지 않고, 하나의 일관된 제품 경험으로 묶도록 설계되었습니다.
+### Team 워크스페이스
+
+Team 워크스페이스 화면은 Leader 데스크톱, Team 채팅, 멤버 테이블, 배포 워크플로우를 하나의 운영 화면에 모아 ClawManager 안에서 협업 진행 상황을 따라갈 수 있게 합니다.
+
+
+
+
+
### 관리 콘솔
관리 콘솔은 사용자, 쿼터, 런타임 작업, 보안 제어, 플랫폼 수준 정책을 하나의 화면으로 묶습니다. 대규모 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를 통해 전달되며, 감사, 리스크, 비용 제어가 함께 적용됩니다.
## 개발자 개요
diff --git a/README.md b/README.md
index c12be09..89a707a 100644
--- a/README.md
+++ b/README.md
@@ -27,6 +27,7 @@
Explore the Product |
+ Team Workspaces |
AI Gateway |
Agent Control Plane |
Runtime Integrations |
@@ -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
+
+## 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
+
## 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.
+
+
+
+
+
### 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
diff --git a/README.zh-CN.md b/README.zh-CN.md
index d9ee873..f997626 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -27,6 +27,7 @@
了解产品 |
+ Team 协作 |
AI Gateway |
Agent Control Plane |
Runtime 接入 |
@@ -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 工作空间的开发团队
+
+## 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 通道
+
## Runtime 接入
@@ -161,6 +177,14 @@ Agent Control Plane 是受管 AI Agent 实例的运行时编排层。它让每
ClawManager 的设计目标,是让管理、访问与 AI 治理体验形成统一的产品界面,而不是分散在多个孤立工具中。
+### Team 工作空间
+
+Team 工作空间页面把 Leader 桌面、团队群聊、成员表格和调试派发流程集中在同一个操作视图中,用户可以直接观察协作进度、成员反馈和任务结果。
+
+
+
+
+
### 管理控制台
管理控制台将用户、配额、运行时操作、安全控制与平台级策略集中到一起,是团队管理 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 进入上游服务,并附带审计、风险与成本控制。
## 开发者概览
diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go
index 76f75df..e101127 100644
--- a/backend/cmd/server/main.go
+++ b/backend/cmd/server/main.go
@@ -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()
diff --git a/backend/deployments/k8s/clawreef-incluster.yaml b/backend/deployments/k8s/clawreef-incluster.yaml
index 273d250..88e3c46 100644
--- a/backend/deployments/k8s/clawreef-incluster.yaml
+++ b/backend/deployments/k8s/clawreef-incluster.yaml
@@ -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(*)
diff --git a/backend/internal/db/migrations/014_add_team_mvp.sql b/backend/internal/db/migrations/014_add_team_mvp.sql
new file mode 100644
index 0000000..6bcd530
--- /dev/null
+++ b/backend/internal/db/migrations/014_add_team_mvp.sql
@@ -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;
diff --git a/backend/internal/db/migrations/015_add_team_member_description.sql b/backend/internal/db/migrations/015_add_team_member_description.sql
new file mode 100644
index 0000000..fe941bd
--- /dev/null
+++ b/backend/internal/db/migrations/015_add_team_member_description.sql
@@ -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;
diff --git a/backend/internal/db/migrations/016_release_deleted_team_names.sql b/backend/internal/db/migrations/016_release_deleted_team_names.sql
new file mode 100644
index 0000000..925ef20
--- /dev/null
+++ b/backend/internal/db/migrations/016_release_deleted_team_names.sql
@@ -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]+$';
diff --git a/backend/internal/db/migrations/017_add_team_member_runtime_state.sql b/backend/internal/db/migrations/017_add_team_member_runtime_state.sql
new file mode 100644
index 0000000..d05cf25
--- /dev/null
+++ b/backend/internal/db/migrations/017_add_team_member_runtime_state.sql
@@ -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;
diff --git a/backend/internal/db/migrations/018_add_team_member_runtime_type.sql b/backend/internal/db/migrations/018_add_team_member_runtime_type.sql
new file mode 100644
index 0000000..8ed59e0
--- /dev/null
+++ b/backend/internal/db/migrations/018_add_team_member_runtime_type.sql
@@ -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;
diff --git a/backend/internal/db/migrations/014_add_instance_runtime_type.sql b/backend/internal/db/migrations/019_add_instance_runtime_type.sql
similarity index 100%
rename from backend/internal/db/migrations/014_add_instance_runtime_type.sql
rename to backend/internal/db/migrations/019_add_instance_runtime_type.sql
diff --git a/backend/internal/db/migrations/015_add_system_image_runtime_type.sql b/backend/internal/db/migrations/020_add_system_image_runtime_type.sql
similarity index 100%
rename from backend/internal/db/migrations/015_add_system_image_runtime_type.sql
rename to backend/internal/db/migrations/020_add_system_image_runtime_type.sql
diff --git a/backend/internal/handlers/team_handler.go b/backend/internal/handlers/team_handler.go
new file mode 100644
index 0000000..e4a12a0
--- /dev/null
+++ b/backend/internal/handlers/team_handler.go
@@ -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
+}
diff --git a/backend/internal/models/team.go b/backend/internal/models/team.go
new file mode 100644
index 0000000..7f5fc8d
--- /dev/null
+++ b/backend/internal/models/team.go
@@ -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"
+}
diff --git a/backend/internal/repository/instance_repository.go b/backend/internal/repository/instance_repository.go
index 12bc667..1102afd 100644
--- a/backend/internal/repository/instance_repository.go
+++ b/backend/internal/repository/instance_repository.go
@@ -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)
}
diff --git a/backend/internal/repository/team_repository.go b/backend/internal/repository/team_repository.go
new file mode 100644
index 0000000..b536ca5
--- /dev/null
+++ b/backend/internal/repository/team_repository.go
@@ -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
+}
diff --git a/backend/internal/services/instance_service.go b/backend/internal/services/instance_service.go
index bc730e5..d791918 100644
--- a/backend/internal/services/instance_service.go
+++ b/backend/internal/services/instance_service.go
@@ -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)
diff --git a/backend/internal/services/k8s/client.go b/backend/internal/services/k8s/client.go
index 2b6b47f..4ae60b9 100644
--- a/backend/internal/services/k8s/client.go
+++ b/backend/internal/services/k8s/client.go
@@ -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))
diff --git a/backend/internal/services/k8s/configmap_service.go b/backend/internal/services/k8s/configmap_service.go
new file mode 100644
index 0000000..28c71bb
--- /dev/null
+++ b/backend/internal/services/k8s/configmap_service.go
@@ -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
+}
diff --git a/backend/internal/services/k8s/pod_service.go b/backend/internal/services/k8s/pod_service.go
index 9630f55..0da1259 100644
--- a/backend/internal/services/k8s/pod_service.go
+++ b/backend/internal/services/k8s/pod_service.go
@@ -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{
diff --git a/backend/internal/services/k8s/pod_service_test.go b/backend/internal/services/k8s/pod_service_test.go
index 9eb4bb0..a802f4d 100644
--- a/backend/internal/services/k8s/pod_service_test.go
+++ b/backend/internal/services/k8s/pod_service_test.go
@@ -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)
+ }
+}
diff --git a/backend/internal/services/k8s/pvc_service.go b/backend/internal/services/k8s/pvc_service.go
index a3a9cd2..7df7f46 100644
--- a/backend/internal/services/k8s/pvc_service.go
+++ b/backend/internal/services/k8s/pvc_service.go
@@ -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)
diff --git a/backend/internal/services/k8s/secret_service.go b/backend/internal/services/k8s/secret_service.go
index 8a943b7..8a93c33 100644
--- a/backend/internal/services/k8s/secret_service.go
+++ b/backend/internal/services/k8s/secret_service.go
@@ -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
+}
diff --git a/backend/internal/services/team_redis.go b/backend/internal/services/team_redis.go
new file mode 100644
index 0000000..4e426eb
--- /dev/null
+++ b/backend/internal/services/team_redis.go
@@ -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
+}
diff --git a/backend/internal/services/team_service.go b/backend/internal/services/team_service.go
new file mode 100644
index 0000000..906c167
--- /dev/null
+++ b/backend/internal/services/team_service.go
@@ -0,0 +1,1798 @@
+package services
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "os"
+ "regexp"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "clawreef/internal/models"
+ "clawreef/internal/repository"
+ "clawreef/internal/services/k8s"
+)
+
+const (
+ teamSharedMountPath = "/team"
+ teamConfigMountPath = "/team/team.json"
+ teamConfigFileName = "team.json"
+ teamRedisURLSecretKey = "CLAWMANAGER_TEAM_REDIS_URL"
+ teamTokenSecretKey = "CLAWMANAGER_TEAM_TOKEN"
+
+ defaultTeamTaskStaleTimeout = 30 * time.Minute
+ teamTaskStaleSweepInterval = 30 * time.Second
+
+ initialLeaderTaskIntent = "team_bootstrap_introduction"
+)
+
+var (
+ teamMemberKeyPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`)
+ teamMemberInstanceNameInvalidChars = regexp.MustCompile(`[^a-z0-9-]+`)
+ teamMemberInstanceNameRepeatedDashs = regexp.MustCompile(`-+`)
+)
+
+type TeamService interface {
+ Start()
+ Stop()
+ CreateTeam(userID int, req CreateTeamRequest) (*TeamDetailsPayload, error)
+ ListTeams(userID, offset, limit int) (*TeamListPayload, error)
+ GetTeam(userID, teamID int) (*TeamDetailsPayload, error)
+ DispatchTask(userID, teamID int, req DispatchTeamTaskRequest) (*TeamTaskPayload, error)
+ DeleteTeam(userID, teamID int) error
+ DeleteMember(userID, teamID int, memberID string) error
+}
+
+type CreateTeamRequest struct {
+ Name string `json:"name"`
+ Description *string `json:"description,omitempty"`
+ CommunicationMode string `json:"communication_mode,omitempty"`
+ RedisURL string `json:"redis_url,omitempty"`
+ SharedStorageGB int `json:"shared_storage_gb,omitempty"`
+ StorageClass string `json:"storage_class,omitempty"`
+ Members []CreateTeamMemberRequest `json:"members"`
+}
+
+type CreateTeamMemberRequest struct {
+ MemberID string `json:"member_id,omitempty"`
+ Name string `json:"name,omitempty"`
+ Role string `json:"role"`
+ RuntimeType string `json:"runtime_type,omitempty"`
+ Description *string `json:"description,omitempty"`
+ CPUCores float64 `json:"cpu_cores,omitempty"`
+ MemoryGB int `json:"memory_gb,omitempty"`
+ DiskGB int `json:"disk_gb,omitempty"`
+ GPUEnabled bool `json:"gpu_enabled,omitempty"`
+ GPUCount int `json:"gpu_count,omitempty"`
+ ImageRegistry *string `json:"image_registry,omitempty"`
+ ImageTag *string `json:"image_tag,omitempty"`
+ EnvironmentOverrides map[string]string `json:"environment_overrides,omitempty"`
+ OpenClawConfigPlan *OpenClawConfigPlan `json:"openclaw_config_plan,omitempty"`
+ IsLeader bool `json:"is_leader,omitempty"`
+}
+
+type DispatchTeamTaskRequest struct {
+ TargetMemberID string `json:"target_member_id"`
+ MessageID string `json:"message_id,omitempty"`
+ Payload map[string]interface{} `json:"payload"`
+}
+
+type TeamListPayload struct {
+ Teams []models.Team `json:"teams"`
+ Total int `json:"total"`
+}
+
+type TeamDetailsPayload struct {
+ Team *models.Team `json:"team"`
+ LeaderMemberID string `json:"leader_member_id,omitempty"`
+ Leader *models.TeamMember `json:"leader,omitempty"`
+ Members []models.TeamMember `json:"members"`
+ Tasks []TeamTaskPayload `json:"tasks,omitempty"`
+ Events []TeamEventPayload `json:"events,omitempty"`
+}
+
+type TeamTaskPayload struct {
+ models.TeamTask
+ Payload map[string]interface{} `json:"payload,omitempty"`
+ Result map[string]interface{} `json:"result,omitempty"`
+}
+
+type TeamEventPayload struct {
+ models.TeamEvent
+ Payload map[string]interface{} `json:"payload,omitempty"`
+}
+
+type teamService struct {
+ repo repository.TeamRepository
+ instanceService InstanceService
+ pvcService *k8s.PVCService
+ secretService *k8s.SecretService
+ configMapService *k8s.ConfigMapService
+
+ ctx context.Context
+ cancel context.CancelFunc
+ mu sync.Mutex
+ consumers map[int]struct{}
+ staleMonitorStarted bool
+}
+
+type plannedTeamMember struct {
+ Request CreateTeamMemberRequest
+ MemberKey string
+ DisplayName string
+ Role string
+ RuntimeType string
+ IsLeader bool
+}
+
+func NewTeamService(repo repository.TeamRepository, instanceService InstanceService) TeamService {
+ ctx, cancel := context.WithCancel(context.Background())
+ return &teamService{
+ repo: repo,
+ instanceService: instanceService,
+ pvcService: k8s.NewPVCService(),
+ secretService: k8s.NewSecretService(),
+ configMapService: k8s.NewConfigMapService(),
+ ctx: ctx,
+ cancel: cancel,
+ consumers: map[int]struct{}{},
+ }
+}
+
+func (s *teamService) Start() {
+ teams, err := s.repo.ListActiveTeams()
+ if err != nil {
+ fmt.Printf("Warning: failed to start Team event consumers: %v\n", err)
+ return
+ }
+ for _, team := range teams {
+ s.ensureConsumer(team.ID)
+ }
+ s.ensureStaleTaskMonitor()
+}
+
+func (s *teamService) Stop() {
+ s.cancel()
+}
+
+func (s *teamService) CreateTeam(userID int, req CreateTeamRequest) (*TeamDetailsPayload, error) {
+ req.Name = strings.TrimSpace(req.Name)
+ if req.Name == "" {
+ return nil, fmt.Errorf("team name is required")
+ }
+ if len(req.Members) == 0 {
+ return nil, fmt.Errorf("team must include at least one member")
+ }
+ memberPlans, err := planTeamMembers(req.Name, req.Members)
+ if err != nil {
+ return nil, err
+ }
+ existingTeam, err := s.repo.GetTeamByUserIDAndName(userID, req.Name)
+ if err != nil {
+ return nil, err
+ }
+ if existingTeam != nil {
+ if existingTeam.Status == models.TeamStatusFailed {
+ if err := s.DeleteTeam(userID, existingTeam.ID); err != nil {
+ return nil, err
+ }
+ } else {
+ return nil, fmt.Errorf("team name already exists")
+ }
+ }
+
+ communicationMode := strings.TrimSpace(req.CommunicationMode)
+ if communicationMode == "" {
+ communicationMode = "leader_mediated"
+ }
+ redisURL := strings.TrimSpace(req.RedisURL)
+ if redisURL == "" {
+ redisURL = defaultTeamRedisURL()
+ }
+ if redisURL == "" {
+ return nil, fmt.Errorf("team redis url is required")
+ }
+ if _, err := newRedisBus(redisURL); err != nil {
+ return nil, err
+ }
+
+ sharedStorageGB := req.SharedStorageGB
+ if sharedStorageGB <= 0 {
+ sharedStorageGB = 10
+ }
+ preflightTeam := &models.Team{
+ ID: 0,
+ Name: req.Name,
+ StorageClass: optionalString(strings.TrimSpace(req.StorageClass)),
+ SharedMountPath: teamSharedMountPath,
+ }
+ if err := s.instanceService.ValidateCreateRequests(userID, s.buildTeamMemberInstanceRequests(preflightTeam, memberPlans)); err != nil {
+ return nil, err
+ }
+
+ now := time.Now().UTC()
+ storageClass := optionalString(strings.TrimSpace(req.StorageClass))
+ team := &models.Team{
+ UserID: userID,
+ Name: req.Name,
+ Description: req.Description,
+ Status: models.TeamStatusCreating,
+ CommunicationMode: communicationMode,
+ RedisEventsLastID: "0-0",
+ SharedMountPath: teamSharedMountPath,
+ StorageClass: storageClass,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ if err := s.repo.CreateTeam(team); err != nil {
+ return nil, err
+ }
+ if err := s.instanceService.ValidateCreateRequests(userID, s.buildTeamMemberInstanceRequests(team, memberPlans)); err != nil {
+ return nil, s.rollbackTeamCreation(userID, team, err)
+ }
+
+ if err := s.provisionTeamK8s(userID, team, redisURL, sharedStorageGB, strings.TrimSpace(req.StorageClass)); err != nil {
+ return nil, s.rollbackTeamCreation(userID, team, err)
+ }
+ if err := s.upsertTeamRosterConfig(userID, team, memberPlans); err != nil {
+ return nil, s.rollbackTeamCreation(userID, team, err)
+ }
+
+ for _, memberPlan := range memberPlans {
+ member, err := s.createTeamMemberInstance(userID, team, memberPlan)
+ if err != nil {
+ return nil, s.rollbackTeamCreation(userID, team, err)
+ }
+ member.Status = models.TeamMemberStatusIdle
+ member.UpdatedAt = time.Now().UTC()
+ if err := s.repo.UpdateMember(member); err != nil {
+ return nil, s.rollbackTeamCreation(userID, team, err)
+ }
+ }
+
+ team.Status = models.TeamStatusRunning
+ team.UpdatedAt = time.Now().UTC()
+ if err := s.repo.UpdateTeam(team); err != nil {
+ return nil, err
+ }
+ s.ensureConsumer(team.ID)
+ s.ensureStaleTaskMonitor()
+ if err := s.dispatchInitialLeaderTask(userID, team); err != nil {
+ fmt.Printf("Warning: failed to dispatch initial Team %d leader task: %v\n", team.ID, err)
+ if recordErr := s.recordInitialLeaderTaskDispatchFailure(team.ID, err); recordErr != nil {
+ fmt.Printf("Warning: failed to record Team %d initial leader task dispatch failure: %v\n", team.ID, recordErr)
+ }
+ }
+ return s.GetTeam(userID, team.ID)
+}
+
+func (s *teamService) dispatchInitialLeaderTask(userID int, team *models.Team) error {
+ if team == nil {
+ return fmt.Errorf("team is required")
+ }
+ members, err := s.repo.ListMembersByTeamID(team.ID)
+ if err != nil {
+ return err
+ }
+ leader := findTeamLeader(activeTeamMembers(members))
+ if leader == nil {
+ return fmt.Errorf("team leader not found")
+ }
+ _, err = s.DispatchTask(userID, team.ID, DispatchTeamTaskRequest{
+ TargetMemberID: leader.MemberKey,
+ MessageID: initialLeaderTaskMessageID(team.ID),
+ Payload: buildInitialLeaderTaskPayload(team.Name),
+ })
+ return err
+}
+
+func initialLeaderTaskMessageID(teamID int) string {
+ return fmt.Sprintf("team-%d-bootstrap-introduction", teamID)
+}
+
+func (s *teamService) recordInitialLeaderTaskDispatchFailure(teamID int, cause error) error {
+ now := time.Now().UTC()
+ payload := map[string]interface{}{
+ "v": 1,
+ "event": "bootstrap_dispatch_failed",
+ "teamId": strconv.Itoa(teamID),
+ "intent": initialLeaderTaskIntent,
+ "messageId": initialLeaderTaskMessageID(teamID),
+ "source": "clawmanager",
+ }
+ if cause != nil {
+ payload["diagnostic"] = cause.Error()
+ }
+ payloadJSON, err := marshalOptionalJSON(payload)
+ if err != nil {
+ return err
+ }
+ messageID := initialLeaderTaskMessageID(teamID)
+ return s.repo.CreateEvent(&models.TeamEvent{
+ TeamID: teamID,
+ MessageID: &messageID,
+ EventType: "bootstrap_dispatch_failed",
+ PayloadJSON: payloadJSON,
+ OccurredAt: &now,
+ CreatedAt: now,
+ })
+}
+
+func (s *teamService) provisionTeamK8s(userID int, team *models.Team, redisURL string, sharedStorageGB int, storageClass string) error {
+ ctx := context.Background()
+ pvc, err := s.pvcService.CreateTeamSharedPVC(ctx, userID, team.ID, sharedStorageGB, storageClass)
+ if err != nil {
+ return err
+ }
+ secretName := s.pvcService.GetClient().GetTeamSecretName(team.ID)
+ teamToken, err := generatePrefixedToken("team")
+ if err != nil {
+ return fmt.Errorf("failed to generate Team token: %w", err)
+ }
+ if err := s.secretService.UpsertSecret(ctx, userID, secretName, map[string]string{
+ teamRedisURLSecretKey: redisURL,
+ teamTokenSecretKey: teamToken,
+ }, map[string]string{
+ "app": "clawreef",
+ "managed-by": "clawreef",
+ "team-id": strconv.Itoa(team.ID),
+ }); err != nil {
+ return err
+ }
+
+ team.RedisURLSecretName = &secretName
+ team.RedisURLSecretKey = optionalString(teamRedisURLSecretKey)
+ team.TeamTokenSecretName = &secretName
+ team.TeamTokenSecretKey = optionalString(teamTokenSecretKey)
+ team.SharedPVCName = &pvc.Name
+ team.SharedPVCNamespace = &pvc.Namespace
+ team.UpdatedAt = time.Now().UTC()
+ return s.repo.UpdateTeam(team)
+}
+
+func (s *teamService) upsertTeamRosterConfig(userID int, team *models.Team, members []plannedTeamMember) error {
+ rosterJSON, err := buildTeamRosterConfig(team, members)
+ if err != nil {
+ return err
+ }
+ return s.configMapService.UpsertConfigMap(context.Background(), userID, s.teamConfigMapName(team.ID), map[string]string{
+ teamConfigFileName: rosterJSON,
+ }, map[string]string{
+ "app": "clawreef",
+ "managed-by": "clawreef",
+ "team-id": strconv.Itoa(team.ID),
+ })
+}
+
+func (s *teamService) teamConfigMapName(teamID int) string {
+ client := k8s.GetClient()
+ if client == nil {
+ return fmt.Sprintf("clawreef-team-%d-config", teamID)
+ }
+ return client.GetTeamConfigMapName(teamID)
+}
+
+func (s *teamService) createTeamMemberInstance(userID int, team *models.Team, memberPlan plannedTeamMember) (*models.TeamMember, error) {
+ now := time.Now().UTC()
+ member := &models.TeamMember{
+ TeamID: team.ID,
+ UserID: userID,
+ MemberKey: memberPlan.MemberKey,
+ DisplayName: memberPlan.DisplayName,
+ Role: memberPlan.Role,
+ RuntimeType: memberPlan.RuntimeType,
+ Description: optionalString(strings.TrimSpace(derefTeamString(memberPlan.Request.Description))),
+ Status: models.TeamMemberStatusCreating,
+ Availability: models.TeamMemberAvailabilityUnknown,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ if err := s.repo.CreateMember(member); err != nil {
+ return nil, err
+ }
+
+ createReq := s.buildTeamMemberInstanceRequest(team, memberPlan)
+ instance, err := s.instanceService.Create(userID, createReq)
+ if err != nil {
+ member.Status = models.TeamMemberStatusFailed
+ member.UpdatedAt = time.Now().UTC()
+ _ = s.repo.UpdateMember(member)
+ return nil, err
+ }
+ member.InstanceID = &instance.ID
+ member.UpdatedAt = time.Now().UTC()
+ if err := s.repo.UpdateMember(member); err != nil {
+ return nil, err
+ }
+ return member, nil
+}
+
+func (s *teamService) buildTeamMemberInstanceRequests(team *models.Team, memberPlans []plannedTeamMember) []CreateInstanceRequest {
+ requests := make([]CreateInstanceRequest, 0, len(memberPlans))
+ for _, memberPlan := range memberPlans {
+ requests = append(requests, s.buildTeamMemberInstanceRequest(team, memberPlan))
+ }
+ return requests
+}
+
+func (s *teamService) buildTeamMemberInstanceRequest(team *models.Team, memberPlan plannedTeamMember) CreateInstanceRequest {
+ req := memberPlan.Request
+ return CreateInstanceRequest{
+ Name: teamMemberInstanceName(team.Name, team.ID, memberPlan.MemberKey),
+ Type: memberPlan.RuntimeType,
+ CPUCores: defaultFloat(req.CPUCores, 2),
+ MemoryGB: defaultInt(req.MemoryGB, 4),
+ DiskGB: defaultInt(req.DiskGB, 20),
+ GPUEnabled: req.GPUEnabled,
+ GPUCount: req.GPUCount,
+ OSType: memberPlan.RuntimeType,
+ OSVersion: "latest",
+ ImageRegistry: req.ImageRegistry,
+ ImageTag: req.ImageTag,
+ EnvironmentOverrides: req.EnvironmentOverrides,
+ StorageClass: derefTeamString(team.StorageClass),
+ OpenClawConfigPlan: req.OpenClawConfigPlan,
+ Team: &TeamInstanceConfig{
+ Environment: s.teamMemberEnv(team, memberPlan.MemberKey, memberPlan.Role),
+ SecretName: derefTeamString(team.TeamTokenSecretName),
+ SharedPVCName: derefTeamString(team.SharedPVCName),
+ SharedMountPath: team.SharedMountPath,
+ ConfigMapName: s.teamConfigMapName(team.ID),
+ ConfigMountPath: teamConfigMountPath,
+ },
+ }
+}
+
+func (s *teamService) teamMemberEnv(team *models.Team, memberKey, role string) map[string]string {
+ managerBaseURL, _ := defaultTeamManagerBaseURL()
+ return map[string]string{
+ "CLAWMANAGER_TEAM_ENABLED": "true",
+ "CLAWMANAGER_TEAM_ID": strconv.Itoa(team.ID),
+ "CLAWMANAGER_TEAM_MEMBER_ID": memberKey,
+ "CLAWMANAGER_TEAM_ROLE": role,
+ "CLAWMANAGER_TEAM_SHARED_DIR": team.SharedMountPath,
+ "CLAWMANAGER_TEAM_CONFIG_PATH": teamConfigMountPath,
+ "CLAWMANAGER_TEAM_AUTORUN": "true",
+ "CLAWMANAGER_TEAM_CONSUMER_GROUP": "team-members",
+ "CLAWMANAGER_TEAM_INBOX_KEY": teamInboxKey(team.ID, memberKey),
+ "CLAWMANAGER_TEAM_EVENTS_KEY": teamEventsKey(team.ID),
+ "CLAWMANAGER_TEAM_PRESENCE_KEY": teamPresenceKey(team.ID),
+ "CLAWMANAGER_TEAM_DLQ_KEY": teamDLQKey(team.ID),
+ "CLAWMANAGER_TEAM_MANAGER_URL": managerBaseURL,
+ }
+}
+
+func (s *teamService) ListTeams(userID, offset, limit int) (*TeamListPayload, error) {
+ teams, err := s.repo.ListTeamsByUserID(userID, offset, limit)
+ if err != nil {
+ return nil, err
+ }
+ teams = activeTeams(teams)
+ total, err := s.repo.CountTeamsByUserID(userID)
+ if err != nil {
+ return nil, err
+ }
+ return &TeamListPayload{Teams: teams, Total: total}, nil
+}
+
+func (s *teamService) GetTeam(userID, teamID int) (*TeamDetailsPayload, error) {
+ team, err := s.requireOwnedTeam(userID, teamID)
+ if err != nil {
+ return nil, err
+ }
+ members, err := s.repo.ListMembersByTeamID(teamID)
+ if err != nil {
+ return nil, err
+ }
+ members = activeTeamMembers(members)
+ tasks, err := s.repo.ListTasksByTeamID(teamID, 20)
+ if err != nil {
+ return nil, err
+ }
+ events, err := s.repo.ListEventsByTeamID(teamID, 50)
+ if err != nil {
+ return nil, err
+ }
+ leader := findTeamLeader(members)
+ return &TeamDetailsPayload{
+ Team: team,
+ LeaderMemberID: leaderMemberKey(leader),
+ Leader: leader,
+ Members: members,
+ Tasks: teamTaskPayloads(tasks),
+ Events: teamEventPayloads(events),
+ }, nil
+}
+
+func (s *teamService) DispatchTask(userID, teamID int, req DispatchTeamTaskRequest) (*TeamTaskPayload, error) {
+ team, err := s.requireOwnedTeam(userID, teamID)
+ if err != nil {
+ return nil, err
+ }
+ memberKey := strings.TrimSpace(req.TargetMemberID)
+ if memberKey == "" {
+ members, err := s.repo.ListMembersByTeamID(teamID)
+ if err != nil {
+ return nil, err
+ }
+ memberKey = leaderMemberKey(findTeamLeader(activeTeamMembers(members)))
+ }
+ if memberKey == "" {
+ return nil, fmt.Errorf("target member id is required")
+ }
+ if req.Payload == nil {
+ return nil, fmt.Errorf("task payload is required")
+ }
+ member, err := s.repo.GetMemberByTeamKey(teamID, memberKey)
+ if err != nil {
+ return nil, err
+ }
+ if member == nil {
+ return nil, fmt.Errorf("team member not found")
+ }
+
+ messageID := strings.TrimSpace(req.MessageID)
+ if messageID == "" {
+ messageID = fmt.Sprintf("team-%d-task-%d", teamID, time.Now().UTC().UnixNano())
+ }
+ existing, err := s.repo.GetTaskByMessageID(teamID, messageID)
+ if err != nil {
+ return nil, err
+ }
+ if existing != nil {
+ if existing.TargetMemberID != member.ID {
+ return nil, fmt.Errorf("team task message id already exists")
+ }
+ if existing.Status != models.TeamTaskStatusPending || existing.RedisStreamID != nil {
+ return teamTaskPayload(*existing)
+ }
+ } else {
+ payloadJSON, err := marshalJSON(req.Payload)
+ if err != nil {
+ return nil, fmt.Errorf("failed to encode task payload: %w", err)
+ }
+ now := time.Now().UTC()
+ existing = &models.TeamTask{
+ TeamID: teamID,
+ TargetMemberID: member.ID,
+ CreatedBy: &userID,
+ MessageID: messageID,
+ Status: models.TeamTaskStatusPending,
+ PayloadJSON: payloadJSON,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ if err := s.repo.CreateTask(existing); err != nil {
+ return nil, err
+ }
+ }
+ task := existing
+
+ bus, err := s.redisBusForTeam(context.Background(), team)
+ if err != nil {
+ return nil, err
+ }
+ taskPayload := map[string]interface{}{}
+ if strings.TrimSpace(task.PayloadJSON) != "" {
+ if err := json.Unmarshal([]byte(task.PayloadJSON), &taskPayload); err != nil {
+ return nil, fmt.Errorf("failed to decode task payload: %w", err)
+ }
+ }
+ now := time.Now().UTC()
+ envelope := map[string]interface{}{
+ "v": 1,
+ "messageId": messageID,
+ "teamId": strconv.Itoa(teamID),
+ "from": "clawmanager",
+ "to": member.MemberKey,
+ "intent": eventString(taskPayload, "intent"),
+ "taskId": fmt.Sprintf("team-%d-task-%d", teamID, task.ID),
+ "title": eventString(taskPayload, "title"),
+ "prompt": eventString(taskPayload, "prompt", "goal", "instruction", "instructions"),
+ "contextRefs": normalizeContextRefs(taskPayload["contextRefs"]),
+ "metadata": taskPayload,
+ "createdAt": now.Format(time.RFC3339Nano),
+ }
+ if envelope["intent"] == "" {
+ envelope["intent"] = "run_task"
+ }
+ if envelope["title"] == "" {
+ envelope["title"] = fmt.Sprintf("Team task %d", task.ID)
+ }
+ if envelope["prompt"] == "" {
+ rawPayload, _ := marshalJSON(taskPayload)
+ envelope["prompt"] = rawPayload
+ }
+ envelopeJSON, err := marshalJSON(envelope)
+ if err != nil {
+ return nil, fmt.Errorf("failed to encode task envelope: %w", err)
+ }
+ streamID, err := bus.XAdd(context.Background(), teamInboxKey(team.ID, member.MemberKey), map[string]string{
+ "payload": envelopeJSON,
+ "team_id": strconv.Itoa(team.ID),
+ "task_id": strconv.Itoa(task.ID),
+ "message_id": messageID,
+ "member_id": member.MemberKey,
+ })
+ if err != nil {
+ return nil, err
+ }
+ task.Status = models.TeamTaskStatusDispatched
+ task.RedisStreamID = &streamID
+ task.DispatchedAt = &now
+ task.UpdatedAt = time.Now().UTC()
+ if err := s.repo.UpdateTask(task); err != nil {
+ return nil, err
+ }
+ return teamTaskPayload(*task)
+}
+
+func (s *teamService) DeleteTeam(userID, teamID int) error {
+ team, err := s.requireOwnedTeam(userID, teamID)
+ if err != nil {
+ return err
+ }
+ if team.Status == models.TeamStatusDeleted {
+ return nil
+ }
+
+ now := time.Now().UTC()
+ team.Status = models.TeamStatusDeleting
+ team.UpdatedAt = now
+ if err := s.repo.UpdateTeam(team); err != nil {
+ return err
+ }
+
+ members, err := s.repo.ListMembersByTeamID(teamID)
+ if err != nil {
+ return err
+ }
+ for idx := range members {
+ member := members[idx]
+ if member.Status == models.TeamMemberStatusDeleted {
+ continue
+ }
+ member.Status = models.TeamMemberStatusDeleting
+ member.UpdatedAt = time.Now().UTC()
+ _ = s.repo.UpdateMember(&member)
+ if member.InstanceID != nil && *member.InstanceID > 0 {
+ if err := s.instanceService.Delete(*member.InstanceID); err != nil {
+ fmt.Printf("Warning: failed to delete Team %d member %s instance %d: %v\n", teamID, member.MemberKey, *member.InstanceID, err)
+ }
+ }
+ member.Status = models.TeamMemberStatusDeleted
+ member.CurrentTaskID = nil
+ member.UpdatedAt = time.Now().UTC()
+ _ = s.repo.UpdateMember(&member)
+ }
+
+ ctx := context.Background()
+ if strings.TrimSpace(derefTeamString(team.TeamTokenSecretName)) != "" {
+ if err := s.secretService.DeleteSecret(ctx, userID, derefTeamString(team.TeamTokenSecretName)); err != nil {
+ fmt.Printf("Warning: failed to delete Team %d secret: %v\n", teamID, err)
+ }
+ }
+ if err := s.configMapService.DeleteConfigMap(ctx, userID, s.teamConfigMapName(teamID)); err != nil {
+ fmt.Printf("Warning: failed to delete Team %d configmap: %v\n", teamID, err)
+ }
+ if err := s.pvcService.DeleteTeamSharedPVC(ctx, userID, teamID); err != nil {
+ fmt.Printf("Warning: failed to delete Team %d shared PVC: %v\n", teamID, err)
+ }
+
+ team.Name = deletedTeamName(team.Name, team.ID)
+ team.Status = models.TeamStatusDeleted
+ team.UpdatedAt = time.Now().UTC()
+ return s.repo.UpdateTeam(team)
+}
+
+func (s *teamService) DeleteMember(userID, teamID int, memberID string) error {
+ team, err := s.requireOwnedTeam(userID, teamID)
+ if err != nil {
+ return err
+ }
+ member, err := s.findTeamMemberForDelete(teamID, memberID)
+ if err != nil {
+ return err
+ }
+ if member == nil {
+ return fmt.Errorf("team member not found")
+ }
+ if member.UserID != userID || member.TeamID != teamID {
+ return fmt.Errorf("access denied")
+ }
+ if member.Status == models.TeamMemberStatusDeleted {
+ return nil
+ }
+ if isTeamLeaderRole(member.Role) {
+ return fmt.Errorf("team leader cannot be deleted before assigning a new leader")
+ }
+
+ now := time.Now().UTC()
+ member.Status = models.TeamMemberStatusDeleting
+ member.UpdatedAt = now
+ if err := s.repo.UpdateMember(member); err != nil {
+ return err
+ }
+ if member.InstanceID != nil && *member.InstanceID > 0 {
+ if err := s.instanceService.Delete(*member.InstanceID); err != nil {
+ return err
+ }
+ }
+ member.Status = models.TeamMemberStatusDeleted
+ member.CurrentTaskID = nil
+ member.Progress = 0
+ member.UpdatedAt = time.Now().UTC()
+ if err := s.repo.UpdateMember(member); err != nil {
+ return err
+ }
+ return s.refreshTeamRosterConfig(userID, team)
+}
+
+func (s *teamService) findTeamMemberForDelete(teamID int, memberID string) (*models.TeamMember, error) {
+ value := strings.TrimSpace(memberID)
+ if value == "" {
+ return nil, fmt.Errorf("team member id is required")
+ }
+ if numericID, err := strconv.Atoi(value); err == nil && numericID > 0 {
+ member, err := s.repo.GetMemberByID(numericID)
+ if err != nil || member == nil || member.TeamID != teamID {
+ return member, err
+ }
+ return member, nil
+ }
+ return s.repo.GetMemberByTeamKey(teamID, value)
+}
+
+func (s *teamService) refreshTeamRosterConfig(userID int, team *models.Team) error {
+ members, err := s.repo.ListMembersByTeamID(team.ID)
+ if err != nil {
+ return err
+ }
+ rosterJSON, err := buildTeamRosterConfigFromMembers(team, activeTeamMembers(members))
+ if err != nil {
+ return err
+ }
+ return s.configMapService.UpsertConfigMap(context.Background(), userID, s.teamConfigMapName(team.ID), map[string]string{
+ teamConfigFileName: rosterJSON,
+ }, map[string]string{
+ "app": "clawreef",
+ "managed-by": "clawreef",
+ "team-id": strconv.Itoa(team.ID),
+ })
+}
+
+func (s *teamService) requireOwnedTeam(userID, teamID int) (*models.Team, error) {
+ team, err := s.repo.GetTeamByID(teamID)
+ if err != nil {
+ return nil, err
+ }
+ if team == nil {
+ return nil, fmt.Errorf("team not found")
+ }
+ if team.UserID != userID {
+ return nil, fmt.Errorf("access denied")
+ }
+ return team, nil
+}
+
+func (s *teamService) ensureConsumer(teamID int) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if _, exists := s.consumers[teamID]; exists {
+ return
+ }
+ s.consumers[teamID] = struct{}{}
+ go s.consumeTeamEvents(teamID)
+}
+
+func (s *teamService) consumeTeamEvents(teamID int) {
+ defer func() {
+ s.mu.Lock()
+ delete(s.consumers, teamID)
+ s.mu.Unlock()
+ }()
+
+ for {
+ select {
+ case <-s.ctx.Done():
+ return
+ default:
+ }
+
+ team, err := s.repo.GetTeamByID(teamID)
+ if err != nil || team == nil {
+ time.Sleep(5 * time.Second)
+ continue
+ }
+ bus, err := s.redisBusForTeam(s.ctx, team)
+ if err != nil {
+ time.Sleep(5 * time.Second)
+ continue
+ }
+ lastID := strings.TrimSpace(team.RedisEventsLastID)
+ if lastID == "" {
+ lastID = "0-0"
+ }
+ messages, err := bus.XRead(s.ctx, teamEventsKey(teamID), lastID, 5*time.Second)
+ if err != nil {
+ time.Sleep(2 * time.Second)
+ continue
+ }
+ for _, message := range messages {
+ if err := s.projectTeamEvent(team, bus, message); err != nil {
+ fmt.Printf("Warning: failed to project Team %d event %s: %v\n", teamID, message.ID, err)
+ }
+ team.RedisEventsLastID = message.ID
+ team.UpdatedAt = time.Now().UTC()
+ _ = s.repo.UpdateTeam(team)
+ }
+ }
+}
+
+func (s *teamService) ensureStaleTaskMonitor() {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.staleMonitorStarted {
+ return
+ }
+ s.staleMonitorStarted = true
+ go s.monitorStaleTasks()
+}
+
+func (s *teamService) monitorStaleTasks() {
+ ticker := time.NewTicker(teamTaskStaleSweepInterval)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-s.ctx.Done():
+ return
+ case <-ticker.C:
+ if err := s.sweepStaleTasks(); err != nil {
+ fmt.Printf("Warning: failed to sweep stale Team tasks: %v\n", err)
+ }
+ }
+ }
+}
+
+func (s *teamService) sweepStaleTasks() error {
+ timeout := teamTaskStaleTimeout()
+ if timeout <= 0 {
+ return nil
+ }
+ cutoff := time.Now().UTC().Add(-timeout)
+ tasks, err := s.repo.ListStaleCandidateTasks(cutoff, 100)
+ if err != nil {
+ return err
+ }
+ for idx := range tasks {
+ if err := s.markTaskStale(&tasks[idx], timeout); err != nil {
+ fmt.Printf("Warning: failed to mark Team task %d stale: %v\n", tasks[idx].ID, err)
+ }
+ }
+ return nil
+}
+
+func (s *teamService) markTaskStale(task *models.TeamTask, timeout time.Duration) error {
+ if task == nil {
+ return nil
+ }
+ if task.Status != models.TeamTaskStatusDispatched && task.Status != models.TeamTaskStatusRunning {
+ return nil
+ }
+ lastUpdatedAt := task.UpdatedAt
+ team, err := s.repo.GetTeamByID(task.TeamID)
+ if err != nil {
+ return err
+ }
+ if team == nil || team.Status == models.TeamStatusDeleted || team.Status == models.TeamStatusDeleting {
+ return nil
+ }
+
+ now := time.Now().UTC()
+ previousStatus := task.Status
+ task.Status = models.TeamTaskStatusStale
+ task.FinishedAt = &now
+ message := fmt.Sprintf("Team task stale: no runtime event for %s since %s", timeout.String(), task.UpdatedAt.Format(time.RFC3339))
+ task.ErrorMessage = &message
+ task.UpdatedAt = now
+ if err := s.repo.UpdateTask(task); err != nil {
+ return err
+ }
+
+ member, err := s.repo.GetMemberByID(task.TargetMemberID)
+ if err != nil {
+ return err
+ }
+ if member != nil && member.TeamID == task.TeamID && member.CurrentTaskID != nil && *member.CurrentTaskID == task.ID {
+ member.Status = models.TeamMemberStatusIdle
+ member.CurrentTaskID = nil
+ member.Availability = models.TeamMemberAvailabilityBlocked
+ member.RuntimeTaskID = &task.MessageID
+ member.RuntimeIntent = nil
+ member.BlockedReason = &message
+ member.LastSummary = &message
+ member.Progress = 0
+ member.UpdatedAt = now
+ if err := s.repo.UpdateMember(member); err != nil {
+ return err
+ }
+ }
+
+ payload := map[string]interface{}{
+ "v": 1,
+ "event": "task_stale",
+ "teamId": strconv.Itoa(task.TeamID),
+ "taskId": fmt.Sprintf("team-%d-task-%d", task.TeamID, task.ID),
+ "messageId": task.MessageID,
+ "previousStatus": previousStatus,
+ "staleAfterSeconds": int(timeout.Seconds()),
+ "lastTaskUpdatedAt": lastUpdatedAt.Format(time.RFC3339Nano),
+ "diagnostic": message,
+ "source": "clawmanager",
+ }
+ payloadJSON, err := marshalOptionalJSON(payload)
+ if err != nil {
+ return err
+ }
+ event := &models.TeamEvent{
+ TeamID: task.TeamID,
+ TaskID: &task.ID,
+ EventType: "task_stale",
+ MessageID: &task.MessageID,
+ PayloadJSON: payloadJSON,
+ OccurredAt: &now,
+ CreatedAt: now,
+ }
+ if member != nil && member.TeamID == task.TeamID {
+ event.MemberID = &member.ID
+ }
+ return s.repo.CreateEvent(event)
+}
+
+func (s *teamService) redisBusForTeam(ctx context.Context, team *models.Team) (*redisBus, error) {
+ redisURL := ""
+ if team.RedisURLSecretName != nil && team.RedisURLSecretKey != nil {
+ client := k8s.GetClient()
+ if client == nil {
+ return nil, fmt.Errorf("k8s client not initialized")
+ }
+ value, err := s.secretService.GetSecretValue(ctx, client.GetNamespace(team.UserID), *team.RedisURLSecretName, *team.RedisURLSecretKey)
+ if err != nil {
+ return nil, err
+ }
+ redisURL = strings.TrimSpace(value)
+ }
+ if redisURL == "" {
+ redisURL = defaultTeamRedisURL()
+ }
+ if redisURL == "" {
+ return nil, fmt.Errorf("team redis url is required")
+ }
+ return newRedisBus(redisURL)
+}
+
+func (s *teamService) projectTeamEvent(team *models.Team, bus *redisBus, message redisStreamMessage) error {
+ if exists, err := s.repo.EventExistsByStreamID(team.ID, message.ID); err != nil || exists {
+ return err
+ }
+ payload := mergeRedisEventPayload(message.Fields)
+ eventType := eventString(payload, "event_type", "event", "type")
+ if eventType == "" {
+ eventType = "message"
+ }
+ messageID := eventString(payload, "message_id", "messageId")
+ memberKey := eventString(payload, "member_id", "memberId", "member_key")
+ if isOutboundTeamEvent(eventType) && messageID != "" && !teamEventHasBody(payload) && bus != nil {
+ enriched, err := s.enrichOutboundEventFromInbox(team.ID, bus, payload, messageID)
+ if err != nil {
+ fmt.Printf("Warning: failed to enrich Team %d outbound event %s from inbox: %v\n", team.ID, messageID, err)
+ } else {
+ payload = enriched
+ }
+ }
+
+ var member *models.TeamMember
+ if memberKey != "" {
+ found, err := s.repo.GetMemberByTeamKey(team.ID, memberKey)
+ if err != nil {
+ return err
+ }
+ member = found
+ }
+
+ var task *models.TeamTask
+ if taskID := eventInt(payload, "task_id", "taskId"); taskID > 0 {
+ found, err := s.repo.GetTaskByID(taskID)
+ if err != nil {
+ return err
+ }
+ if found != nil && found.TeamID == team.ID {
+ task = found
+ }
+ }
+ if task == nil && messageID != "" {
+ found, err := s.repo.GetTaskByMessageID(team.ID, messageID)
+ if err != nil {
+ return err
+ }
+ task = found
+ }
+
+ payloadJSON, err := marshalOptionalJSON(payload)
+ if err != nil {
+ return err
+ }
+ streamID := message.ID
+ event := &models.TeamEvent{
+ TeamID: team.ID,
+ EventType: eventType,
+ PayloadJSON: payloadJSON,
+ RedisStreamID: &streamID,
+ OccurredAt: eventTime(payload),
+ }
+ if member != nil {
+ event.MemberID = &member.ID
+ }
+ if task != nil {
+ event.TaskID = &task.ID
+ }
+ if messageID != "" {
+ event.MessageID = &messageID
+ }
+ if err := s.repo.CreateEvent(event); err != nil {
+ return err
+ }
+
+ now := time.Now().UTC()
+ if task != nil {
+ switch eventType {
+ case "task_received":
+ if task.Status == models.TeamTaskStatusPending {
+ task.Status = models.TeamTaskStatusDispatched
+ }
+ case "task_started":
+ if task.Status != models.TeamTaskStatusStale {
+ task.Status = models.TeamTaskStatusRunning
+ task.StartedAt = &now
+ }
+ case "task_completed":
+ task.Status = models.TeamTaskStatusSucceeded
+ task.FinishedAt = &now
+ task.ResultJSON = payloadJSON
+ case "task_failed", "message_failed":
+ task.Status = models.TeamTaskStatusFailed
+ task.FinishedAt = &now
+ if errText := eventString(payload, "error_message", "error"); errText != "" {
+ task.ErrorMessage = &errText
+ }
+ }
+ task.UpdatedAt = now
+ if err := s.repo.UpdateTask(task); err != nil {
+ return err
+ }
+ }
+ if member != nil {
+ member.LastSeenAt = &now
+ applyTeamMemberRuntimeProjection(member, payload, eventType)
+ if task != nil && (eventType == "task_received" || eventType == "task_started") {
+ member.Status = models.TeamMemberStatusBusy
+ if member.Availability == "" || member.Availability == models.TeamMemberAvailabilityUnknown {
+ member.Availability = models.TeamMemberAvailabilityBusy
+ }
+ member.CurrentTaskID = &task.ID
+ member.Progress = eventInt(payload, "progress")
+ }
+ if eventType == "task_completed" || eventType == "task_failed" || eventType == "message_failed" {
+ member.Status = models.TeamMemberStatusIdle
+ member.CurrentTaskID = nil
+ if eventType == "task_completed" {
+ member.Progress = 100
+ if member.Availability != models.TeamMemberAvailabilityBlocked {
+ member.Availability = models.TeamMemberAvailabilityIdle
+ member.BlockedReason = nil
+ }
+ } else {
+ member.Progress = 0
+ if member.Availability == "" || member.Availability == models.TeamMemberAvailabilityUnknown {
+ member.Availability = models.TeamMemberAvailabilityBlocked
+ }
+ if member.BlockedReason == nil {
+ if errText := eventString(payload, "error_message", "error", "reason", "diagnostic", "lastSummary", "last_summary"); errText != "" {
+ member.BlockedReason = &errText
+ }
+ }
+ }
+ }
+ member.UpdatedAt = now
+ if err := s.repo.UpdateMember(member); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (s *teamService) enrichOutboundEventFromInbox(teamID int, bus *redisBus, payload map[string]interface{}, messageID string) (map[string]interface{}, error) {
+ targetMember := eventString(payload, "to", "recipient", "target", "targetMemberId", "target_member_id")
+ if targetMember == "" {
+ return payload, nil
+ }
+ var lastErr error
+ for attempt := 0; attempt < 5; attempt++ {
+ if attempt > 0 {
+ time.Sleep(100 * time.Millisecond)
+ }
+ messages, err := bus.XRevRange(context.Background(), teamInboxKey(teamID, targetMember), 100)
+ if err != nil {
+ lastErr = err
+ continue
+ }
+ for _, inboxMessage := range messages {
+ if !redisStreamMessageMatches(inboxMessage, messageID) {
+ continue
+ }
+ envelope := mergeRedisEventPayload(inboxMessage.Fields)
+ return mergeMissingEventFields(payload, envelope), nil
+ }
+ }
+ return payload, lastErr
+}
+
+func redisStreamMessageMatches(message redisStreamMessage, messageID string) bool {
+ if strings.TrimSpace(message.Fields["message_id"]) == messageID {
+ return true
+ }
+ payload := mergeRedisEventPayload(message.Fields)
+ return eventString(payload, "message_id", "messageId") == messageID
+}
+
+func mergeMissingEventFields(base map[string]interface{}, extra map[string]interface{}) map[string]interface{} {
+ merged := map[string]interface{}{}
+ for key, value := range base {
+ merged[key] = value
+ }
+ for key, value := range extra {
+ if existing, ok := merged[key]; !ok || isEmptyEventValue(existing) {
+ merged[key] = value
+ }
+ }
+ if metadata, ok := extra["metadata"].(map[string]interface{}); ok {
+ for key, value := range metadata {
+ if existing, ok := merged[key]; !ok || isEmptyEventValue(existing) {
+ merged[key] = value
+ }
+ }
+ }
+ return merged
+}
+
+func isEmptyEventValue(value interface{}) bool {
+ if value == nil {
+ return true
+ }
+ if text, ok := value.(string); ok {
+ return strings.TrimSpace(text) == ""
+ }
+ return false
+}
+
+func isOutboundTeamEvent(eventType string) bool {
+ switch eventType {
+ case "outbound", "task_assigned":
+ return true
+ default:
+ return false
+ }
+}
+
+func teamEventHasBody(payload map[string]interface{}) bool {
+ if eventString(payload, "text", "title", "prompt", "instruction", "instructions", "summary", "resultMarkdown") != "" {
+ return true
+ }
+ for _, key := range []string{"sent", "metadata", "data", "envelope", "task"} {
+ record, ok := payload[key].(map[string]interface{})
+ if !ok {
+ continue
+ }
+ if eventString(record, "text", "title", "prompt", "instruction", "instructions", "summary", "resultMarkdown") != "" {
+ return true
+ }
+ }
+ return false
+}
+
+func (s *teamService) markTeamFailed(team *models.Team, cause error) error {
+ team.Status = models.TeamStatusFailed
+ team.UpdatedAt = time.Now().UTC()
+ _ = s.repo.UpdateTeam(team)
+ return cause
+}
+
+func (s *teamService) rollbackTeamCreation(userID int, team *models.Team, cause error) error {
+ members, err := s.repo.ListMembersByTeamID(team.ID)
+ if err != nil {
+ fmt.Printf("Warning: failed to list Team %d members during create rollback: %v\n", team.ID, err)
+ }
+ for idx := range members {
+ member := members[idx]
+ if member.InstanceID != nil && *member.InstanceID > 0 {
+ if err := s.instanceService.Delete(*member.InstanceID); err != nil {
+ fmt.Printf("Warning: failed to delete Team %d member %s instance %d during create rollback: %v\n", team.ID, member.MemberKey, *member.InstanceID, err)
+ }
+ }
+ member.Status = models.TeamMemberStatusDeleted
+ member.CurrentTaskID = nil
+ member.UpdatedAt = time.Now().UTC()
+ _ = s.repo.UpdateMember(&member)
+ }
+
+ ctx := context.Background()
+ if strings.TrimSpace(derefTeamString(team.TeamTokenSecretName)) != "" {
+ if err := s.secretService.DeleteSecret(ctx, userID, derefTeamString(team.TeamTokenSecretName)); err != nil {
+ fmt.Printf("Warning: failed to delete Team %d secret during create rollback: %v\n", team.ID, err)
+ }
+ }
+ if err := s.configMapService.DeleteConfigMap(ctx, userID, s.teamConfigMapName(team.ID)); err != nil {
+ fmt.Printf("Warning: failed to delete Team %d configmap during create rollback: %v\n", team.ID, err)
+ }
+ if err := s.pvcService.DeleteTeamSharedPVC(ctx, userID, team.ID); err != nil {
+ fmt.Printf("Warning: failed to delete Team %d shared PVC during create rollback: %v\n", team.ID, err)
+ }
+
+ team.Name = deletedTeamName(team.Name, team.ID)
+ team.Status = models.TeamStatusDeleted
+ team.UpdatedAt = time.Now().UTC()
+ if err := s.repo.UpdateTeam(team); err != nil {
+ fmt.Printf("Warning: failed to mark Team %d deleted during create rollback: %v\n", team.ID, err)
+ }
+ return cause
+}
+
+func teamTaskPayloads(tasks []models.TeamTask) []TeamTaskPayload {
+ result := make([]TeamTaskPayload, 0, len(tasks))
+ for _, task := range tasks {
+ if payload, err := teamTaskPayload(task); err == nil {
+ result = append(result, *payload)
+ }
+ }
+ return result
+}
+
+func teamTaskPayload(task models.TeamTask) (*TeamTaskPayload, error) {
+ payload := &TeamTaskPayload{TeamTask: task}
+ if strings.TrimSpace(task.PayloadJSON) != "" {
+ if err := json.Unmarshal([]byte(task.PayloadJSON), &payload.Payload); err != nil {
+ return nil, err
+ }
+ }
+ if task.ResultJSON != nil && strings.TrimSpace(*task.ResultJSON) != "" {
+ if err := json.Unmarshal([]byte(*task.ResultJSON), &payload.Result); err != nil {
+ return nil, err
+ }
+ }
+ return payload, nil
+}
+
+func buildInitialLeaderTaskPayload(teamName string) map[string]interface{} {
+ normalizedTeamName := strings.TrimSpace(teamName)
+ if normalizedTeamName == "" {
+ normalizedTeamName = "current"
+ }
+ prompt := fmt.Sprintf("请介绍`team %s`当前 Redis Team成员构成,包括各角色的职责分工、运行状态与技术能力边界。同时说明团队内部的协作与通信机制(team_send),例如任务流转方式、消息同步方式、上下文共享方式以及可调用的方法、工具与操作能力,以便后续能够更高效地开展团队工作", normalizedTeamName)
+ return map[string]interface{}{
+ "intent": initialLeaderTaskIntent,
+ "title": "介绍当前 Redis Team 成员与协作机制",
+ "prompt": prompt,
+ }
+}
+
+func teamEventPayloads(events []models.TeamEvent) []TeamEventPayload {
+ result := make([]TeamEventPayload, 0, len(events))
+ for _, event := range events {
+ payload := TeamEventPayload{TeamEvent: event}
+ if event.PayloadJSON != nil && strings.TrimSpace(*event.PayloadJSON) != "" {
+ _ = json.Unmarshal([]byte(*event.PayloadJSON), &payload.Payload)
+ }
+ result = append(result, payload)
+ }
+ return result
+}
+
+func mergeRedisEventPayload(fields map[string]string) map[string]interface{} {
+ payload := map[string]interface{}{}
+ if raw := strings.TrimSpace(fields["payload"]); raw != "" {
+ _ = json.Unmarshal([]byte(raw), &payload)
+ }
+ for key, value := range fields {
+ if _, exists := payload[key]; !exists {
+ payload[key] = value
+ }
+ }
+ return payload
+}
+
+func eventString(payload map[string]interface{}, keys ...string) string {
+ for _, key := range keys {
+ value, ok := payload[key]
+ if !ok || value == nil {
+ continue
+ }
+ switch typed := value.(type) {
+ case string:
+ return strings.TrimSpace(typed)
+ case float64:
+ return strconv.Itoa(int(typed))
+ case int:
+ return strconv.Itoa(typed)
+ default:
+ return strings.TrimSpace(fmt.Sprintf("%v", typed))
+ }
+ }
+ return ""
+}
+
+func applyTeamMemberRuntimeProjection(member *models.TeamMember, payload map[string]interface{}, eventType string) {
+ if member == nil {
+ return
+ }
+ availability := normalizeTeamAvailability(eventString(payload, "availability", "memberAvailability"))
+ explicitlyBlocked := availability == models.TeamMemberAvailabilityBlocked
+ if availability != "" {
+ member.Availability = availability
+ }
+ if member.Availability == "" {
+ member.Availability = models.TeamMemberAvailabilityUnknown
+ }
+ if runtimeStatus := eventString(payload, "runtime_status", "runtimeStatus", "runtime", "liveness"); runtimeStatus != "" {
+ member.RuntimeStatus = &runtimeStatus
+ }
+ if runtimeTaskID := eventString(payload, "runtime_task_id", "runtimeTaskId", "current_task_id", "currentTaskId", "taskId"); runtimeTaskID != "" {
+ member.RuntimeTaskID = &runtimeTaskID
+ }
+ if runtimeIntent := eventString(payload, "runtime_intent", "runtimeIntent", "current_intent", "currentIntent", "intent"); runtimeIntent != "" {
+ member.RuntimeIntent = &runtimeIntent
+ }
+ if summary := eventString(payload, "last_summary", "lastSummary", "summary", "diagnostic"); summary != "" {
+ member.LastSummary = &summary
+ }
+ if reason := eventString(payload, "blocked_reason", "blockedReason", "error_message", "error", "reason"); reason != "" {
+ member.BlockedReason = &reason
+ }
+ switch eventType {
+ case "presence", "member_presence", "status", "member_status":
+ return
+ case "task_completed":
+ if !explicitlyBlocked {
+ member.Availability = models.TeamMemberAvailabilityIdle
+ member.BlockedReason = nil
+ }
+ case "task_failed", "message_failed":
+ if member.Availability == "" || member.Availability == models.TeamMemberAvailabilityUnknown || member.Availability == models.TeamMemberAvailabilityBusy {
+ member.Availability = models.TeamMemberAvailabilityBlocked
+ }
+ }
+}
+
+func normalizeTeamAvailability(value string) string {
+ switch strings.ToLower(strings.TrimSpace(value)) {
+ case "idle", "available", "ready":
+ return models.TeamMemberAvailabilityIdle
+ case "busy", "running", "working":
+ return models.TeamMemberAvailabilityBusy
+ case "blocked", "error", "failed":
+ return models.TeamMemberAvailabilityBlocked
+ case "offline", "unavailable":
+ return models.TeamMemberAvailabilityOffline
+ case "unknown":
+ return models.TeamMemberAvailabilityUnknown
+ default:
+ return ""
+ }
+}
+
+func eventInt(payload map[string]interface{}, keys ...string) int {
+ for _, key := range keys {
+ value, ok := payload[key]
+ if !ok || value == nil {
+ continue
+ }
+ switch typed := value.(type) {
+ case float64:
+ return int(typed)
+ case int:
+ return typed
+ case string:
+ parsed, _ := strconv.Atoi(strings.TrimSpace(typed))
+ return parsed
+ }
+ }
+ return 0
+}
+
+func eventTime(payload map[string]interface{}) *time.Time {
+ for _, key := range []string{"occurred_at", "occurredAt", "timestamp"} {
+ raw := eventString(payload, key)
+ if raw == "" {
+ continue
+ }
+ if parsed, err := time.Parse(time.RFC3339Nano, raw); err == nil {
+ return &parsed
+ }
+ }
+ now := time.Now().UTC()
+ return &now
+}
+
+func normalizeContextRefs(value interface{}) []string {
+ rawItems, ok := value.([]interface{})
+ if !ok {
+ if typed, ok := value.([]string); ok {
+ return typed
+ }
+ return nil
+ }
+ refs := make([]string, 0, len(rawItems))
+ for _, item := range rawItems {
+ ref := strings.TrimSpace(fmt.Sprintf("%v", item))
+ if ref != "" {
+ refs = append(refs, ref)
+ }
+ }
+ return refs
+}
+
+func planTeamMembers(teamName string, members []CreateTeamMemberRequest) ([]plannedTeamMember, error) {
+ plans := make([]plannedTeamMember, 0, len(members))
+ memberKeys := map[string]struct{}{}
+ leaderCount := 0
+ for idx, memberReq := range members {
+ role := normalizeTeamMemberRole(memberReq.Role, memberReq.IsLeader)
+ memberKey, err := normalizeTeamMemberKey(memberReq.MemberID, role, idx)
+ if err != nil {
+ return nil, err
+ }
+ if _, exists := memberKeys[memberKey]; exists {
+ return nil, fmt.Errorf("duplicate team member id: %s", memberKey)
+ }
+ memberKeys[memberKey] = struct{}{}
+ runtimeType, err := normalizeTeamMemberRuntimeType(memberReq.RuntimeType)
+ if err != nil {
+ return nil, err
+ }
+
+ isLeader := memberReq.IsLeader || isTeamLeaderRole(role)
+ if isLeader {
+ leaderCount++
+ role = "leader"
+ }
+ displayName := strings.TrimSpace(memberReq.Name)
+ if displayName == "" {
+ displayName = fmt.Sprintf("%s-%s", teamName, memberKey)
+ }
+ plans = append(plans, plannedTeamMember{
+ Request: memberReq,
+ MemberKey: memberKey,
+ DisplayName: displayName,
+ Role: role,
+ RuntimeType: runtimeType,
+ IsLeader: isLeader,
+ })
+ }
+ if leaderCount != 1 {
+ return nil, fmt.Errorf("team must include exactly one leader")
+ }
+ return plans, nil
+}
+
+func teamMemberInstanceName(teamName string, teamID int, memberKey string) string {
+ teamPart := normalizeTeamMemberKeyForInstanceName(teamName)
+ if teamPart == "" {
+ teamPart = "team"
+ }
+ memberPart := normalizeTeamMemberKeyForInstanceName(memberKey)
+ if memberPart == "" {
+ memberPart = "member"
+ }
+ const maxInstanceNameLength = 50
+ idPart := fmt.Sprintf("%d", teamID)
+ maxMemberLength := maxInstanceNameLength - len(idPart) - len("--t")
+ if maxMemberLength < 1 {
+ maxMemberLength = 1
+ }
+ if len(memberPart) > maxMemberLength {
+ memberPart = strings.Trim(memberPart[:maxMemberLength], "-")
+ if memberPart == "" {
+ memberPart = "member"
+ }
+ }
+ suffix := fmt.Sprintf("-%s-%s", idPart, memberPart)
+ if len(teamPart)+len(suffix) <= maxInstanceNameLength {
+ return teamPart + suffix
+ }
+ maxTeamLength := maxInstanceNameLength - len(suffix)
+ if maxTeamLength < 1 {
+ maxTeamLength = 1
+ }
+ return strings.Trim(teamPart[:maxTeamLength], "-") + suffix
+}
+
+func normalizeTeamMemberKeyForInstanceName(value string) string {
+ normalized := strings.ToLower(strings.TrimSpace(value))
+ normalized = strings.ReplaceAll(normalized, "_", "-")
+ normalized = strings.ReplaceAll(normalized, " ", "-")
+ normalized = teamMemberInstanceNameInvalidChars.ReplaceAllString(normalized, "")
+ normalized = teamMemberInstanceNameRepeatedDashs.ReplaceAllString(normalized, "-")
+ return strings.Trim(normalized, "-")
+}
+
+func normalizeTeamMemberRuntimeType(raw string) (string, error) {
+ runtimeType := strings.ToLower(strings.TrimSpace(raw))
+ if runtimeType == "" {
+ return "openclaw", nil
+ }
+ switch runtimeType {
+ case "openclaw", "hermes":
+ return runtimeType, nil
+ default:
+ return "", fmt.Errorf("unsupported team member runtime type: %s", raw)
+ }
+}
+
+func normalizeTeamMemberRole(raw string, isLeader bool) string {
+ role := strings.TrimSpace(raw)
+ if isLeader || isTeamLeaderRole(role) {
+ return "leader"
+ }
+ if role == "" {
+ return "member"
+ }
+ return role
+}
+
+func isTeamLeaderRole(role string) bool {
+ normalized := strings.ToLower(strings.TrimSpace(role))
+ normalized = strings.ReplaceAll(normalized, "_", "-")
+ normalized = strings.ReplaceAll(normalized, " ", "-")
+ return normalized == "leader" || normalized == "team-leader"
+}
+
+func findTeamLeader(members []models.TeamMember) *models.TeamMember {
+ for idx := range members {
+ if isTeamLeaderRole(members[idx].Role) {
+ member := members[idx]
+ return &member
+ }
+ }
+ return nil
+}
+
+func leaderMemberKey(member *models.TeamMember) string {
+ if member == nil {
+ return ""
+ }
+ return member.MemberKey
+}
+
+type teamRosterConfig struct {
+ Version int `json:"version"`
+ TeamID string `json:"teamId"`
+ LeaderMemberID string `json:"leaderMemberId"`
+ CommunicationMode string `json:"communicationMode"`
+ SharedDir string `json:"sharedDir"`
+ Members []teamRosterMember `json:"members"`
+ Redis teamRosterRedis `json:"redis"`
+}
+
+type teamRosterMember struct {
+ MemberID string `json:"memberId"`
+ Role string `json:"role"`
+ RuntimeType string `json:"runtimeType"`
+ DisplayName string `json:"displayName"`
+ Description string `json:"description,omitempty"`
+ IsLeader bool `json:"isLeader"`
+}
+
+type teamRosterRedis struct {
+ EventsKey string `json:"eventsKey"`
+ PresenceKey string `json:"presenceKey"`
+ DLQKey string `json:"dlqKey"`
+}
+
+func buildTeamRosterConfig(team *models.Team, members []plannedTeamMember) (string, error) {
+ config := teamRosterConfig{
+ Version: 1,
+ TeamID: strconv.Itoa(team.ID),
+ CommunicationMode: team.CommunicationMode,
+ SharedDir: team.SharedMountPath,
+ Members: make([]teamRosterMember, 0, len(members)),
+ Redis: teamRosterRedis{
+ EventsKey: teamEventsKey(team.ID),
+ PresenceKey: teamPresenceKey(team.ID),
+ DLQKey: teamDLQKey(team.ID),
+ },
+ }
+ for _, member := range members {
+ if member.IsLeader {
+ config.LeaderMemberID = member.MemberKey
+ }
+ config.Members = append(config.Members, teamRosterMember{
+ MemberID: member.MemberKey,
+ Role: member.Role,
+ RuntimeType: member.RuntimeType,
+ DisplayName: member.DisplayName,
+ Description: derefTeamString(member.Request.Description),
+ IsLeader: member.IsLeader,
+ })
+ }
+ if config.LeaderMemberID == "" {
+ return "", fmt.Errorf("team must include exactly one leader")
+ }
+ return marshalJSON(config)
+}
+
+func buildTeamRosterConfigFromMembers(team *models.Team, members []models.TeamMember) (string, error) {
+ config := teamRosterConfig{
+ Version: 1,
+ TeamID: strconv.Itoa(team.ID),
+ CommunicationMode: team.CommunicationMode,
+ SharedDir: team.SharedMountPath,
+ Members: make([]teamRosterMember, 0, len(members)),
+ Redis: teamRosterRedis{
+ EventsKey: teamEventsKey(team.ID),
+ PresenceKey: teamPresenceKey(team.ID),
+ DLQKey: teamDLQKey(team.ID),
+ },
+ }
+ for _, member := range members {
+ isLeader := isTeamLeaderRole(member.Role)
+ runtimeType := strings.TrimSpace(member.RuntimeType)
+ if runtimeType == "" {
+ runtimeType = "openclaw"
+ }
+ if isLeader {
+ config.LeaderMemberID = member.MemberKey
+ }
+ config.Members = append(config.Members, teamRosterMember{
+ MemberID: member.MemberKey,
+ Role: member.Role,
+ RuntimeType: runtimeType,
+ DisplayName: member.DisplayName,
+ Description: derefTeamString(member.Description),
+ IsLeader: isLeader,
+ })
+ }
+ if config.LeaderMemberID == "" {
+ return "", fmt.Errorf("team must include exactly one leader")
+ }
+ return marshalJSON(config)
+}
+
+func activeTeamMembers(members []models.TeamMember) []models.TeamMember {
+ active := make([]models.TeamMember, 0, len(members))
+ for _, member := range members {
+ if member.Status == models.TeamMemberStatusDeleted || member.Status == models.TeamMemberStatusDeleting {
+ continue
+ }
+ active = append(active, member)
+ }
+ return active
+}
+
+func activeTeams(teams []models.Team) []models.Team {
+ active := make([]models.Team, 0, len(teams))
+ for _, team := range teams {
+ if team.Status == models.TeamStatusDeleted {
+ continue
+ }
+ active = append(active, team)
+ }
+ return active
+}
+
+func deletedTeamName(name string, teamID int) string {
+ const maxTeamNameLength = 255
+ suffix := fmt.Sprintf("__deleted_%d", teamID)
+ trimmed := strings.TrimSpace(name)
+ if trimmed == "" {
+ trimmed = "team"
+ }
+ if strings.HasSuffix(trimmed, suffix) {
+ return trimmed
+ }
+ if len(trimmed)+len(suffix) <= maxTeamNameLength {
+ return trimmed + suffix
+ }
+ runes := []rune(trimmed)
+ maxPrefixLength := maxTeamNameLength - len(suffix)
+ if len(runes) > maxPrefixLength {
+ runes = runes[:maxPrefixLength]
+ }
+ return string(runes) + suffix
+}
+
+func normalizeTeamMemberKey(raw, role string, index int) (string, error) {
+ value := strings.ToLower(strings.TrimSpace(raw))
+ if value == "" {
+ value = strings.ToLower(strings.TrimSpace(role))
+ }
+ if value == "" {
+ value = fmt.Sprintf("member-%d", index+1)
+ }
+ value = strings.ReplaceAll(value, "_", "-")
+ value = strings.ReplaceAll(value, " ", "-")
+ if !teamMemberKeyPattern.MatchString(value) {
+ return "", fmt.Errorf("team member id is invalid")
+ }
+ return value, nil
+}
+
+func defaultTeamRedisURL() string {
+ for _, key := range []string{"CLAWMANAGER_TEAM_REDIS_URL", "TEAM_REDIS_URL", "REDIS_URL"} {
+ if value := strings.TrimSpace(os.Getenv(key)); value != "" {
+ return value
+ }
+ }
+ return ""
+}
+
+func teamTaskStaleTimeout() time.Duration {
+ raw := strings.TrimSpace(os.Getenv("CLAWMANAGER_TEAM_TASK_STALE_SECONDS"))
+ if raw == "" {
+ return defaultTeamTaskStaleTimeout
+ }
+ seconds, err := strconv.Atoi(raw)
+ if err != nil {
+ return defaultTeamTaskStaleTimeout
+ }
+ if seconds <= 0 {
+ return 0
+ }
+ return time.Duration(seconds) * time.Second
+}
+
+func defaultTeamManagerBaseURL() (string, bool) {
+ if override := strings.TrimSpace(os.Getenv("CLAWMANAGER_TEAM_MANAGER_BASE_URL")); override != "" {
+ return override, true
+ }
+ return defaultAgentControlBaseURL()
+}
+
+func teamInboxKey(teamID int, memberID string) string {
+ return fmt.Sprintf("claw:team:%d:inbox:%s", teamID, memberID)
+}
+
+func teamEventsKey(teamID int) string {
+ return fmt.Sprintf("claw:team:%d:events", teamID)
+}
+
+func teamPresenceKey(teamID int) string {
+ return fmt.Sprintf("claw:team:%d:presence", teamID)
+}
+
+func teamDLQKey(teamID int) string {
+ return fmt.Sprintf("claw:team:%d:dlq", teamID)
+}
+
+func defaultInt(value, fallback int) int {
+ if value > 0 {
+ return value
+ }
+ return fallback
+}
+
+func defaultFloat(value, fallback float64) float64 {
+ if value > 0 {
+ return value
+ }
+ return fallback
+}
+
+func derefTeamString(value *string) string {
+ if value == nil {
+ return ""
+ }
+ return strings.TrimSpace(*value)
+}
diff --git a/backend/internal/services/team_service_test.go b/backend/internal/services/team_service_test.go
new file mode 100644
index 0000000..bb6ee5f
--- /dev/null
+++ b/backend/internal/services/team_service_test.go
@@ -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)
+ }
+}
diff --git a/backend/internal/utils/response.go b/backend/internal/utils/response.go
index e13d960..8ba0b9d 100644
--- a/backend/internal/utils/response.go
+++ b/backend/internal/utils/response.go
@@ -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)
diff --git a/deployments/k3s/clawmanager.yaml b/deployments/k3s/clawmanager.yaml
index 18f6369..f94f10e 100644
--- a/deployments/k3s/clawmanager.yaml
+++ b/deployments/k3s/clawmanager.yaml
@@ -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(*)
diff --git a/deployments/k8s/clawmanager.yaml b/deployments/k8s/clawmanager.yaml
index 45f64e3..4bad6e5 100644
--- a/deployments/k8s/clawmanager.yaml
+++ b/deployments/k8s/clawmanager.yaml
@@ -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
diff --git a/docs/main/clawmanager_group_chat.jpg b/docs/main/clawmanager_group_chat.jpg
index 1c24cab..92d03ce 100644
Binary files a/docs/main/clawmanager_group_chat.jpg and b/docs/main/clawmanager_group_chat.jpg differ
diff --git a/docs/main/team-workspace.png b/docs/main/team-workspace.png
new file mode 100644
index 0000000..b0639eb
Binary files /dev/null and b/docs/main/team-workspace.png differ
diff --git a/frontend/src/components/InstanceAccess.tsx b/frontend/src/components/InstanceAccess.tsx
index 4253d25..1ec0926 100644
--- a/frontend/src/components/InstanceAccess.tsx
+++ b/frontend/src/components/InstanceAccess.tsx
@@ -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 (