fix: stabilize OpenClaw Lite Team collaboration and documentation (#162)
* chore: ignore local agency and team snapshots * feat: add team agent profile templates * KANBAN * template * prototype * Fix tenant runtime pool deployment * Fix local team profiles runtime override * Update local team profiles testing guide * Adapt local team profiles test deployment * Patch tenant NFS server to ClusterIP * Use NFS export root for workspace mounts * Tighten team task completion projection * Fix team event body map comparison * before modification * temp_peer1 * leadermodel * team_fix * fix(team): harden workflow completion and workspace handling * fix(team): harden redis workflow protocol * discord link updated * chore: checkpoint team workflow fixes * fix: stabilize leader kanban ledger * fix: coalesce leader mediated work items * fix: confirm leader-mediated member results * fix: harden leader mediated team state * fix: stabilize team shared runtime state * Fix team task reconciliation and create flow * fix: stabilize leader mediated team orchestration * fix: refine team workspace presentation * checkpoint: persist team workflow ledger fixes * fix: reconcile team workflow delivery state * fix: preserve meaningful team chat events * docs: add team workspace quick guides * chore: ignore local team deployment files * fix: preserve setgid on team shared directories
This commit is contained in:
@@ -67,6 +67,7 @@ Thumbs.db
|
||||
|
||||
|
||||
.codex-logs/
|
||||
.codex-temp/
|
||||
.cache/
|
||||
.VSCodeCounter/
|
||||
.superpowers/
|
||||
@@ -79,3 +80,6 @@ CLAUDE.md
|
||||
TEAM_IMAGE_DEPLOY_STEPS.md
|
||||
TEAM_PROFILES_LOCAL_TESTING.md
|
||||
/team-optimization-docs/
|
||||
/clawmanager-apply.sh
|
||||
/clawmanager-team-profiles-test.yaml
|
||||
/clawmanager-tenant.yaml
|
||||
|
||||
+7
-8
@@ -110,16 +110,14 @@ Es eignet sich besonders fuer:
|
||||
<a id="team-workspaces"></a>
|
||||
## Team Workspaces
|
||||
|
||||
Team Workspaces erweitern ClawManager von Einzelinstanz-Betrieb zu koordinierter Multi-Agent-Runtime-Verwaltung. Nutzer koennen ein Team erstellen, einen Leader und mehrere Member zuweisen und ClawManager die Bereitstellung der Member-Runtimes, die Injection der Kollaborationskonfiguration und die Sichtbarkeit von Tasks und Events in der Control Plane ueberlassen.
|
||||
Team Workspaces bieten einen vereinfachten OpenClaw-Lite-Kollaborationsablauf: Rollenvorlage auswaehlen, Team erstellen und das Ziel im Team-Chat beschreiben. Der Leader plant, koordiniert die Mitglieder, sammelt Ergebnisse und liefert die finale Zusammenfassung.
|
||||
|
||||
Das aktuelle MVP konzentriert sich auf OpenClaw-Member-Orchestrierung und den Redis-Team-Bus-Loop:
|
||||
- feste Leader-vermittelte Zusammenarbeit ohne Runtime- oder Ressourcenprofil je Mitglied
|
||||
- integrierte Vorlagen fuer Auslieferung, Produkterkundung und Softwareentwicklung
|
||||
- Team-Chat fuer Plan, Zuweisung, Fortschritt, Review, Lieferung und Zusammenfassung
|
||||
- Execution Kanban fuer Gesamtaufgabe und aktuelle Mitgliederlieferungen
|
||||
|
||||
- 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
|
||||
Siehe [Team Workspace Quick Guide](./docs/team-workspaces-guide_de.md) fuer Erstellung, Kollaborationsphasen und Ergebnisansicht.
|
||||
|
||||
<a id="runtime-integrations"></a>
|
||||
## Runtime-Integrationen
|
||||
@@ -263,6 +261,7 @@ Siehe [Developer Guide (English)](./docs/developer-guide.md).
|
||||
## Dokumentation
|
||||
|
||||
- [Benutzerhandbuch](./docs/use_guide_de.md)
|
||||
- [Team Workspace Quick Guide](./docs/team-workspaces-guide_de.md)
|
||||
- [Deployment Guide (English)](./docs/deployment.md)
|
||||
- [Admin and User Guide (English)](./docs/admin-user-guide.md)
|
||||
- [Agent Control Plane Guide (English)](./docs/agent-control-plane.md)
|
||||
|
||||
+7
-8
@@ -110,16 +110,14 @@ ClawManager は、AI エージェントインスタンスの運用を Kubernetes
|
||||
<a id="team-workspaces"></a>
|
||||
## Team ワークスペース
|
||||
|
||||
Team ワークスペースは、ClawManager を単一インスタンス運用から複数 Agent の協調ランタイム管理へ拡張します。ユーザーは Team を作成し、1 人の Leader と複数のメンバーを割り当て、ClawManager にメンバー Runtime のプロビジョニング、協調設定の注入、タスクとイベント状態の可視化を任せることができます。
|
||||
Team ワークスペースは、簡素化された OpenClaw Lite の協働フローを提供します。ロールテンプレートを選び、Team を作成して、Team チャットで目標を説明するだけです。Leader が計画、メンバー調整、成果物収集、最終結果の提示を担当します。
|
||||
|
||||
現在の MVP は、OpenClaw メンバーのオーケストレーションと Redis Team Bus のループに焦点を当てています。
|
||||
- メンバーごとの Runtime やリソースプリセットを設定せず、Leader 仲介型の協働に固定
|
||||
- デリバリー、製品探索、ソフトウェア開発向けの組み込みテンプレート
|
||||
- 計画、割り当て、進捗、レビュー、成果物、最終統合を表示する Team チャット
|
||||
- 総合タスク状態と現在の成果物を表示する Execution Kanban
|
||||
|
||||
- 検証済みの Leader / メンバー roster によるワンクリック Team 作成
|
||||
- Team ロール、メンバー ID、コントロールプレーン URL、共有マウント設定を持つメンバー Runtime Pod の作成
|
||||
- 管理された環境変数と Secret 参照による Redis inbox、events、presence、DLQ key の注入
|
||||
- コンテキスト、成果物、スナップショット、タスク結果のための共有 PVC を `/team` にマウント
|
||||
- Leader デスクトップ、Team チャット、メンバー一覧、配布パネル、タスク進捗、イベント/結果履歴をまとめた Team 詳細ビュー
|
||||
- Team、メンバー、タスク、イベントを DB の権威状態として保持し、Redis はメッセージバスとして扱う設計
|
||||
作成、協働段階、結果の確認方法は [Team Workspace Quick Guide](./docs/team-workspaces-guide_ja.md) を参照してください。
|
||||
|
||||
<a id="runtime-integrations"></a>
|
||||
## Runtime 連携
|
||||
@@ -263,6 +261,7 @@ ClawManager は、React フロントエンド、Go バックエンド、状態
|
||||
## ドキュメント
|
||||
|
||||
- [ユーザーガイド](./docs/use_guide_ja.md)
|
||||
- [Team Workspace Quick Guide](./docs/team-workspaces-guide_ja.md)
|
||||
- [Deployment Guide (English)](./docs/deployment.md)
|
||||
- [Admin and User Guide (English)](./docs/admin-user-guide.md)
|
||||
- [Agent Control Plane Guide (English)](./docs/agent-control-plane.md)
|
||||
|
||||
+7
-8
@@ -110,16 +110,14 @@ ClawManager는 AI Agent 인스턴스 운영을 Kubernetes 위로 확장하고,
|
||||
<a id="team-workspaces"></a>
|
||||
## Team 워크스페이스
|
||||
|
||||
Team 워크스페이스는 ClawManager를 단일 인스턴스 운영에서 여러 Agent의 협업 런타임 관리로 확장합니다. 사용자는 Team을 생성하고 한 명의 Leader와 여러 멤버를 지정할 수 있으며, ClawManager는 멤버 Runtime 프로비저닝, 협업 설정 주입, 작업 및 이벤트 상태 가시화를 담당합니다.
|
||||
Team 워크스페이스는 단순화된 OpenClaw Lite 협업 흐름을 제공합니다. 역할 템플릿을 고르고 Team을 만든 뒤 Team 채팅에 목표를 설명하면 됩니다. Leader가 계획 수립, 멤버 조율, 산출물 수집, 최종 결과 정리를 담당합니다.
|
||||
|
||||
현재 MVP는 OpenClaw 멤버 오케스트레이션과 Redis Team Bus 루프에 초점을 맞춥니다.
|
||||
- 멤버별 Runtime 또는 리소스 프리셋 설정 없이 Leader 중개 협업으로 고정
|
||||
- 납품, 제품 탐색, 소프트웨어 엔지니어링을 위한 기본 템플릿
|
||||
- 계획, 배정, 진행, 검토, 산출물, 최종 종합을 보여 주는 Team 채팅
|
||||
- 전체 작업 상태와 현재 멤버 산출물을 보여 주는 Execution Kanban
|
||||
|
||||
- 검증된 Leader / 멤버 roster 기반 원클릭 Team 생성
|
||||
- Team 역할, 멤버 ID, 컨트롤 플레인 URL, 공유 마운트 설정이 포함된 멤버 Runtime Pod 생성
|
||||
- 관리되는 환경 변수와 Secret 참조를 통한 Redis inbox, events, presence, DLQ key 주입
|
||||
- 컨텍스트, 산출물, 스냅샷, 작업 결과를 위한 공유 PVC를 `/team`에 마운트
|
||||
- Leader 데스크톱, Team 채팅, 멤버 목록, 배포 패널, 작업 진행률, 이벤트/결과 이력을 모아 보여주는 Team 상세 화면
|
||||
- Team, 멤버, 작업, 이벤트를 DB의 권위 상태로 유지하고 Redis는 메시지 버스로 사용하는 설계
|
||||
생성 과정, 협업 단계, 결과 확인은 [Team Workspace Quick Guide](./docs/team-workspaces-guide_ko.md)를 참고하세요.
|
||||
|
||||
<a id="runtime-integrations"></a>
|
||||
## Runtime 연동
|
||||
@@ -263,6 +261,7 @@ ClawManager는 React 프런트엔드, Go 백엔드, 상태 저장용 MySQL, 그
|
||||
## 문서
|
||||
|
||||
- [사용자 가이드](./docs/use_guide_ko.md)
|
||||
- [Team Workspace Quick Guide](./docs/team-workspaces-guide_ko.md)
|
||||
- [Deployment Guide (English)](./docs/deployment.md)
|
||||
- [Admin and User Guide (English)](./docs/admin-user-guide.md)
|
||||
- [Agent Control Plane Guide (English)](./docs/agent-control-plane.md)
|
||||
|
||||
@@ -110,16 +110,14 @@ It is designed for:
|
||||
<a id="team-workspaces"></a>
|
||||
## Team Workspaces
|
||||
|
||||
Team workspaces extend ClawManager from single-instance operations into coordinated multi-agent runtime management. A user can create a Team, assign one leader and multiple members, and let ClawManager provision the member runtimes, inject collaboration configuration, and keep task and event state visible from the control plane.
|
||||
Team Workspaces provide a simplified OpenClaw Lite collaboration flow: choose a role template, create the Team, and describe the goal in the Team chat. The Leader plans the work, coordinates members, collects deliveries, and publishes the final result.
|
||||
|
||||
The current MVP focuses on OpenClaw member orchestration and the Redis Team Bus loop:
|
||||
- fixed Leader-mediated collaboration, without per-member runtime or resource-preset setup
|
||||
- built-in templates for focused delivery, product discovery, and software engineering work
|
||||
- Team chat for plans, assignments, progress, reviews, deliveries, and final synthesis
|
||||
- Execution Kanban for the root-task state and current member deliveries
|
||||
|
||||
- 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
|
||||
See the [Team Workspace Quick Guide](./docs/team-workspaces-guide_en.md) for the creation flow, collaboration stages, and result viewing.
|
||||
|
||||
<a id="runtime-integrations"></a>
|
||||
## Runtime Integrations
|
||||
@@ -263,6 +261,7 @@ See the [Developer Guide](./docs/developer-guide.md).
|
||||
## Documentation
|
||||
|
||||
- [User Guide](./docs/use_guide_en.md)
|
||||
- [Team Workspace Quick Guide](./docs/team-workspaces-guide_en.md)
|
||||
- [Deployment Guide](./docs/deployment.md)
|
||||
- [Admin and User Guide](./docs/admin-user-guide.md)
|
||||
- [Agent Control Plane Guide](./docs/agent-control-plane.md)
|
||||
|
||||
+7
-8
@@ -110,16 +110,14 @@ ClawManager 将 AI Agent 实例的运行、治理与运维能力带到 Kubernete
|
||||
<a id="team-workspaces"></a>
|
||||
## Team 工作空间
|
||||
|
||||
Team 工作空间让 ClawManager 从单实例运维扩展到多 Agent 协作编排。用户可以创建一个 Team,指定一个 Leader 和多个成员,由 ClawManager 负责创建成员 Runtime、注入协作配置,并在控制面持续展示任务、事件和结果状态。
|
||||
Team 工作空间提供简化的 OpenClaw Lite 协作流程:选择角色模板、创建 Team,然后在团队群聊中描述目标即可。Leader 会负责制定计划、协调成员、收集交付并输出最终结果。
|
||||
|
||||
当前 MVP 聚焦 OpenClaw 成员编排与 Redis Team Bus 闭环:
|
||||
- 固定为 Leader 中介协作,无需逐个配置成员运行时或资源预设
|
||||
- 内置交付、产品探索和软件工程等成员模板
|
||||
- 团队群聊展示计划、派发、进度、验收、交付和最终汇总
|
||||
- Execution Kanban 展示总任务状态及当前成员交付
|
||||
|
||||
- 一键创建 Team,并校验 Leader / 成员 roster
|
||||
- 为成员 Runtime Pod 注入 Team 角色、成员 ID、控制面地址和共享目录配置
|
||||
- 通过受控环境变量和 Secret 引用注入 Redis inbox、events、presence 与 DLQ key
|
||||
- 将共享 PVC 挂载到 `/team`,用于上下文、产物、快照和任务结果
|
||||
- Team 详情页集中展示 Leader 桌面、团队群聊、成员列表、调试派发、任务进度与事件结果
|
||||
- Team、成员、任务和事件以 DB 为权威状态,Redis 仅作为消息总线与短期 presence 通道
|
||||
参见 [Team 协作快速指南](./docs/team-workspaces-guide.md),了解创建、协作阶段和查看交付结果的流程。
|
||||
|
||||
<a id="runtime-integrations"></a>
|
||||
## Runtime 接入
|
||||
@@ -267,6 +265,7 @@ ClawManager 是一个 Kubernetes 原生平台,包含 React 前端、Go 后端
|
||||
## 文档
|
||||
|
||||
- [用户指南](./docs/use_guide_cn.md)
|
||||
- [Team 协作快速指南](./docs/team-workspaces-guide.md)
|
||||
- [Deployment Guide(英文)](./docs/deployment.md)
|
||||
- [Admin and User Guide(英文)](./docs/admin-user-guide.md)
|
||||
- [Agent Control Plane Guide(英文)](./docs/agent-control-plane.md)
|
||||
|
||||
@@ -173,7 +173,7 @@ func main() {
|
||||
teamHandler := handlers.NewTeamHandler(teamService)
|
||||
workspaceFileHandler := handlers.NewWorkspaceFileHandler(instanceService, workspaceFileService, runtimeWorkspaceFileService)
|
||||
workspaceFileHandler.SetSkillRepository(skillRepo)
|
||||
runtimeAgentHandler := handlers.NewRuntimeAgentHandler(cfg.Runtime, runtimePodRepo, bindingRepo, runtimeEvents)
|
||||
runtimeAgentHandler := handlers.NewRuntimeAgentHandler(cfg.Runtime, runtimePodRepo, bindingRepo, instanceRepo, runtimeEvents)
|
||||
|
||||
// Initialize WebSocket hub and handler
|
||||
wsHub := services.GetHub()
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
CREATE TABLE IF NOT EXISTS team_event_outbox (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
team_id INT NOT NULL,
|
||||
source_event_id VARCHAR(255) NOT NULL,
|
||||
destination VARCHAR(255) NOT NULL,
|
||||
message_id VARCHAR(255) NOT NULL,
|
||||
payload_json LONGTEXT NOT NULL,
|
||||
status VARCHAR(30) NOT NULL DEFAULT 'pending',
|
||||
attempts INT NOT NULL DEFAULT 0,
|
||||
available_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_error TEXT NULL,
|
||||
delivered_at TIMESTAMP NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
|
||||
UNIQUE KEY uk_team_event_outbox_message (team_id, destination, message_id),
|
||||
INDEX idx_team_event_outbox_pending (status, available_at),
|
||||
INDEX idx_team_event_outbox_team (team_id, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,48 @@
|
||||
ALTER TABLE team_tasks
|
||||
ADD COLUMN workflow_state VARCHAR(40) NOT NULL DEFAULT 'open' AFTER status,
|
||||
ADD COLUMN plan_version BIGINT NOT NULL DEFAULT 0 AFTER workflow_state,
|
||||
ADD COLUMN ledger_version BIGINT NOT NULL DEFAULT 0 AFTER plan_version,
|
||||
ADD COLUMN current_phase_id VARCHAR(255) NULL AFTER ledger_version,
|
||||
ADD COLUMN accepted_completion_id VARCHAR(255) NULL AFTER current_phase_id,
|
||||
ADD UNIQUE KEY uk_team_tasks_accepted_completion (team_id, accepted_completion_id),
|
||||
ADD INDEX idx_team_tasks_workflow (team_id, workflow_state, status);
|
||||
|
||||
ALTER TABLE team_work_items
|
||||
ADD COLUMN assignment_id VARCHAR(255) NULL AFTER work_id,
|
||||
ADD COLUMN canonical_work_id VARCHAR(255) NULL AFTER assignment_id,
|
||||
ADD COLUMN phase_id VARCHAR(255) NULL AFTER canonical_work_id,
|
||||
ADD COLUMN revision INT NOT NULL DEFAULT 1 AFTER phase_id,
|
||||
ADD COLUMN required_for_root BOOLEAN NOT NULL DEFAULT TRUE AFTER revision,
|
||||
ADD COLUMN superseded_by VARCHAR(255) NULL AFTER required_for_root,
|
||||
ADD COLUMN review_required BOOLEAN NOT NULL DEFAULT FALSE AFTER superseded_by,
|
||||
ADD COLUMN validated_revision INT NULL AFTER review_required,
|
||||
ADD INDEX idx_team_work_items_assignment (root_task_id, assignment_id, revision),
|
||||
ADD INDEX idx_team_work_items_phase (root_task_id, phase_id, status);
|
||||
|
||||
UPDATE team_work_items
|
||||
SET assignment_id = work_id,
|
||||
canonical_work_id = work_id,
|
||||
phase_id = 'legacy'
|
||||
WHERE assignment_id IS NULL OR canonical_work_id IS NULL OR phase_id IS NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS team_workflow_phases (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
team_id INT NOT NULL,
|
||||
root_task_id INT NOT NULL,
|
||||
phase_id VARCHAR(255) NOT NULL,
|
||||
plan_version BIGINT NOT NULL DEFAULT 1,
|
||||
sequence_no INT NOT NULL DEFAULT 0,
|
||||
status VARCHAR(40) NOT NULL DEFAULT 'planned',
|
||||
required_for_root BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
decision_required BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
depends_on_json LONGTEXT NULL,
|
||||
next_phase_id VARCHAR(255) NULL,
|
||||
completion_policy VARCHAR(80) NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
completed_at TIMESTAMP NULL,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (root_task_id) REFERENCES team_tasks(id) ON DELETE CASCADE,
|
||||
UNIQUE KEY uk_team_workflow_phase (root_task_id, phase_id, plan_version),
|
||||
INDEX idx_team_workflow_phase_state (root_task_id, plan_version, status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -117,3 +117,46 @@ func TestMigration035HardensTeamEventProtocol(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigration036AddsReliableTeamEventOutbox(t *testing.T) {
|
||||
raw, err := embeddedMigrations.ReadFile("migrations/036_add_team_event_outbox.sql")
|
||||
if err != nil {
|
||||
t.Fatalf("read migration 036: %v", err)
|
||||
}
|
||||
sql := string(raw)
|
||||
for _, required := range []string{
|
||||
"CREATE TABLE IF NOT EXISTS team_event_outbox",
|
||||
"uk_team_event_outbox_message",
|
||||
"idx_team_event_outbox_pending",
|
||||
"source_event_id",
|
||||
"available_at",
|
||||
} {
|
||||
if !strings.Contains(sql, required) {
|
||||
t.Fatalf("migration 036 must contain %s", required)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigration037AddsTeamWorkflowLedger(t *testing.T) {
|
||||
raw, err := embeddedMigrations.ReadFile("migrations/037_add_team_workflow_ledger.sql")
|
||||
if err != nil {
|
||||
t.Fatalf("read migration 037: %v", err)
|
||||
}
|
||||
sql := string(raw)
|
||||
for _, required := range []string{
|
||||
"workflow_state",
|
||||
"plan_version",
|
||||
"ledger_version",
|
||||
"accepted_completion_id",
|
||||
"assignment_id",
|
||||
"canonical_work_id",
|
||||
"phase_id",
|
||||
"required_for_root",
|
||||
"CREATE TABLE IF NOT EXISTS team_workflow_phases",
|
||||
"uk_team_workflow_phase",
|
||||
} {
|
||||
if !strings.Contains(sql, required) {
|
||||
t.Fatalf("migration 037 must contain %s", required)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,10 +23,11 @@ type runtimeEventPublisher interface {
|
||||
}
|
||||
|
||||
type RuntimeAgentHandler struct {
|
||||
cfg config.RuntimePoolConfig
|
||||
podRepo repository.RuntimePodRepository
|
||||
bindingRepo repository.InstanceRuntimeBindingRepository
|
||||
events runtimeEventPublisher
|
||||
cfg config.RuntimePoolConfig
|
||||
podRepo repository.RuntimePodRepository
|
||||
bindingRepo repository.InstanceRuntimeBindingRepository
|
||||
instanceRepo repository.InstanceRepository
|
||||
events runtimeEventPublisher
|
||||
}
|
||||
|
||||
type runtimeAgentPodIdentity struct {
|
||||
@@ -88,12 +89,13 @@ type runtimeAgentGatewayReport struct {
|
||||
HealthAt *time.Time `json:"health_at,omitempty"`
|
||||
}
|
||||
|
||||
func NewRuntimeAgentHandler(cfg config.RuntimePoolConfig, podRepo repository.RuntimePodRepository, bindingRepo repository.InstanceRuntimeBindingRepository, events runtimeEventPublisher) *RuntimeAgentHandler {
|
||||
func NewRuntimeAgentHandler(cfg config.RuntimePoolConfig, podRepo repository.RuntimePodRepository, bindingRepo repository.InstanceRuntimeBindingRepository, instanceRepo repository.InstanceRepository, events runtimeEventPublisher) *RuntimeAgentHandler {
|
||||
return &RuntimeAgentHandler{
|
||||
cfg: cfg,
|
||||
podRepo: podRepo,
|
||||
bindingRepo: bindingRepo,
|
||||
events: events,
|
||||
cfg: cfg,
|
||||
podRepo: podRepo,
|
||||
bindingRepo: bindingRepo,
|
||||
instanceRepo: instanceRepo,
|
||||
events: events,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,9 +281,9 @@ func (h *RuntimeAgentHandler) ReportGateways(c *gin.Context) {
|
||||
if binding == nil || binding.RuntimePodID != podID || binding.Generation != gateway.Generation {
|
||||
continue
|
||||
}
|
||||
state := strings.TrimSpace(gateway.State)
|
||||
state := strings.ToLower(strings.TrimSpace(gateway.State))
|
||||
switch state {
|
||||
case "running", "healthy":
|
||||
case "running", "ready", "healthy":
|
||||
if err := h.bindingRepo.UpdateRunning(c.Request.Context(), gateway.InstanceID, gateway.Generation, strings.TrimSpace(gateway.GatewayID), gateway.GatewayPort, gateway.GatewayPID); err != nil {
|
||||
utils.HandleError(c, err)
|
||||
return
|
||||
@@ -292,6 +294,10 @@ func (h *RuntimeAgentHandler) ReportGateways(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := h.syncInstanceRuntimeState(c.Request.Context(), gateway, state); err != nil {
|
||||
utils.HandleError(c, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
h.publish(c.Request.Context(), "runtime_pod_gateways_reported", map[string]any{
|
||||
"pod_id": podID,
|
||||
@@ -300,6 +306,37 @@ func (h *RuntimeAgentHandler) ReportGateways(c *gin.Context) {
|
||||
utils.Success(c, http.StatusOK, "Runtime gateway report accepted", nil)
|
||||
}
|
||||
|
||||
func (h *RuntimeAgentHandler) syncInstanceRuntimeState(ctx context.Context, gateway runtimeAgentGatewayReport, state string) error {
|
||||
if h.instanceRepo == nil {
|
||||
return nil
|
||||
}
|
||||
instanceState, message := instanceRuntimeStateFromGatewayReport(state, gateway.ErrorMessage)
|
||||
return h.instanceRepo.UpdateRuntimeState(ctx, gateway.InstanceID, instanceState, gateway.Generation, message)
|
||||
}
|
||||
|
||||
func instanceRuntimeStateFromGatewayReport(state string, errorMessage *string) (string, *string) {
|
||||
normalized := strings.ToLower(strings.TrimSpace(state))
|
||||
switch normalized {
|
||||
case "running", "ready", "healthy":
|
||||
return "running", nil
|
||||
case "error", "failed", "failure", "errored":
|
||||
if errorMessage != nil && strings.TrimSpace(*errorMessage) != "" {
|
||||
msg := strings.TrimSpace(*errorMessage)
|
||||
return "error", &msg
|
||||
}
|
||||
msg := "runtime gateway reported " + normalized
|
||||
return "error", &msg
|
||||
case "stopped", "deleted":
|
||||
return "stopped", nil
|
||||
default:
|
||||
msg := "runtime gateway starting"
|
||||
if errorMessage != nil && strings.TrimSpace(*errorMessage) != "" {
|
||||
msg = strings.TrimSpace(*errorMessage)
|
||||
}
|
||||
return "creating", &msg
|
||||
}
|
||||
}
|
||||
|
||||
func (h *RuntimeAgentHandler) ReportSkills(c *gin.Context) {
|
||||
if !h.requireAgentToken(c) {
|
||||
return
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
func TestRuntimeAgentHandlerRejectsInvalidToken(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
podRepo := &runtimeAgentHandlerPodRepo{}
|
||||
handler := NewRuntimeAgentHandler(config.RuntimePoolConfig{AgentReportToken: "secret"}, podRepo, &runtimeAgentHandlerBindingRepo{}, &runtimeAgentHandlerEvents{})
|
||||
handler := NewRuntimeAgentHandler(config.RuntimePoolConfig{AgentReportToken: "secret"}, podRepo, &runtimeAgentHandlerBindingRepo{}, nil, &runtimeAgentHandlerEvents{})
|
||||
|
||||
router := gin.New()
|
||||
router.POST("/api/v1/runtime-agent/metrics/report", handler.ReportMetrics)
|
||||
@@ -43,7 +43,7 @@ func TestRuntimeAgentHandlerRegisterUsesConfiguredCapacity(t *testing.T) {
|
||||
handler := NewRuntimeAgentHandler(config.RuntimePoolConfig{
|
||||
AgentReportToken: "secret",
|
||||
MaxGatewaysPerPod: 33,
|
||||
}, podRepo, &runtimeAgentHandlerBindingRepo{}, events)
|
||||
}, podRepo, &runtimeAgentHandlerBindingRepo{}, nil, events)
|
||||
|
||||
router := gin.New()
|
||||
router.POST("/api/v1/runtime-agent/register", handler.Register)
|
||||
@@ -94,7 +94,7 @@ func TestRuntimeAgentHandlerHeartbeatUsesConfiguredCapacity(t *testing.T) {
|
||||
handler := NewRuntimeAgentHandler(config.RuntimePoolConfig{
|
||||
AgentReportToken: "secret",
|
||||
MaxGatewaysPerPod: 44,
|
||||
}, podRepo, &runtimeAgentHandlerBindingRepo{}, events)
|
||||
}, podRepo, &runtimeAgentHandlerBindingRepo{}, nil, events)
|
||||
|
||||
router := gin.New()
|
||||
router.POST("/api/v1/runtime-agent/heartbeat", handler.Heartbeat)
|
||||
@@ -133,7 +133,7 @@ func TestRuntimeAgentHandlerMetricsReportUpdatesPodAndPublishesEvent(t *testing.
|
||||
gin.SetMode(gin.TestMode)
|
||||
podRepo := &runtimeAgentHandlerPodRepo{}
|
||||
events := &runtimeAgentHandlerEvents{}
|
||||
handler := NewRuntimeAgentHandler(config.RuntimePoolConfig{AgentReportToken: "secret"}, podRepo, &runtimeAgentHandlerBindingRepo{}, events)
|
||||
handler := NewRuntimeAgentHandler(config.RuntimePoolConfig{AgentReportToken: "secret"}, podRepo, &runtimeAgentHandlerBindingRepo{}, nil, events)
|
||||
|
||||
router := gin.New()
|
||||
router.POST("/api/v1/runtime-agent/metrics/report", handler.ReportMetrics)
|
||||
@@ -186,7 +186,7 @@ func TestRuntimeAgentHandlerGatewayReportOnlyUpdatesCurrentPodBinding(t *testing
|
||||
12: {InstanceID: 12, RuntimePodID: 9, Generation: 3},
|
||||
},
|
||||
}
|
||||
handler := NewRuntimeAgentHandler(config.RuntimePoolConfig{AgentReportToken: "secret"}, &runtimeAgentHandlerPodRepo{}, bindingRepo, &runtimeAgentHandlerEvents{})
|
||||
handler := NewRuntimeAgentHandler(config.RuntimePoolConfig{AgentReportToken: "secret"}, &runtimeAgentHandlerPodRepo{}, bindingRepo, nil, &runtimeAgentHandlerEvents{})
|
||||
|
||||
router := gin.New()
|
||||
router.POST("/api/v1/runtime-agent/gateways/report", handler.ReportGateways)
|
||||
@@ -213,6 +213,53 @@ func TestRuntimeAgentHandlerGatewayReportOnlyUpdatesCurrentPodBinding(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeAgentHandlerGatewayReportSyncsInstanceRuntimeState(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
message := "gateway port 20000 is not listening"
|
||||
bindingRepo := &runtimeAgentHandlerBindingRepo{
|
||||
bindings: map[int]*models.InstanceRuntimeBinding{
|
||||
10: {InstanceID: 10, RuntimePodID: 9, Generation: 2},
|
||||
11: {InstanceID: 11, RuntimePodID: 9, Generation: 2},
|
||||
12: {InstanceID: 12, RuntimePodID: 9, Generation: 2},
|
||||
},
|
||||
}
|
||||
instanceRepo := &runtimeAgentHandlerInstanceRepo{}
|
||||
handler := NewRuntimeAgentHandler(config.RuntimePoolConfig{AgentReportToken: "secret"}, &runtimeAgentHandlerPodRepo{}, bindingRepo, instanceRepo, &runtimeAgentHandlerEvents{})
|
||||
|
||||
router := gin.New()
|
||||
router.POST("/api/v1/runtime-agent/gateways/report", handler.ReportGateways)
|
||||
|
||||
body := `{
|
||||
"pod_id": 9,
|
||||
"gateways": [
|
||||
{"instance_id":10,"gateway_id":"gw-10","gateway_port":20010,"state":"healthy","generation":2},
|
||||
{"instance_id":11,"gateway_id":"gw-11","gateway_port":20011,"state":"error","generation":2,"error_message":"` + message + `"},
|
||||
{"instance_id":12,"gateway_id":"gw-12","gateway_port":20012,"state":"ready","generation":2}
|
||||
]
|
||||
}`
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/runtime-agent/gateways/report", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-ClawManager-Agent-Token", "secret")
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s, want 200", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := instanceRepo.statusByID[10]; got != "running" {
|
||||
t.Fatalf("healthy gateway synced instance status = %q, want running", got)
|
||||
}
|
||||
if got := instanceRepo.statusByID[11]; got != "error" {
|
||||
t.Fatalf("error gateway synced instance status = %q, want error", got)
|
||||
}
|
||||
if got := instanceRepo.statusByID[12]; got != "running" {
|
||||
t.Fatalf("ready gateway synced instance status = %q, want running", got)
|
||||
}
|
||||
if instanceRepo.messageByID[11] == nil || *instanceRepo.messageByID[11] != message {
|
||||
t.Fatalf("error message = %#v, want %q", instanceRepo.messageByID[11], message)
|
||||
}
|
||||
}
|
||||
|
||||
type runtimeAgentHandlerPodRepo struct {
|
||||
updatedPodID int64
|
||||
lastMetrics repository.RuntimePodMetricsUpdate
|
||||
@@ -330,6 +377,96 @@ func (r *runtimeAgentHandlerBindingRepo) DeleteByInstanceIDAndReleaseSlot(ctx co
|
||||
return nil
|
||||
}
|
||||
|
||||
type runtimeAgentHandlerInstanceRepo struct {
|
||||
statusByID map[int]string
|
||||
generationByID map[int]int
|
||||
messageByID map[int]*string
|
||||
}
|
||||
|
||||
func (r *runtimeAgentHandlerInstanceRepo) Create(instance *models.Instance) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *runtimeAgentHandlerInstanceRepo) GetByID(id int) (*models.Instance, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *runtimeAgentHandlerInstanceRepo) GetByAccessToken(accessToken string) (*models.Instance, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *runtimeAgentHandlerInstanceRepo) GetByAgentBootstrapToken(bootstrapToken string) (*models.Instance, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *runtimeAgentHandlerInstanceRepo) GetAll(offset, limit int) ([]models.Instance, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *runtimeAgentHandlerInstanceRepo) CountAll() (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (r *runtimeAgentHandlerInstanceRepo) GetByUserID(userID int, offset, limit int) ([]models.Instance, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *runtimeAgentHandlerInstanceRepo) CountByUserID(userID int) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (r *runtimeAgentHandlerInstanceRepo) CountActiveByMode(ctx context.Context, mode string) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (r *runtimeAgentHandlerInstanceRepo) ExistsByUserIDAndName(userID int, name string) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (r *runtimeAgentHandlerInstanceRepo) GetAllRunning() ([]models.Instance, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *runtimeAgentHandlerInstanceRepo) GetV2DesiredRunning(ctx context.Context, limit int) ([]models.Instance, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *runtimeAgentHandlerInstanceRepo) GetV2Creating(ctx context.Context, limit int) ([]models.Instance, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *runtimeAgentHandlerInstanceRepo) UpdateRuntimeState(ctx context.Context, id int, status string, generation int, message *string) error {
|
||||
if r.statusByID == nil {
|
||||
r.statusByID = map[int]string{}
|
||||
}
|
||||
if r.generationByID == nil {
|
||||
r.generationByID = map[int]int{}
|
||||
}
|
||||
if r.messageByID == nil {
|
||||
r.messageByID = map[int]*string{}
|
||||
}
|
||||
r.statusByID[id] = status
|
||||
r.generationByID[id] = generation
|
||||
r.messageByID[id] = message
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *runtimeAgentHandlerInstanceRepo) SetWorkspacePath(ctx context.Context, id int, workspacePath string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *runtimeAgentHandlerInstanceRepo) UpdateWorkspaceUsage(ctx context.Context, id int, usageBytes int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *runtimeAgentHandlerInstanceRepo) Update(instance *models.Instance) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *runtimeAgentHandlerInstanceRepo) Delete(id int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type runtimeAgentHandlerEvents struct {
|
||||
lastType string
|
||||
lastPayload any
|
||||
|
||||
@@ -86,21 +86,26 @@ func (TeamMember) TableName() string {
|
||||
}
|
||||
|
||||
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"`
|
||||
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"`
|
||||
WorkflowState string `db:"workflow_state" json:"workflow_state"`
|
||||
PlanVersion int64 `db:"plan_version" json:"plan_version"`
|
||||
LedgerVersion int64 `db:"ledger_version" json:"ledger_version"`
|
||||
CurrentPhaseID *string `db:"current_phase_id" json:"current_phase_id,omitempty"`
|
||||
AcceptedCompletionID *string `db:"accepted_completion_id" json:"accepted_completion_id,omitempty"`
|
||||
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 {
|
||||
@@ -128,22 +133,72 @@ func (TeamEvent) TableName() string {
|
||||
}
|
||||
|
||||
type TeamWorkItem struct {
|
||||
ID int `db:"id,primarykey,autoincrement" json:"id"`
|
||||
TeamID int `db:"team_id" json:"team_id"`
|
||||
RootTaskID int `db:"root_task_id" json:"root_task_id"`
|
||||
WorkID string `db:"work_id" json:"work_id"`
|
||||
AssignmentID *string `db:"assignment_id" json:"assignment_id,omitempty"`
|
||||
CanonicalWorkID *string `db:"canonical_work_id" json:"canonical_work_id,omitempty"`
|
||||
PhaseID *string `db:"phase_id" json:"phase_id,omitempty"`
|
||||
Revision int `db:"revision" json:"revision"`
|
||||
RequiredForRoot bool `db:"required_for_root" json:"required_for_root"`
|
||||
SupersededBy *string `db:"superseded_by" json:"superseded_by,omitempty"`
|
||||
ReviewRequired bool `db:"review_required" json:"review_required"`
|
||||
ValidatedRevision *int `db:"validated_revision" json:"validated_revision,omitempty"`
|
||||
OwnerMemberID *int `db:"owner_member_id" json:"owner_member_id,omitempty"`
|
||||
Title string `db:"title" json:"title"`
|
||||
Status string `db:"status" json:"status"`
|
||||
DependsOnJSON *string `db:"depends_on_json" json:"-"`
|
||||
ResultJSON *string `db:"result_json" json:"-"`
|
||||
ArtifactRefsJSON *string `db:"artifact_refs_json" json:"-"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
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"`
|
||||
}
|
||||
|
||||
type TeamWorkflowPhase struct {
|
||||
ID int `db:"id,primarykey,autoincrement" json:"id"`
|
||||
TeamID int `db:"team_id" json:"team_id"`
|
||||
RootTaskID int `db:"root_task_id" json:"root_task_id"`
|
||||
WorkID string `db:"work_id" json:"work_id"`
|
||||
OwnerMemberID *int `db:"owner_member_id" json:"owner_member_id,omitempty"`
|
||||
Title string `db:"title" json:"title"`
|
||||
PhaseID string `db:"phase_id" json:"phase_id"`
|
||||
PlanVersion int64 `db:"plan_version" json:"plan_version"`
|
||||
SequenceNo int `db:"sequence_no" json:"sequence_no"`
|
||||
Status string `db:"status" json:"status"`
|
||||
RequiredForRoot bool `db:"required_for_root" json:"required_for_root"`
|
||||
DecisionRequired bool `db:"decision_required" json:"decision_required"`
|
||||
DependsOnJSON *string `db:"depends_on_json" json:"-"`
|
||||
ResultJSON *string `db:"result_json" json:"-"`
|
||||
ArtifactRefsJSON *string `db:"artifact_refs_json" json:"-"`
|
||||
NextPhaseID *string `db:"next_phase_id" json:"next_phase_id,omitempty"`
|
||||
CompletionPolicy *string `db:"completion_policy" json:"completion_policy,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
StartedAt *time.Time `db:"started_at" json:"started_at,omitempty"`
|
||||
FinishedAt *time.Time `db:"finished_at" json:"finished_at,omitempty"`
|
||||
CompletedAt *time.Time `db:"completed_at" json:"completed_at,omitempty"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (TeamWorkflowPhase) TableName() string {
|
||||
return "team_workflow_phases"
|
||||
}
|
||||
|
||||
type TeamEventOutbox struct {
|
||||
ID int `db:"id,primarykey,autoincrement" json:"id"`
|
||||
TeamID int `db:"team_id" json:"team_id"`
|
||||
SourceEventID string `db:"source_event_id" json:"source_event_id"`
|
||||
Destination string `db:"destination" json:"destination"`
|
||||
MessageID string `db:"message_id" json:"message_id"`
|
||||
PayloadJSON string `db:"payload_json" json:"-"`
|
||||
Status string `db:"status" json:"status"`
|
||||
Attempts int `db:"attempts" json:"attempts"`
|
||||
AvailableAt time.Time `db:"available_at" json:"available_at"`
|
||||
LastError *string `db:"last_error" json:"last_error,omitempty"`
|
||||
DeliveredAt *time.Time `db:"delivered_at" json:"delivered_at,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (TeamEventOutbox) TableName() string {
|
||||
return "team_event_outbox"
|
||||
}
|
||||
|
||||
func (TeamWorkItem) TableName() string {
|
||||
return "team_work_items"
|
||||
}
|
||||
|
||||
@@ -45,8 +45,17 @@ type TeamRepository interface {
|
||||
ListEventsBeforeID(teamID, beforeID, limit int) ([]models.TeamEvent, error)
|
||||
|
||||
UpsertWorkItem(item *models.TeamWorkItem) error
|
||||
InvalidateWorkItemReview(workItemID int, updatedAt time.Time) error
|
||||
ListWorkItemsByRootTaskID(rootTaskID int) ([]models.TeamWorkItem, error)
|
||||
ListWorkItemsByTeamID(teamID int, limit int) ([]models.TeamWorkItem, error)
|
||||
UpsertWorkflowPhase(phase *models.TeamWorkflowPhase) error
|
||||
ListWorkflowPhasesByRootTaskID(rootTaskID int) ([]models.TeamWorkflowPhase, error)
|
||||
AcceptRootCompletion(task *models.TeamTask, expectedLedgerVersion int64, event *models.TeamEvent, outbox *models.TeamEventOutbox) (bool, error)
|
||||
ConfirmWorkItemResult(item *models.TeamWorkItem, event *models.TeamEvent, outbox *models.TeamEventOutbox) error
|
||||
CreateEventOutbox(outbox *models.TeamEventOutbox) error
|
||||
ListPendingEventOutbox(now time.Time, limit int) ([]models.TeamEventOutbox, error)
|
||||
MarkEventOutboxDelivered(id int, deliveredAt time.Time) error
|
||||
MarkEventOutboxFailed(id int, availableAt time.Time, cause string) error
|
||||
}
|
||||
|
||||
type teamRepository struct {
|
||||
@@ -304,6 +313,252 @@ func (r *teamRepository) CreateEvent(event *models.TeamEvent) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *teamRepository) ConfirmWorkItemResult(item *models.TeamWorkItem, event *models.TeamEvent, outbox *models.TeamEventOutbox) error {
|
||||
if item == nil || event == nil || outbox == nil {
|
||||
return fmt.Errorf("work item, confirmation event and outbox are required")
|
||||
}
|
||||
return r.sess.Tx(func(sess db.Session) error {
|
||||
txRepo := &teamRepository{sess: sess}
|
||||
if err := txRepo.UpsertWorkItem(item); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := txRepo.CreateEvent(event); err != nil && !errors.Is(err, ErrDuplicateTeamEvent) {
|
||||
return err
|
||||
}
|
||||
if outbox.Status == "" {
|
||||
outbox.Status = "pending"
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if outbox.AvailableAt.IsZero() {
|
||||
outbox.AvailableAt = now
|
||||
}
|
||||
if outbox.CreatedAt.IsZero() {
|
||||
outbox.CreatedAt = now
|
||||
}
|
||||
if outbox.UpdatedAt.IsZero() {
|
||||
outbox.UpdatedAt = now
|
||||
}
|
||||
res, err := sess.Collection("team_event_outbox").Insert(outbox)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "uk_team_event_outbox_message") {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("failed to create team event outbox: %w", err)
|
||||
}
|
||||
if id, ok := res.ID().(int64); ok {
|
||||
outbox.ID = int(id)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (r *teamRepository) UpsertWorkflowPhase(phase *models.TeamWorkflowPhase) error {
|
||||
if phase == nil || phase.RootTaskID <= 0 || strings.TrimSpace(phase.PhaseID) == "" {
|
||||
return fmt.Errorf("team workflow phase is required")
|
||||
}
|
||||
if phase.PlanVersion <= 0 {
|
||||
phase.PlanVersion = 1
|
||||
}
|
||||
var existing models.TeamWorkflowPhase
|
||||
err := r.sess.Collection("team_workflow_phases").Find(db.Cond{
|
||||
"root_task_id": phase.RootTaskID,
|
||||
"phase_id": phase.PhaseID,
|
||||
"plan_version": phase.PlanVersion,
|
||||
}).One(&existing)
|
||||
if err != nil && err != db.ErrNoMoreRows {
|
||||
return fmt.Errorf("failed to get team workflow phase: %w", err)
|
||||
}
|
||||
if err == nil {
|
||||
phase.ID = existing.ID
|
||||
phase.CreatedAt = existing.CreatedAt
|
||||
if phase.DependsOnJSON == nil {
|
||||
phase.DependsOnJSON = existing.DependsOnJSON
|
||||
}
|
||||
if phase.NextPhaseID == nil {
|
||||
phase.NextPhaseID = existing.NextPhaseID
|
||||
}
|
||||
if phase.CompletionPolicy == nil {
|
||||
phase.CompletionPolicy = existing.CompletionPolicy
|
||||
}
|
||||
if phase.CompletedAt == nil {
|
||||
phase.CompletedAt = existing.CompletedAt
|
||||
}
|
||||
if phase.UpdatedAt.IsZero() {
|
||||
phase.UpdatedAt = time.Now().UTC()
|
||||
}
|
||||
if err := r.sess.Collection("team_workflow_phases").Find(db.Cond{"id": existing.ID}).Update(phase); err != nil {
|
||||
return fmt.Errorf("failed to update team workflow phase: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
ensureTimestamps(&phase.CreatedAt, &phase.UpdatedAt)
|
||||
res, err := r.sess.Collection("team_workflow_phases").Insert(phase)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create team workflow phase: %w", err)
|
||||
}
|
||||
if id, ok := res.ID().(int64); ok {
|
||||
phase.ID = int(id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *teamRepository) ListWorkflowPhasesByRootTaskID(rootTaskID int) ([]models.TeamWorkflowPhase, error) {
|
||||
var phases []models.TeamWorkflowPhase
|
||||
if err := r.sess.Collection("team_workflow_phases").Find(db.Cond{"root_task_id": rootTaskID}).OrderBy("plan_version", "sequence_no", "id").All(&phases); err != nil {
|
||||
return nil, fmt.Errorf("failed to list team workflow phases: %w", err)
|
||||
}
|
||||
return phases, nil
|
||||
}
|
||||
|
||||
func (r *teamRepository) AcceptRootCompletion(task *models.TeamTask, expectedLedgerVersion int64, event *models.TeamEvent, outbox *models.TeamEventOutbox) (bool, error) {
|
||||
if task == nil || event == nil || outbox == nil || task.ID <= 0 {
|
||||
return false, fmt.Errorf("task, accepted completion event and outbox are required")
|
||||
}
|
||||
accepted := false
|
||||
err := r.sess.Tx(func(sess db.Session) error {
|
||||
result, err := sess.SQL().Exec(`
|
||||
UPDATE team_tasks
|
||||
SET status = ?, workflow_state = ?, plan_version = ?, ledger_version = ?, current_phase_id = ?,
|
||||
accepted_completion_id = ?, result_json = ?, error_message = ?, finished_at = ?, updated_at = ?
|
||||
WHERE id = ? AND ledger_version = ? AND accepted_completion_id IS NULL
|
||||
AND status NOT IN (?, ?, ?)
|
||||
`,
|
||||
task.Status,
|
||||
task.WorkflowState,
|
||||
task.PlanVersion,
|
||||
task.LedgerVersion,
|
||||
task.CurrentPhaseID,
|
||||
task.AcceptedCompletionID,
|
||||
task.ResultJSON,
|
||||
task.ErrorMessage,
|
||||
task.FinishedAt,
|
||||
task.UpdatedAt,
|
||||
task.ID,
|
||||
expectedLedgerVersion,
|
||||
models.TeamTaskStatusSucceeded,
|
||||
models.TeamTaskStatusFailed,
|
||||
models.TeamTaskStatusStale,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to atomically accept team completion: %w", err)
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to inspect accepted team completion: %w", err)
|
||||
}
|
||||
if affected != 1 {
|
||||
return nil
|
||||
}
|
||||
accepted = true
|
||||
if _, err := sess.SQL().Exec(`
|
||||
UPDATE team_workflow_phases
|
||||
SET status = 'completed', completed_at = ?, updated_at = ?
|
||||
WHERE root_task_id = ? AND (? = 0 OR plan_version = ?)
|
||||
AND status NOT IN ('cancelled', 'superseded')
|
||||
`, task.UpdatedAt, task.UpdatedAt, task.ID, task.PlanVersion, task.PlanVersion); err != nil {
|
||||
return fmt.Errorf("failed to complete team workflow phases: %w", err)
|
||||
}
|
||||
txRepo := &teamRepository{sess: sess}
|
||||
if err := txRepo.CreateEvent(event); err != nil && !errors.Is(err, ErrDuplicateTeamEvent) {
|
||||
return err
|
||||
}
|
||||
if outbox.Status == "" {
|
||||
outbox.Status = "pending"
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if outbox.AvailableAt.IsZero() {
|
||||
outbox.AvailableAt = now
|
||||
}
|
||||
if outbox.CreatedAt.IsZero() {
|
||||
outbox.CreatedAt = now
|
||||
}
|
||||
if outbox.UpdatedAt.IsZero() {
|
||||
outbox.UpdatedAt = now
|
||||
}
|
||||
insertResult, err := sess.Collection("team_event_outbox").Insert(outbox)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "uk_team_event_outbox_message") {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("failed to create completion acknowledgement outbox: %w", err)
|
||||
}
|
||||
if id, ok := insertResult.ID().(int64); ok {
|
||||
outbox.ID = int(id)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return accepted, err
|
||||
}
|
||||
|
||||
func (r *teamRepository) ListPendingEventOutbox(now time.Time, limit int) ([]models.TeamEventOutbox, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
var rows []models.TeamEventOutbox
|
||||
if err := r.sess.Collection("team_event_outbox").Find(db.Cond{
|
||||
"status": "pending",
|
||||
"available_at <=": now,
|
||||
}).OrderBy("available_at", "id").Limit(limit).All(&rows); err != nil {
|
||||
return nil, fmt.Errorf("failed to list pending team event outbox: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (r *teamRepository) CreateEventOutbox(outbox *models.TeamEventOutbox) error {
|
||||
if outbox == nil {
|
||||
return fmt.Errorf("team event outbox is required")
|
||||
}
|
||||
if outbox.Status == "" {
|
||||
outbox.Status = "pending"
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if outbox.AvailableAt.IsZero() {
|
||||
outbox.AvailableAt = now
|
||||
}
|
||||
if outbox.CreatedAt.IsZero() {
|
||||
outbox.CreatedAt = now
|
||||
}
|
||||
if outbox.UpdatedAt.IsZero() {
|
||||
outbox.UpdatedAt = now
|
||||
}
|
||||
res, err := r.sess.Collection("team_event_outbox").Insert(outbox)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "uk_team_event_outbox_message") {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("failed to create team event outbox: %w", err)
|
||||
}
|
||||
if id, ok := res.ID().(int64); ok {
|
||||
outbox.ID = int(id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *teamRepository) MarkEventOutboxDelivered(id int, deliveredAt time.Time) error {
|
||||
if id <= 0 {
|
||||
return nil
|
||||
}
|
||||
return r.sess.Collection("team_event_outbox").Find(db.Cond{"id": id}).Update(map[string]interface{}{
|
||||
"status": "delivered",
|
||||
"delivered_at": deliveredAt,
|
||||
"last_error": nil,
|
||||
"updated_at": deliveredAt,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *teamRepository) MarkEventOutboxFailed(id int, availableAt time.Time, cause string) error {
|
||||
if id <= 0 {
|
||||
return nil
|
||||
}
|
||||
return r.sess.Collection("team_event_outbox").Find(db.Cond{"id": id}).Update(map[string]interface{}{
|
||||
"status": "pending",
|
||||
"attempts": db.Raw("attempts + 1"),
|
||||
"available_at": availableAt,
|
||||
"last_error": cause,
|
||||
"updated_at": time.Now().UTC(),
|
||||
})
|
||||
}
|
||||
|
||||
func (r *teamRepository) EventExistsByStreamID(teamID int, streamID string) (bool, error) {
|
||||
if streamID == "" {
|
||||
return false, nil
|
||||
@@ -375,8 +630,14 @@ func (r *teamRepository) UpsertWorkItem(item *models.TeamWorkItem) error {
|
||||
if err == nil {
|
||||
item.ID = existing.ID
|
||||
item.CreatedAt = existing.CreatedAt
|
||||
if existing.Status == models.TeamTaskStatusSucceeded || existing.Status == models.TeamTaskStatusFailed {
|
||||
if item.Status != models.TeamTaskStatusSucceeded && item.Status != models.TeamTaskStatusFailed {
|
||||
newRevision := item.Revision > existing.Revision
|
||||
existingTerminal := existing.Status == models.TeamTaskStatusSucceeded || existing.Status == models.TeamTaskStatusFailed || existing.Status == models.TeamTaskStatusStale
|
||||
itemTerminal := item.Status == models.TeamTaskStatusSucceeded || item.Status == models.TeamTaskStatusFailed || item.Status == models.TeamTaskStatusStale
|
||||
reopeningCurrent := !newRevision && existingTerminal && !itemTerminal &&
|
||||
!item.UpdatedAt.IsZero() &&
|
||||
(existing.UpdatedAt.IsZero() || item.UpdatedAt.After(existing.UpdatedAt))
|
||||
if !newRevision && existingTerminal && !reopeningCurrent {
|
||||
if !itemTerminal {
|
||||
item.Status = existing.Status
|
||||
}
|
||||
}
|
||||
@@ -386,16 +647,37 @@ func (r *teamRepository) UpsertWorkItem(item *models.TeamWorkItem) error {
|
||||
if item.StartedAt == nil {
|
||||
item.StartedAt = existing.StartedAt
|
||||
}
|
||||
if item.FinishedAt == nil {
|
||||
if item.FinishedAt == nil && !newRevision && !reopeningCurrent {
|
||||
item.FinishedAt = existing.FinishedAt
|
||||
}
|
||||
if item.DependsOnJSON == nil {
|
||||
item.DependsOnJSON = existing.DependsOnJSON
|
||||
}
|
||||
if item.ResultJSON == nil {
|
||||
if item.AssignmentID == nil {
|
||||
item.AssignmentID = existing.AssignmentID
|
||||
}
|
||||
if item.CanonicalWorkID == nil {
|
||||
item.CanonicalWorkID = existing.CanonicalWorkID
|
||||
}
|
||||
if item.PhaseID == nil {
|
||||
item.PhaseID = existing.PhaseID
|
||||
}
|
||||
if item.Revision <= 0 {
|
||||
item.Revision = existing.Revision
|
||||
}
|
||||
if item.SupersededBy == nil && !reopeningCurrent {
|
||||
item.SupersededBy = existing.SupersededBy
|
||||
}
|
||||
if item.ValidatedRevision == nil && !newRevision && !reopeningCurrent {
|
||||
item.ValidatedRevision = existing.ValidatedRevision
|
||||
}
|
||||
if existing.ReviewRequired && !newRevision && !reopeningCurrent {
|
||||
item.ReviewRequired = true
|
||||
}
|
||||
if item.ResultJSON == nil && !newRevision && !reopeningCurrent {
|
||||
item.ResultJSON = existing.ResultJSON
|
||||
}
|
||||
if item.ArtifactRefsJSON == nil {
|
||||
if item.ArtifactRefsJSON == nil && !newRevision && !reopeningCurrent {
|
||||
item.ArtifactRefsJSON = existing.ArtifactRefsJSON
|
||||
}
|
||||
if item.UpdatedAt.IsZero() {
|
||||
@@ -417,6 +699,23 @@ func (r *teamRepository) UpsertWorkItem(item *models.TeamWorkItem) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *teamRepository) InvalidateWorkItemReview(workItemID int, updatedAt time.Time) error {
|
||||
if workItemID <= 0 {
|
||||
return fmt.Errorf("team work item id is required")
|
||||
}
|
||||
if updatedAt.IsZero() {
|
||||
updatedAt = time.Now().UTC()
|
||||
}
|
||||
if _, err := r.sess.SQL().Exec(`
|
||||
UPDATE team_work_items
|
||||
SET validated_revision = NULL, updated_at = ?
|
||||
WHERE id = ? AND review_required = TRUE AND validated_revision IS NOT NULL
|
||||
`, updatedAt, workItemID); err != nil {
|
||||
return fmt.Errorf("failed to invalidate team work item review: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *teamRepository) ListWorkItemsByRootTaskID(rootTaskID int) ([]models.TeamWorkItem, error) {
|
||||
var items []models.TeamWorkItem
|
||||
if err := r.sess.Collection("team_work_items").Find(db.Cond{"root_task_id": rootTaskID}).OrderBy("id").All(&items); err != nil {
|
||||
|
||||
@@ -30,6 +30,9 @@ const (
|
||||
storageProfileSingle = "single-node"
|
||||
storageProfileLegacyNFS = "legacy-nfs"
|
||||
defaultPVCBindTimeout = 2 * time.Minute
|
||||
// os.FileMode keeps setgid outside the Unix permission-bit range, so
|
||||
// 0o2775 alone does not set it when passed to os.Chmod.
|
||||
teamSharedDirectoryMode = os.FileMode(0o775) | os.ModeSetgid
|
||||
)
|
||||
|
||||
// NewPVCService creates a new PVC service
|
||||
@@ -521,11 +524,11 @@ func (s *PVCService) ensureTeamSharedWorkspaceDirectory(userID, teamID int) erro
|
||||
dir := filepath.Join(root, filepath.FromSlash(TeamSharedWorkspaceRelativePath(userID, teamID)))
|
||||
dirs := append([]string{dir}, teamSharedRuntimeSubdirectories(dir)...)
|
||||
for _, target := range dirs {
|
||||
if err := os.MkdirAll(target, 0o2775); err != nil {
|
||||
if err := os.MkdirAll(target, teamSharedDirectoryMode); err != nil {
|
||||
return fmt.Errorf("failed to create Team shared runtime workspace directory %s: %w", target, err)
|
||||
}
|
||||
_ = os.Chown(target, 1000, 1000)
|
||||
if err := os.Chmod(target, 0o2775); err != nil {
|
||||
if err := os.Chmod(target, teamSharedDirectoryMode); err != nil {
|
||||
return fmt.Errorf("failed to chmod Team shared runtime workspace directory %s: %w", target, err)
|
||||
}
|
||||
}
|
||||
@@ -538,6 +541,8 @@ func teamSharedRuntimeSubdirectories(root string) []string {
|
||||
filepath.Join(root, "inbox"),
|
||||
filepath.Join(root, "results"),
|
||||
filepath.Join(root, "tasks"),
|
||||
filepath.Join(root, ".openclaw-redis-team"),
|
||||
filepath.Join(root, ".openclaw-redis-team", "tasks"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -251,7 +252,7 @@ func TestCreateTeamSharedPVCCreatesWritableRuntimeSubdirectories(t *testing.T) {
|
||||
t.Fatalf("CreateTeamSharedPVC returned error: %v", err)
|
||||
}
|
||||
|
||||
for _, name := range []string{"status", "inbox", "results", "tasks"} {
|
||||
for _, name := range []string{"status", "inbox", "results", "tasks", ".openclaw-redis-team", filepath.Join(".openclaw-redis-team", "tasks")} {
|
||||
dir := filepath.Join(workspaceRoot, "teams", "user-1", "team-28-shared", name)
|
||||
info, err := os.Stat(dir)
|
||||
if err != nil {
|
||||
@@ -260,6 +261,12 @@ func TestCreateTeamSharedPVCCreatesWritableRuntimeSubdirectories(t *testing.T) {
|
||||
if !info.IsDir() {
|
||||
t.Fatalf("expected %s to be a directory", dir)
|
||||
}
|
||||
if runtime.GOOS != "windows" {
|
||||
mode := info.Mode()
|
||||
if mode&os.ModeSetgid == 0 || mode.Perm()&0o020 == 0 {
|
||||
t.Fatalf("expected %s to preserve shared group-write/setgid permissions, got mode %v", dir, mode)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -507,6 +507,9 @@ func (s *RuntimeScheduler) reconcile(ctx context.Context) error {
|
||||
continue
|
||||
}
|
||||
if binding != nil {
|
||||
if err := s.syncInstanceStateFromBinding(ctx, instance, binding); err != nil {
|
||||
errs = append(errs, fmt.Errorf("sync creating instance %d from binding: %w", instance.ID, err))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := s.assignInstance(ctx, instance); err != nil {
|
||||
@@ -536,6 +539,9 @@ func (s *RuntimeScheduler) reconcile(ctx context.Context) error {
|
||||
continue
|
||||
}
|
||||
if binding != nil {
|
||||
if err := s.syncInstanceStateFromBinding(ctx, instance, binding); err != nil {
|
||||
errs = append(errs, fmt.Errorf("sync desired instance %d from binding: %w", instance.ID, err))
|
||||
}
|
||||
continue
|
||||
}
|
||||
binding, err = s.bindingRepo.GetByInstanceID(ctx, instance.ID)
|
||||
@@ -544,6 +550,9 @@ func (s *RuntimeScheduler) reconcile(ctx context.Context) error {
|
||||
continue
|
||||
}
|
||||
if binding != nil {
|
||||
if err := s.syncInstanceStateFromBinding(ctx, instance, binding); err != nil {
|
||||
errs = append(errs, fmt.Errorf("sync desired instance %d from binding: %w", instance.ID, err))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if assignErr := s.assignInstance(ctx, instance); assignErr != nil {
|
||||
@@ -558,6 +567,33 @@ func (s *RuntimeScheduler) reconcile(ctx context.Context) error {
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
func (s *RuntimeScheduler) syncInstanceStateFromBinding(ctx context.Context, instance models.Instance, binding *models.InstanceRuntimeBinding) error {
|
||||
if s == nil || s.instanceRepo == nil || binding == nil {
|
||||
return nil
|
||||
}
|
||||
state := strings.ToLower(strings.TrimSpace(binding.State))
|
||||
switch state {
|
||||
case "running", "ready", "healthy":
|
||||
if strings.EqualFold(strings.TrimSpace(instance.Status), "running") && instance.RuntimeErrorMessage == nil {
|
||||
return nil
|
||||
}
|
||||
return s.instanceRepo.UpdateRuntimeState(ctx, instance.ID, "running", maxInt(instance.RuntimeGeneration, binding.Generation), nil)
|
||||
case "error", "failed":
|
||||
message := "runtime gateway failed"
|
||||
if binding.ErrorMessage != nil && strings.TrimSpace(*binding.ErrorMessage) != "" {
|
||||
message = strings.TrimSpace(*binding.ErrorMessage)
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(instance.Status), "error") &&
|
||||
instance.RuntimeErrorMessage != nil &&
|
||||
strings.TrimSpace(*instance.RuntimeErrorMessage) == message {
|
||||
return nil
|
||||
}
|
||||
return s.instanceRepo.UpdateRuntimeState(ctx, instance.ID, "error", maxInt(instance.RuntimeGeneration, binding.Generation), &message)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *RuntimeScheduler) failoverStalePods(ctx context.Context) error {
|
||||
if s.heartbeatTimeout <= 0 {
|
||||
return nil
|
||||
@@ -745,6 +781,7 @@ func (s *RuntimeScheduler) createGatewayOnPod(ctx context.Context, instance mode
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
gatewayState := normalizeRuntimeGatewayCreateState(resp.Status)
|
||||
binding := &models.InstanceRuntimeBinding{
|
||||
InstanceID: instance.ID,
|
||||
RuntimePodID: pod.ID,
|
||||
@@ -753,9 +790,11 @@ func (s *RuntimeScheduler) createGatewayOnPod(ctx context.Context, instance mode
|
||||
GatewayPort: resp.Port,
|
||||
GatewayPID: resp.PID,
|
||||
WorkspacePath: workspacePath,
|
||||
State: "running",
|
||||
State: gatewayState,
|
||||
Generation: instance.RuntimeGeneration,
|
||||
LastHealthAt: &now,
|
||||
}
|
||||
if gatewayState == "running" {
|
||||
binding.LastHealthAt = &now
|
||||
}
|
||||
if err := s.bindingRepo.Create(ctx, binding); err != nil {
|
||||
return s.cleanupGatewayAfterAssignFailure(ctx, endpoint, instance.ID, resp.GatewayID, false, err)
|
||||
@@ -763,16 +802,29 @@ func (s *RuntimeScheduler) createGatewayOnPod(ctx context.Context, instance mode
|
||||
if err := s.instanceRepo.SetWorkspacePath(ctx, instance.ID, workspacePath); err != nil {
|
||||
return s.cleanupGatewayAfterAssignFailure(ctx, endpoint, instance.ID, resp.GatewayID, true, err)
|
||||
}
|
||||
if err := s.instanceRepo.UpdateRuntimeState(ctx, instance.ID, "running", instance.RuntimeGeneration, nil); err != nil {
|
||||
instanceState := "creating"
|
||||
var stateMessage *string
|
||||
if gatewayState == "running" {
|
||||
instanceState = "running"
|
||||
} else {
|
||||
message := "runtime gateway starting"
|
||||
stateMessage = &message
|
||||
}
|
||||
if err := s.instanceRepo.UpdateRuntimeState(ctx, instance.ID, instanceState, instance.RuntimeGeneration, stateMessage); err != nil {
|
||||
return s.cleanupGatewayAfterAssignFailure(ctx, endpoint, instance.ID, resp.GatewayID, true, err)
|
||||
}
|
||||
if s.events != nil {
|
||||
if err := s.events.Publish(ctx, "runtime.instance.running", map[string]any{
|
||||
eventType := "runtime.instance.starting"
|
||||
if gatewayState == "running" {
|
||||
eventType = "runtime.instance.running"
|
||||
}
|
||||
if err := s.events.Publish(ctx, eventType, map[string]any{
|
||||
"instance_id": instance.ID,
|
||||
"runtime_type": runtimeType,
|
||||
"runtime_pod_id": pod.ID,
|
||||
"gateway_id": resp.GatewayID,
|
||||
"gateway_port": resp.Port,
|
||||
"gateway_state": gatewayState,
|
||||
"workspace_path": workspacePath,
|
||||
"generation": instance.RuntimeGeneration,
|
||||
}); err != nil {
|
||||
@@ -782,6 +834,15 @@ func (s *RuntimeScheduler) createGatewayOnPod(ctx context.Context, instance mode
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeRuntimeGatewayCreateState(status string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(status)) {
|
||||
case "running", "ready", "healthy":
|
||||
return "running"
|
||||
default:
|
||||
return "starting"
|
||||
}
|
||||
}
|
||||
|
||||
func runtimeGatewayLinuxIDs(instanceID int, environment map[string]string) (int, int) {
|
||||
linuxID := RuntimeLinuxID(instanceID)
|
||||
if !strings.EqualFold(strings.TrimSpace(environment["CLAWMANAGER_TEAM_ENABLED"]), "true") {
|
||||
@@ -878,3 +939,10 @@ func minInt(a, b int) int {
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func maxInt(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
@@ -113,6 +113,9 @@ func TestRuntimeSchedulerAssignsCreatingInstanceToReadyPod(t *testing.T) {
|
||||
if got := len(events.published); got != 1 {
|
||||
t.Fatalf("published events = %d, want 1", got)
|
||||
}
|
||||
if events.published[0].eventType != "runtime.instance.running" {
|
||||
t.Fatalf("published event = %q, want runtime.instance.running", events.published[0].eventType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeSchedulerPassesGatewayEnvironmentToRuntimeAgent(t *testing.T) {
|
||||
@@ -197,6 +200,10 @@ func TestRuntimeSchedulerPassesGatewayEnvironmentToRuntimeAgent(t *testing.T) {
|
||||
if env["CLAWMANAGER_LLM_MODEL"] != `["auto","gpt-5.5"]` {
|
||||
t.Fatalf("CLAWMANAGER_LLM_MODEL = %q", env["CLAWMANAGER_LLM_MODEL"])
|
||||
}
|
||||
state := instanceRepo.runtimeStates[68]
|
||||
if state.status != "creating" || state.generation != 4 || state.message == nil || !strings.Contains(*state.message, "gateway starting") {
|
||||
t.Fatalf("runtime state after starting gateway = %+v, want creating with startup message", state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeSchedulerUsesTeamSharedGIDForTeamLiteGateway(t *testing.T) {
|
||||
@@ -739,6 +746,49 @@ func TestRuntimeSchedulerSkipsCreatingInstanceWithExistingBinding(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeSchedulerSyncsCreatingInstanceFromRunningBinding(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
workspacePath := "/workspaces/openclaw/user-1/instance-25"
|
||||
instanceRepo := newFakeRuntimeInstanceRepo()
|
||||
instanceRepo.creating = []models.Instance{{
|
||||
ID: 25,
|
||||
UserID: 1,
|
||||
Type: RuntimeTypeOpenClaw,
|
||||
RuntimeType: RuntimeBackendGateway,
|
||||
InstanceMode: InstanceModeLite,
|
||||
Status: "creating",
|
||||
WorkspacePath: &workspacePath,
|
||||
RuntimeGeneration: 2,
|
||||
}}
|
||||
bindingRepo := newFakeRuntimeBindingRepo()
|
||||
bindingRepo.bindings[25] = &models.InstanceRuntimeBinding{
|
||||
InstanceID: 25,
|
||||
RuntimeType: RuntimeTypeOpenClaw,
|
||||
State: "running",
|
||||
Generation: 2,
|
||||
}
|
||||
scheduler := NewRuntimeScheduler(
|
||||
instanceRepo,
|
||||
&fakeRuntimePodRepo{},
|
||||
bindingRepo,
|
||||
&fakeRuntimeRolloutRepo{},
|
||||
&fakeRuntimeAgentClient{},
|
||||
NewRuntimeEventService(nil),
|
||||
nil,
|
||||
&fakeRuntimeDeploymentService{},
|
||||
time.Second,
|
||||
)
|
||||
|
||||
if err := scheduler.reconcile(ctx); err != nil {
|
||||
t.Fatalf("reconcile returned error: %v", err)
|
||||
}
|
||||
|
||||
state := instanceRepo.runtimeStates[25]
|
||||
if state.status != "running" || state.generation != 2 || state.message != nil {
|
||||
t.Fatalf("expected instance to sync to running from binding, got %+v", state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeSchedulerCleansUpGatewayAndReleasesSlotWhenBindingCreateFails(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
endpoint := "http://agent.runtime"
|
||||
|
||||
@@ -84,6 +84,15 @@ func (b *redisBus) SetNX(ctx context.Context, key, value string, ttl time.Durati
|
||||
return reply == "OK", nil
|
||||
}
|
||||
|
||||
func (b *redisBus) Set(ctx context.Context, key, value string, ttl time.Duration) error {
|
||||
args := []string{"SET", key, value}
|
||||
if ttl > 0 {
|
||||
args = append(args, "PX", fmt.Sprintf("%d", ttl.Milliseconds()))
|
||||
}
|
||||
_, err := b.do(ctx, args...)
|
||||
return err
|
||||
}
|
||||
|
||||
func (b *redisBus) Del(ctx context.Context, key string) error {
|
||||
_, err := b.do(ctx, "DEL", key)
|
||||
return err
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,7 @@ Administrators use ClawManager to:
|
||||
End users use ClawManager to:
|
||||
|
||||
- create or access OpenClaw workspaces
|
||||
- create a template-based Team and describe a shared goal in the Team chat
|
||||
- open workspaces through the portal experience
|
||||
- inspect runtime status, agent signals, and recent command activity
|
||||
- attach or remove skills from an instance when permitted
|
||||
@@ -28,9 +29,11 @@ End users use ClawManager to:
|
||||
- [Agent Control Plane Guide](./agent-control-plane.md)
|
||||
- [Resource Management Guide](./resource-management.md)
|
||||
- [Security / Skill Scanner Guide](./security-skill-scanner.md)
|
||||
- [Team Workspace Quick Guide](./team-workspaces-guide_en.md)
|
||||
|
||||
## Suggested Walkthrough
|
||||
|
||||
1. Start with the AI Gateway overview if your team cares most about model governance.
|
||||
2. Review Agent Control Plane if your focus is runtime visibility and operations.
|
||||
3. Review Resource Management and Security Center if you want reusable channels, skills, and scan-backed workflows.
|
||||
4. Create a Team from a role template when a task benefits from coordinated planning, delivery, and review.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 98 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 307 KiB |
@@ -0,0 +1,50 @@
|
||||
# Team 协作快速指南
|
||||
|
||||
Team 让多个 OpenClaw Lite 成员围绕同一个目标协作。选择角色模板、创建 Team,然后在团队群聊中向 Leader 描述目标即可;Leader 负责计划、派发、协调、汇总和最终交付。
|
||||
|
||||
## 适用范围
|
||||
|
||||
- Team 当前使用 **OpenClaw Lite** 运行方式。
|
||||
- 协作模式固定为 **Leader 中介协作**:任务先进入 Leader,再由 Leader 协调成员完成。
|
||||
- 模板已经包含成员职责;通常无需分别设置成员 Runtime、资源预设或协作参数。
|
||||
|
||||
## 1. 创建 Team
|
||||
|
||||
1. 在左侧导航打开 **Teams**,选择创建 Team。
|
||||
2. 填写 Team 名称;按需要调整共享存储容量。
|
||||
3. 选择成员模板,并在右侧摘要确认 Leader、成员数量和运行方式。
|
||||
4. 点击 **创建**。
|
||||
|
||||

|
||||
|
||||
创建页会固定显示 OpenClaw Lite 与 Leader 中介协作。模板决定成员职责,因此不需要手动逐项选择成员资源预设。
|
||||
|
||||
## 2. 发起协作
|
||||
|
||||
进入 Team 详情页,在团队群聊中向 Leader 描述业务目标。例如:“生成一个简易的计算器 App”。
|
||||
|
||||
Leader 会根据目标制定计划、安排成员、收集交付和验收信息,并在工作完成后给出最终汇总。用户不需要逐个向成员手动派发同一任务。
|
||||
|
||||
## 3. 查看过程与进度
|
||||
|
||||
Team 详情页包含两个主要区域:
|
||||
|
||||
- **团队群聊**:展示计划、任务派发、有效进度、交付、Review 和 Leader 汇总等协作过程。
|
||||
- **Execution Kanban**:展示总任务状态、当前待办、进行中的工作和已完成交付。
|
||||
|
||||

|
||||
|
||||
当 Kanban 显示“Leader 正在规划”“等待成员交付”或“等待 Leader 汇总”时,表示协作正在对应阶段推进。无需重复发送相同任务;如需调整目标,可在群聊中补充要求。
|
||||
|
||||
## 4. 查看交付结果
|
||||
|
||||
成员会把产物和验收结论回传给 Leader。Leader 的最终汇总会显示在团队群聊和任务详情中;需要查看产物时,可使用详情页顶部的 **文件** 标签浏览 Team 共享文件。
|
||||
|
||||
## 5. 选择合适模板
|
||||
|
||||
- **标准双成员**:适合简单任务,由 Leader 协调一名执行成员。
|
||||
- **交付三成员**:适合“实现 + 验收”的小型交付。
|
||||
- **产品探索四成员**:适合需求、设计和技术可行性分析。
|
||||
- **软件工程八成员**:适合包含产品、设计、前后端、架构、测试与代码审查的完整研发协作。
|
||||
|
||||
模板是起点。优先选择最接近目标的模板,再在群聊中补充范围、约束和验收标准。
|
||||
@@ -0,0 +1,50 @@
|
||||
# Team Workspace Quick Guide
|
||||
|
||||
Teams lassen mehrere OpenClaw-Lite-Mitglieder an einem gemeinsamen Ziel arbeiten. Waehle eine Rollenvorlage, erstelle das Team und beschreibe das Ziel im Team-Chat fuer den Leader. Der Leader plant, delegiert, koordiniert, sammelt Ergebnisse und liefert die finale Zusammenfassung.
|
||||
|
||||
## Geltungsbereich
|
||||
|
||||
- Teams laufen derzeit im Modus **OpenClaw Lite**.
|
||||
- Die Zusammenarbeit ist fest als **Leader-vermittelte Zusammenarbeit** definiert: Arbeit erreicht zuerst den Leader, der die Mitglieder koordiniert.
|
||||
- Vorlagen enthalten bereits die Rollen der Mitglieder. In der Regel musst du nicht fuer jedes Mitglied Runtime, Ressourcenprofil oder Kollaborationsoptionen konfigurieren.
|
||||
|
||||
## 1. Team erstellen
|
||||
|
||||
1. Oeffne **Teams** in der Navigation und waehle das Erstellen eines Teams.
|
||||
2. Gib einen Teamnamen ein und passe bei Bedarf den gemeinsamen Speicher an.
|
||||
3. Waehle eine Mitgliedervorlage und pruefe Leader, Mitgliederzahl und Runtime in der Zusammenfassung.
|
||||
4. Waehle **Erstellen**.
|
||||
|
||||

|
||||
|
||||
Die Erstellungsseite verwendet fest OpenClaw Lite und Leader-vermittelte Zusammenarbeit. Die Vorlage definiert die Aufgaben der Mitglieder; einzelne Ressourcenprofile sind nicht erforderlich.
|
||||
|
||||
## 2. Zusammenarbeit starten
|
||||
|
||||
Oeffne die Team-Detailseite und beschreibe das fachliche Ziel im Team-Chat fuer den Leader, zum Beispiel: „Erstelle eine einfache Rechner-App.“
|
||||
|
||||
Der Leader erstellt den Plan, weist Mitglieder zu, sammelt Ergebnisse und Review-Nachweise und veroeffentlicht die finale Zusammenfassung. Du musst nicht dieselbe Aufgabe einzeln an alle Mitglieder senden.
|
||||
|
||||
## 3. Fortschritt verfolgen
|
||||
|
||||
Die Team-Detailseite hat zwei zentrale Bereiche:
|
||||
|
||||
- **Team-Chat** zeigt Plaene, Zuweisungen, aussagekraeftigen Fortschritt, Lieferungen, Reviews und die Leader-Zusammenfassung.
|
||||
- **Execution Kanban** zeigt Gesamtaufgabenstatus, ausstehende, laufende und abgeschlossene Arbeit.
|
||||
|
||||

|
||||
|
||||
Zustaende wie „Leader plant“, „Warten auf Mitgliederlieferung“ und „Warten auf Leader-Zusammenfassung“ zeigen die aktuelle Phase. Sende die gleiche Aufgabe nicht erneut; ergaenze Anforderungen im Chat, wenn sich Ziel oder Abnahmekriterien aendern.
|
||||
|
||||
## 4. Ergebnisse ansehen
|
||||
|
||||
Mitglieder geben Artefakte und Validierungsergebnisse an den Leader zurueck. Die finale Zusammenfassung erscheint im Team-Chat und in den Aufgabendetails. Ueber den Tab **Files** oben auf der Detailseite kannst du gemeinsame Team-Artefakte ansehen.
|
||||
|
||||
## 5. Vorlage waehlen
|
||||
|
||||
- **Standard Two-Member Team**: Ein Leader koordiniert ein Ausfuehrungsmitglied fuer eine fokussierte Aufgabe.
|
||||
- **Delivery Three-Member Team**: Implementierung plus Review fuer eine kleine Lieferung.
|
||||
- **Product Discovery Four-Member Team**: Produkt-, Design- und technische Machbarkeitsanalyse.
|
||||
- **Software Engineering Eight-Member Team**: Produkt, Design, Frontend, Backend, Architektur, QA und Code Review.
|
||||
|
||||
Vorlagen sind ein Ausgangspunkt. Waehle die passendste Vorlage und ergaenze Umfang, Einschraenkungen und Abnahmekriterien im Team-Chat.
|
||||
@@ -0,0 +1,50 @@
|
||||
# Team Workspace Quick Guide
|
||||
|
||||
Teams let multiple OpenClaw Lite members work toward one shared goal. Choose a role template, create the Team, then describe the goal to the Leader in Team chat. The Leader plans, delegates, coordinates, synthesizes, and delivers the final result.
|
||||
|
||||
## Scope
|
||||
|
||||
- Teams currently run in **OpenClaw Lite** mode.
|
||||
- Collaboration is fixed to **Leader-mediated collaboration**: work reaches the Leader first, who coordinates the members.
|
||||
- Templates already define member responsibilities. You normally do not configure a runtime, resource preset, or collaboration setting for every member.
|
||||
|
||||
## 1. Create a Team
|
||||
|
||||
1. Open **Teams** from the navigation and choose to create a Team.
|
||||
2. Enter a Team name and adjust shared storage when needed.
|
||||
3. Choose a member template and confirm the Leader, member count, and runtime in the summary.
|
||||
4. Select **Create**.
|
||||
|
||||

|
||||
|
||||
The creation page fixes Teams to OpenClaw Lite and Leader-mediated collaboration. The selected template defines member responsibilities, so individual resource presets are not required.
|
||||
|
||||
## 2. Start Collaboration
|
||||
|
||||
Open the Team detail page and describe the business goal to the Leader in Team chat. For example: “Build a simple calculator app.”
|
||||
|
||||
The Leader creates the plan, assigns members, collects deliveries and review evidence, and publishes a final synthesis. You do not need to manually send the same task to every member.
|
||||
|
||||
## 3. Follow Progress
|
||||
|
||||
The Team detail page has two primary areas:
|
||||
|
||||
- **Team chat** shows plans, assignments, meaningful progress, deliveries, reviews, and Leader synthesis.
|
||||
- **Execution Kanban** shows root-task state, pending work, active work, and completed deliveries.
|
||||
|
||||

|
||||
|
||||
States such as “Leader planning”, “waiting for member delivery”, and “waiting for Leader synthesis” indicate the current collaboration stage. Do not resend the same task; add a follow-up in chat if the goal or acceptance criteria change.
|
||||
|
||||
## 4. View Deliveries
|
||||
|
||||
Members return artifacts and validation conclusions to the Leader. The final synthesis appears in Team chat and task details. Use the **Files** tab at the top of the detail page to browse shared Team artifacts.
|
||||
|
||||
## 5. Choose a Template
|
||||
|
||||
- **Standard two-member Team**: a Leader coordinates one execution member for a focused task.
|
||||
- **Delivery three-member Team**: implementation plus review for a small delivery.
|
||||
- **Product discovery four-member Team**: product, design, and technical-feasibility exploration.
|
||||
- **Software engineering eight-member Team**: product, design, frontend, backend, architecture, QA, and code review.
|
||||
|
||||
Templates are a starting point. Select the closest one, then add scope, constraints, and acceptance criteria in Team chat.
|
||||
@@ -0,0 +1,50 @@
|
||||
# Team Workspace Quick Guide
|
||||
|
||||
Team では複数の OpenClaw Lite メンバーが一つの共通目標に取り組みます。ロールテンプレートを選び、Team を作成してから、Team チャットで Leader に目標を説明します。Leader が計画、割り当て、調整、成果物の収集、最終結果の提示を担当します。
|
||||
|
||||
## 対象範囲
|
||||
|
||||
- Team は現在 **OpenClaw Lite** モードで動作します。
|
||||
- 協働方式は **Leader 仲介型** に固定されています。作業はまず Leader に届き、Leader がメンバーを調整します。
|
||||
- テンプレートにはメンバーの役割が含まれます。通常、メンバーごとに Runtime、リソースプリセット、協働設定を指定する必要はありません。
|
||||
|
||||
## 1. Team を作成する
|
||||
|
||||
1. ナビゲーションで **Teams** を開き、Team の作成を選択します。
|
||||
2. Team 名を入力し、必要に応じて共有ストレージを調整します。
|
||||
3. メンバーテンプレートを選び、概要で Leader、メンバー数、Runtime を確認します。
|
||||
4. **作成** を選択します。
|
||||
|
||||

|
||||
|
||||
作成画面では OpenClaw Lite と Leader 仲介型協働が固定されています。テンプレートが役割を定義するため、個別のリソースプリセットは不要です。
|
||||
|
||||
## 2. 協働を開始する
|
||||
|
||||
Team 詳細ページを開き、Team チャットで Leader に業務目標を説明します。例: 「シンプルな電卓アプリを作成してください。」
|
||||
|
||||
Leader は計画を作成し、メンバーを割り当て、成果物とレビュー証跡を集め、最終統合を公開します。同じ作業を各メンバーに手動で送る必要はありません。
|
||||
|
||||
## 3. 進捗を確認する
|
||||
|
||||
Team 詳細ページには二つの主要領域があります。
|
||||
|
||||
- **Team チャット**: 計画、割り当て、有効な進捗、成果物、レビュー、Leader の統合結果を表示します。
|
||||
- **Execution Kanban**: ルートタスクの状態、未着手、進行中、完了済みの作業を表示します。
|
||||
|
||||

|
||||
|
||||
「Leader が計画中」「メンバーの成果物を待機中」「Leader の統合を待機中」といった状態は現在の段階を示します。同じタスクを再送せず、目標や受入基準が変わった場合はチャットで追記してください。
|
||||
|
||||
## 4. 成果物を確認する
|
||||
|
||||
メンバーは成果物と検証結果を Leader に返します。最終統合は Team チャットとタスク詳細に表示されます。詳細ページ上部の **Files** タブで共有 Team 成果物を参照できます。
|
||||
|
||||
## 5. テンプレートを選ぶ
|
||||
|
||||
- **Standard Two-Member Team**: Leader が一人の実行メンバーを調整する、集中的な作業向けです。
|
||||
- **Delivery Three-Member Team**: 小規模な提供に対する実装とレビュー向けです。
|
||||
- **Product Discovery Four-Member Team**: プロダクト、デザイン、技術的実現性の探索向けです。
|
||||
- **Software Engineering Eight-Member Team**: プロダクト、デザイン、フロントエンド、バックエンド、アーキテクチャ、QA、コードレビューを含む開発向けです。
|
||||
|
||||
テンプレートは出発点です。最も近いテンプレートを選び、範囲、制約、受入基準を Team チャットで補足してください。
|
||||
@@ -0,0 +1,50 @@
|
||||
# Team Workspace Quick Guide
|
||||
|
||||
Team은 여러 OpenClaw Lite 멤버가 하나의 공통 목표를 위해 협업하게 합니다. 역할 템플릿을 선택하고 Team을 만든 다음 Team 채팅에서 Leader에게 목표를 설명하면 됩니다. Leader가 계획 수립, 배정, 조율, 산출물 수집, 최종 결과 정리를 담당합니다.
|
||||
|
||||
## 적용 범위
|
||||
|
||||
- Team은 현재 **OpenClaw Lite** 모드로 실행됩니다.
|
||||
- 협업 방식은 **Leader 중개 협업**으로 고정됩니다. 작업은 먼저 Leader에게 전달되고 Leader가 멤버를 조율합니다.
|
||||
- 템플릿에는 멤버 역할이 이미 정의되어 있습니다. 일반적으로 멤버별 Runtime, 리소스 프리셋 또는 협업 설정을 따로 구성할 필요가 없습니다.
|
||||
|
||||
## 1. Team 만들기
|
||||
|
||||
1. 탐색 메뉴에서 **Teams**를 열고 Team 만들기를 선택합니다.
|
||||
2. Team 이름을 입력하고 필요하면 공유 스토리지 용량을 조정합니다.
|
||||
3. 멤버 템플릿을 선택하고 요약에서 Leader, 멤버 수, Runtime을 확인합니다.
|
||||
4. **만들기**를 선택합니다.
|
||||
|
||||

|
||||
|
||||
생성 화면은 OpenClaw Lite와 Leader 중개 협업을 고정으로 사용합니다. 템플릿이 멤버 역할을 정하므로 개별 리소스 프리셋은 필요하지 않습니다.
|
||||
|
||||
## 2. 협업 시작하기
|
||||
|
||||
Team 상세 페이지를 열고 Team 채팅에서 Leader에게 업무 목표를 설명합니다. 예: “간단한 계산기 앱을 만들어 주세요.”
|
||||
|
||||
Leader는 계획을 만들고, 멤버를 배정하며, 산출물과 검토 근거를 수집한 뒤 최종 종합 결과를 게시합니다. 같은 작업을 각 멤버에게 직접 보낼 필요가 없습니다.
|
||||
|
||||
## 3. 진행 상황 확인하기
|
||||
|
||||
Team 상세 페이지에는 두 가지 주요 영역이 있습니다.
|
||||
|
||||
- **Team 채팅**: 계획, 배정, 의미 있는 진행, 산출물, 검토, Leader 종합을 보여 줍니다.
|
||||
- **Execution Kanban**: 루트 작업 상태, 대기 중인 작업, 진행 중인 작업, 완료된 산출물을 보여 줍니다.
|
||||
|
||||

|
||||
|
||||
“Leader 계획 중”, “멤버 산출물 대기”, “Leader 종합 대기” 같은 상태는 현재 협업 단계를 뜻합니다. 같은 작업을 다시 보내지 말고, 목표나 승인 기준이 바뀌면 채팅에 후속 요구 사항을 남기세요.
|
||||
|
||||
## 4. 산출물 보기
|
||||
|
||||
멤버는 산출물과 검증 결론을 Leader에게 반환합니다. 최종 종합 결과는 Team 채팅과 작업 상세에 표시됩니다. 상세 페이지 상단의 **Files** 탭에서 공유 Team 산출물을 살펴볼 수 있습니다.
|
||||
|
||||
## 5. 템플릿 선택하기
|
||||
|
||||
- **Standard Two-Member Team**: Leader가 한 명의 실행 멤버를 조율하는 집중 작업에 적합합니다.
|
||||
- **Delivery Three-Member Team**: 소규모 납품을 위한 구현과 검토에 적합합니다.
|
||||
- **Product Discovery Four-Member Team**: 제품, 디자인, 기술 실현 가능성 탐색에 적합합니다.
|
||||
- **Software Engineering Eight-Member Team**: 제품, 디자인, 프론트엔드, 백엔드, 아키텍처, QA, 코드 리뷰를 포함한 개발 작업에 적합합니다.
|
||||
|
||||
템플릿은 시작점입니다. 가장 가까운 템플릿을 선택한 뒤 범위, 제약 조건, 승인 기준을 Team 채팅에 추가하세요.
|
||||
@@ -13,6 +13,7 @@
|
||||
- [八、快速启动指南(登录后初始化并创建 OpenClaw 实例)](#sec-09)
|
||||
- [九、控制台与 AI 网关其他功能说明](#sec-12)
|
||||
- [十、工作台模块说明](#sec-13)
|
||||
- [10.4 Team 协作](#sec-13-team)
|
||||
- [十一、问题与对策速查](#sec-14)
|
||||
- [十二、建议的最终检查顺序(可按此自查)](#sec-15)
|
||||
|
||||
@@ -685,6 +686,15 @@ AI 审计页面用于查看最近的受管模型调用记录,帮助管理员
|
||||
> - 如果上传后列表未立即刷新,可手动点击页面右上角 **刷新** 重新加载。
|
||||
---
|
||||
|
||||
<a id="sec-13-team"></a>
|
||||
### 10.4 Team 协作
|
||||
|
||||
Team 用于让多个 OpenClaw Lite 成员围绕同一目标协作。进入 **Teams** 后,选择成员模板、填写 Team 名称并创建即可;不需要逐个配置成员运行时或资源预设。
|
||||
|
||||
创建后,在团队群聊中向 Leader 描述目标。Leader 会安排成员完成计划、执行、验收和最终汇总;通过 **Execution Kanban** 查看当前阶段和已完成交付,通过 **文件** 标签查看共享产物。
|
||||
|
||||
详见 [Team 协作快速指南](./team-workspaces-guide.md)。
|
||||
|
||||
<a id="sec-14"></a>
|
||||
## 十一、问题与对策速查
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
- [VIII. Schnellstartanleitung (nach dem Login initialisieren und eine OpenClaw-Instanz erstellen)](#sec-09)
|
||||
- [IX. Konsole und weitere Funktionen des AI Gateway](#sec-12)
|
||||
- [X. Beschreibung des Workspace-Moduls](#sec-13)
|
||||
- [10.4 Team-Zusammenarbeit](#sec-13-team)
|
||||
- [XI. Schnelle Referenz für Probleme und Gegenmaßnahmen](#sec-14)
|
||||
- [XII. Empfohlene abschließende Prüfreihenfolge (zur Selbstprüfung)](#sec-15)
|
||||
|
||||
@@ -659,6 +660,15 @@ Gehe beim Hochladen von Skills wie folgt vor:
|
||||
> - Falls die Liste nach dem Upload nicht sofort aktualisiert wird, kannst du oben rechts auf der Seite manuell auf **Aktualisieren** klicken, um neu zu laden。
|
||||
---
|
||||
|
||||
<a id="sec-13-team"></a>
|
||||
### 10.4 Team-Zusammenarbeit
|
||||
|
||||
Teams lassen mehrere OpenClaw-Lite-Mitglieder an einem gemeinsamen Ziel arbeiten. Oeffne **Teams**, waehle eine Mitgliedervorlage, gib einen Teamnamen ein und erstelle das Team; Runtime- und Ressourcenprofile je Mitglied sind nicht erforderlich.
|
||||
|
||||
Beschreibe nach der Erstellung das Ziel im Team-Chat fuer den Leader. Der Leader koordiniert Planung, Ausfuehrung, Review und finale Zusammenfassung. Mit **Execution Kanban** siehst du die aktuelle Phase und abgeschlossene Lieferungen; unter **Files** findest du gemeinsame Artefakte.
|
||||
|
||||
Siehe [Team Workspace Quick Guide](./team-workspaces-guide_de.md).
|
||||
|
||||
<a id="sec-14"></a>
|
||||
## XI. Schnelle Referenz für Probleme und Gegenmaßnahmen
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
- [VIII. Quick Start Guide (Initialize and Create an OpenClaw Instance After Login)](#sec-09)
|
||||
- [IX. Console and Other AI Gateway Features](#sec-12)
|
||||
- [X. Workspace Module Guide](#sec-13)
|
||||
- [10.4 Team Collaboration](#sec-13-team)
|
||||
- [XI. Quick Troubleshooting Reference](#sec-14)
|
||||
- [XII. Recommended Final Check Sequence (Use as a Self-Check)](#sec-15)
|
||||
|
||||
@@ -684,6 +685,15 @@ To upload skills, follow these steps:
|
||||
> - If the list is not refreshed immediately after upload, you can manually click **Refresh** in the upper-right corner of the page to reload it.
|
||||
---
|
||||
|
||||
<a id="sec-13-team"></a>
|
||||
### 10.4 Team Collaboration
|
||||
|
||||
Teams let multiple OpenClaw Lite members work toward a shared goal. Open **Teams**, choose a member template, enter a Team name, and create it; individual member runtime and resource-preset setup is not required.
|
||||
|
||||
After creation, describe the goal to the Leader in Team chat. The Leader coordinates planning, execution, review, and final synthesis. Use **Execution Kanban** to follow the current stage and completed deliveries, and use **Files** to browse shared artifacts.
|
||||
|
||||
See the [Team Workspace Quick Guide](./team-workspaces-guide_en.md).
|
||||
|
||||
<a id="sec-14"></a>
|
||||
## XI. Quick Troubleshooting Reference
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
- [八、クイックスタートガイド(ログイン後に初期化して OpenClaw インスタンスを作成)](#sec-09)
|
||||
- [九、コンソールと AI Gateway のその他の機能説明](#sec-12)
|
||||
- [十、ワークスペースモジュールの説明](#sec-13)
|
||||
- [10.4 Team 協働](#sec-13-team)
|
||||
- [十一、問題と対処のクイックリファレンス](#sec-14)
|
||||
- [十二、推奨される最終確認手順(セルフチェック用)](#sec-15)
|
||||
|
||||
@@ -658,6 +659,15 @@ AI 監査ページは、最近の管理対象モデル呼び出し記録を確
|
||||
> - アップロード後に一覧がすぐ更新されない場合は、ページ右上の **更新** を手動でクリックして再読み込みしてください。
|
||||
---
|
||||
|
||||
<a id="sec-13-team"></a>
|
||||
### 10.4 Team 協働
|
||||
|
||||
Team では複数の OpenClaw Lite メンバーが共通目標に取り組みます。**Teams** を開き、メンバーテンプレートを選び、Team 名を入力して作成します。メンバーごとの Runtime やリソースプリセットの設定は不要です。
|
||||
|
||||
作成後、Team チャットで Leader に目標を説明します。Leader が計画、実行、レビュー、最終統合を調整します。現在の段階と完了した成果物は **Execution Kanban** で、共有成果物は **Files** で確認できます。
|
||||
|
||||
詳しくは [Team Workspace Quick Guide](./team-workspaces-guide_ja.md) を参照してください。
|
||||
|
||||
<a id="sec-14"></a>
|
||||
## 十一、問題と対処のクイックリファレンス
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
- [8. 빠른 시작 가이드(로그인 후 초기화 및 OpenClaw 인스턴스 생성)](#sec-09)
|
||||
- [9. 콘솔 및 AI Gateway 기타 기능 설명](#sec-12)
|
||||
- [10. 워크스페이스 모듈 설명](#sec-13)
|
||||
- [10.4 Team 협업](#sec-13-team)
|
||||
- [11. 문제와 대응 빠른 참조](#sec-14)
|
||||
- [12. 권장 최종 점검 순서(자가 점검용)](#sec-15)
|
||||
|
||||
@@ -659,6 +660,15 @@ AI 감사 페이지는 최근의 관리형 모델 호출 기록을 확인하는
|
||||
> - 업로드 후 목록이 바로 새로고침되지 않으면 페이지 오른쪽 상단의 **새로고침**을 수동으로 클릭하여 다시 불러올 수 있습니다。
|
||||
---
|
||||
|
||||
<a id="sec-13-team"></a>
|
||||
### 10.4 Team 협업
|
||||
|
||||
Team은 여러 OpenClaw Lite 멤버가 공통 목표를 위해 협업하게 합니다. **Teams**를 열고 멤버 템플릿을 선택한 다음 Team 이름을 입력해 만드세요. 멤버별 Runtime이나 리소스 프리셋 설정은 필요하지 않습니다.
|
||||
|
||||
생성 후 Team 채팅에서 Leader에게 목표를 설명합니다. Leader가 계획, 실행, 검토, 최종 종합을 조율합니다. **Execution Kanban**에서 현재 단계와 완료된 산출물을 보고, **Files**에서 공유 산출물을 확인할 수 있습니다.
|
||||
|
||||
자세한 내용은 [Team Workspace Quick Guide](./team-workspaces-guide_ko.md)를 참고하세요.
|
||||
|
||||
<a id="sec-14"></a>
|
||||
## 11. 문제와 대응 빠른 참조
|
||||
|
||||
|
||||
@@ -2,6 +2,44 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@keyframes cm-silver-sheen {
|
||||
0%,
|
||||
18% {
|
||||
transform: translateX(-180%) skewX(-18deg);
|
||||
opacity: 0;
|
||||
}
|
||||
28% {
|
||||
opacity: 0.55;
|
||||
}
|
||||
58%,
|
||||
100% {
|
||||
transform: translateX(420%) skewX(-18deg);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes cm-tech-progress {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes cm-tech-breathe {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(56, 189, 248, 0.12);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 5px rgba(56, 189, 248, 0);
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
--text: #334155;
|
||||
--text-strong: #0f172a;
|
||||
@@ -93,4 +131,96 @@ body {
|
||||
.cm-icon-button:hover {
|
||||
@apply border-slate-400 bg-slate-50 text-slate-950;
|
||||
}
|
||||
|
||||
.cm-tech-panel {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
border-color: #d8dee6;
|
||||
background: #f4f7fa;
|
||||
box-shadow:
|
||||
0 24px 60px -42px rgba(15, 23, 42, 0.46),
|
||||
0 1px 0 rgba(255, 255, 255, 0.95) inset;
|
||||
}
|
||||
|
||||
.cm-tech-header {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-bottom: 1px solid #d8dee6;
|
||||
background:
|
||||
radial-gradient(circle at 86% 0%, rgba(125, 211, 252, 0.16), transparent 34%),
|
||||
linear-gradient(135deg, #fbfcfd 0%, #e9eef3 48%, #f8fafc 100%);
|
||||
}
|
||||
|
||||
.cm-tech-header::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
linear-gradient(rgba(148, 163, 184, 0.07) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(148, 163, 184, 0.07) 1px, transparent 1px);
|
||||
background-size: 24px 24px;
|
||||
mask-image: linear-gradient(90deg, black, transparent 82%);
|
||||
}
|
||||
|
||||
.cm-tech-header::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -40%;
|
||||
bottom: -40%;
|
||||
left: 0;
|
||||
width: 18%;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.88), transparent);
|
||||
filter: blur(1px);
|
||||
animation: cm-silver-sheen 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.cm-tech-workspace {
|
||||
background:
|
||||
radial-gradient(circle at 100% 0%, rgba(186, 230, 253, 0.2), transparent 32%),
|
||||
linear-gradient(180deg, #f8fafc 0%, #edf2f6 52%, #f8fafc 100%);
|
||||
}
|
||||
|
||||
.cm-tech-surface {
|
||||
border: 1px solid rgba(203, 213, 225, 0.92);
|
||||
background: linear-gradient(145deg, rgba(255, 255, 255, 0.94), rgba(239, 243, 247, 0.9));
|
||||
box-shadow:
|
||||
0 14px 30px -25px rgba(15, 23, 42, 0.55),
|
||||
0 1px 0 rgba(255, 255, 255, 0.95) inset;
|
||||
transition:
|
||||
border-color 220ms ease,
|
||||
box-shadow 220ms ease,
|
||||
transform 220ms ease;
|
||||
}
|
||||
|
||||
.cm-tech-surface:hover {
|
||||
border-color: #b9c5d2;
|
||||
box-shadow:
|
||||
0 18px 36px -26px rgba(15, 23, 42, 0.58),
|
||||
0 1px 0 rgba(255, 255, 255, 0.98) inset;
|
||||
}
|
||||
|
||||
.cm-tech-subtle {
|
||||
border: 1px solid rgba(211, 219, 228, 0.9);
|
||||
background: linear-gradient(145deg, rgba(248, 250, 252, 0.94), rgba(231, 237, 243, 0.82));
|
||||
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.9) inset;
|
||||
}
|
||||
|
||||
.cm-tech-progress {
|
||||
background-size: 220% 220%;
|
||||
animation: cm-tech-progress 3.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.cm-tech-breathe {
|
||||
animation: cm-tech-breathe 2.4s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.cm-tech-header::after,
|
||||
.cm-tech-progress,
|
||||
.cm-tech-breathe {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,43 @@ export type TeamMemberTemplate = {
|
||||
members: TeamMemberTemplateMember[];
|
||||
};
|
||||
|
||||
const BUILTIN_MEMBER_DESCRIPTION_ZH: Record<string, string> = {
|
||||
"Team Leader / Agents Orchestrator: decomposes goals, coordinates members, maintains context, validates member outputs, and reports externally.":
|
||||
"团队负责人 / 智能体编排官:拆解目标、协调成员、维护上下文、验证成员产出,并向用户汇报最终结果。",
|
||||
"Senior Developer: executes implementation tasks assigned by the Leader, reports progress, lists changes, and escalates blockers.":
|
||||
"资深开发工程师:执行 Leader 分派的实现任务,汇报进度和变更,并及时上报阻塞问题。",
|
||||
"Agents Orchestrator: decomposes requirements, sets priorities, dispatches tasks, manages risks, and integrates results.":
|
||||
"智能体编排官:拆解需求、设定优先级、分派任务、管理风险,并整合成员结果。",
|
||||
"Senior Developer: implements code, integrates interfaces, adds necessary tests, and provides reproducible delivery notes.":
|
||||
"资深开发工程师:负责代码实现和接口集成,补充必要测试,并提供可复现的交付说明。",
|
||||
"Evidence Collector / Reviewer: verifies behavior, checks regressions, gathers evidence, reviews delivery items, and gives a PASS/FAIL verdict.":
|
||||
"验收验证员 / 评审员:验证功能行为和回归风险,收集证据、审查交付内容,并给出通过或不通过的结论。",
|
||||
"Agents Orchestrator: owns goals, definition of done, task breakdown, dependency coordination, risk management, acceptance, and final decisions.":
|
||||
"智能体编排官:负责目标和完成标准,统筹任务拆解、依赖协调、风险管理、验收与最终决策。",
|
||||
"Product Manager: owns requirements, product direction, PRD, user flows, feature boundaries, priorities, and acceptance criteria.":
|
||||
"产品经理:负责需求、产品方向、PRD、用户流程、功能边界、优先级和验收标准。",
|
||||
"UI Designer: owns visual direction, UX, interaction states, component guidance, and implementable design handoff.":
|
||||
"UI 设计师:负责视觉方向、用户体验、交互状态和组件规范,并提供可落地的设计交付。",
|
||||
"Frontend Developer: owns frontend UI implementation, API integration, state management, interaction behavior, responsiveness, and accessibility.":
|
||||
"前端开发工程师:负责前端界面实现、API 对接、状态管理、交互行为、响应式适配和无障碍体验。",
|
||||
"Backend Architect: owns APIs, databases, permissions, queues, business logic, and server-side system capabilities.":
|
||||
"后端架构师:负责 API、数据库、权限、队列、业务逻辑和服务端系统能力。",
|
||||
"Software Architect: owns technical choices, system boundaries, availability, extensibility, technical standards, and evolution plans.":
|
||||
"软件架构师:负责技术选型、系统边界、可用性、可扩展性、技术标准和演进规划。",
|
||||
"Evidence Collector: owns functional validation, regression checks, evidence gathering, reproduction notes, and acceptance verdicts.":
|
||||
"验收验证员:负责功能验证、回归检查、证据收集、复现说明和验收结论。",
|
||||
"Code Reviewer: owns code review, architecture consistency, maintainability, test coverage, risk findings, and pre-merge quality gates.":
|
||||
"代码审查员:负责代码评审、架构一致性、可维护性、测试覆盖、风险识别和合并前质量把关。",
|
||||
};
|
||||
|
||||
export const getTeamMemberDisplayDescription = (description?: string) => {
|
||||
const normalized = description?.trim();
|
||||
if (!normalized) {
|
||||
return description;
|
||||
}
|
||||
return BUILTIN_MEMBER_DESCRIPTION_ZH[normalized] || description;
|
||||
};
|
||||
|
||||
const baseMember = (
|
||||
overrides: Partial<TeamMemberTemplateMember>,
|
||||
): TeamMemberTemplateMember => ({
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -66,6 +66,20 @@ export interface TeamTask {
|
||||
| "succeeded"
|
||||
| "failed"
|
||||
| "stale";
|
||||
workflow_state?:
|
||||
| "planning"
|
||||
| "executing"
|
||||
| "awaiting_phase_results"
|
||||
| "awaiting_leader_decision"
|
||||
| "synthesizing"
|
||||
| "completion_pending"
|
||||
| "completed"
|
||||
| "failed"
|
||||
| string;
|
||||
plan_version?: number;
|
||||
ledger_version?: number;
|
||||
current_phase_id?: string;
|
||||
accepted_completion_id?: string;
|
||||
redis_stream_id?: string;
|
||||
error_message?: string;
|
||||
created_at: string;
|
||||
@@ -98,6 +112,14 @@ export interface TeamWorkItem {
|
||||
team_id: number;
|
||||
root_task_id: number;
|
||||
work_id: string;
|
||||
assignment_id?: string;
|
||||
canonical_work_id?: string;
|
||||
phase_id?: string;
|
||||
revision?: number;
|
||||
required_for_root?: boolean;
|
||||
superseded_by?: string;
|
||||
review_required?: boolean;
|
||||
validated_revision?: number;
|
||||
owner_member_id?: number;
|
||||
title: string;
|
||||
status: "pending" | "dispatched" | "running" | "succeeded" | "failed" | "stale";
|
||||
|
||||
Reference in New Issue
Block a user