mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 12:14:40 +00:00
feat(teams): team system v2 with bug fixes, workspace scope, versioning, and prompt optimization (#183)
* feat(workspace): add team shared workspace for file collaboration
- Add workspace_write and workspace_read tools for agents to share files across team members
- Create team_workspaces DB table with migration 000017 (file metadata, pinning, tags)
- Implement PostgreSQL store layer for workspace CRUD operations
- Add RPC handlers for workspace list/read/delete from web UI
- Build React workspace tab with file listing, content preview, and delete
- Propagate workspace channel/chatID scope through delegation chain
- Auto-allow workspace tools in agent tool policy when agent belongs to a team
- Inject team workspace guidance into system prompt for team agents
- Add /reset command handler for clearing session history
- Harden MCP bridge context middleware to reject headers when no gateway token
- Add i18n strings for workspace UI in en/vi/zh locales
* feat(teams): add comprehensive task management with followup reminders and recovery
- Add task followup/reminder system with auto-set on lead agent reply and auto-clear when user responds on channel
- Add task recovery ticker to re-dispatch stale/pending tasks periodically
- Add task scopes, filtering by status/channel/chatID, and task events
- Add WS RPC handlers for task CRUD, assignments, comments, events, and bulk operations (teams_tasks.go)
- Add task detail dialog, settings UI for followup config, and scope filtering in web dashboard
- Add migrations 000018 (team_tasks_v2) and 000019 (task_followup)
- Extend team_tasks_tool with await_reply, clear_followup actions
- Auto-complete/fail team tasks when delegate agent finishes
- Add workspace file listing and team tool manager enhancements
* docs(teams): add team system architecture and playbook ideas documentation
- Add TEAM_SYSTEM.md with full architecture design covering task management, shared workspace, and delegation engine subsystems
- Add TEAM_PLAYBOOK_IDEAS.md outlining future team coordination layers (playbook, member capabilities, auto-learned patterns)
- Document data models, status flows, tool actions, followup reminder system, task ticker, execution locking, and workspace scope model
* fix(teams): resolve 6 critical bugs in team task system
- Fix unblock SQL: check array_length after array_remove (not before)
- Enforce single-team leadership in team creation
- Add requireLead() for approve/reject tool actions
- Validate cross-team dependency references in blocked_by
- Add team_id to handoff route for multi-team isolation
- Set blocked_by DEFAULT '{}' to prevent NULL array issues
* refactor(workspace): use stable userID as scope key instead of connection UUID
Workspace scope changed from (team_id, channel, chat_id) to (team_id, userID).
Fixes workspace fragmentation across WS tab refreshes and reconnections.
* feat(teams): add V1/V2 versioning with feature gating and optimized prompts
- IsTeamV2() helper gates advanced features (locking, followup, review, audit)
- V2 tool actions rejected for V1 teams with clear error message
- Ticker, gateway consumer, delegation hooks respect version flag
- TEAM.md renders v1/v2 sections conditionally
- Tool descriptions and params optimized (~38% token reduction)
- UI: version toggle in settings, V2 Beta badge, conditional rendering
- i18n: version modal keys for en/vi/zh
* fix(migration): use VARCHAR(255) for user ID columns and add metadata JSONB
- assignee_user_id, user_id, actor_id: TEXT → VARCHAR(255)
- Add metadata JSONB to team_task_comments and team_task_attachments
---------
Co-authored-by: Nam Nguyen Ngoc <namnn.0911@gmail.com>
This commit is contained in:
1 parent
7bde3f2f7c
commit
1a42dc93a6
71 files changed
+7908
-790
No files matched your search
@@ -0,0 +1,129 @@
|
||||
# Team Playbook & Coordination — Future Phase
|
||||
|
||||
## Problem
|
||||
|
||||
Lead agent thiếu "bản đồ" để lead team hiệu quả:
|
||||
- Không biết member nào giỏi gì
|
||||
- Không có strategy chia task
|
||||
- Không có decision framework: delegate vs tự làm
|
||||
|
||||
## Approach: 2 layers
|
||||
|
||||
### Layer 1: Team Playbook (static strategy)
|
||||
|
||||
File `TEAM_PLAYBOOK.md` per team — human-authored, load vào lead agent context.
|
||||
|
||||
```markdown
|
||||
# Team: Content Pipeline
|
||||
|
||||
## Members
|
||||
- **researcher** (member): web search, data gathering, fact-checking
|
||||
- **writer** (member): content creation, editing, formatting
|
||||
- **reviewer** (reviewer): quality check, feedback via comments
|
||||
|
||||
## Delegation Strategy
|
||||
- Research tasks → researcher
|
||||
- Writing/editing → writer
|
||||
- Final review → reviewer (workspace comments, not direct edit)
|
||||
- Complex tasks needing both → researcher first, handoff to writer
|
||||
|
||||
## Rules
|
||||
- Never delegate more than 3 tasks simultaneously
|
||||
- Always include context/requirements in task description
|
||||
- Research tasks: max 2 web searches before summarizing
|
||||
```
|
||||
|
||||
**Implementation:**
|
||||
- Store in `agent_teams.settings.playbook` (text field) hoặc workspace file tagged `reference`
|
||||
- Inject vào lead agent system prompt khi delegation
|
||||
- Tool: `team_tasks action=set_playbook content="..."` (lead only)
|
||||
- Hoặc đơn giản hơn: `workspace_write file_name="TEAM_PLAYBOOK.md"` + auto-inject
|
||||
|
||||
### Layer 2: Member Capabilities (structured metadata)
|
||||
|
||||
```sql
|
||||
ALTER TABLE agent_team_members ADD COLUMN capabilities JSONB DEFAULT '{}';
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"skills": ["coding", "research", "writing"],
|
||||
"languages": ["go", "python"],
|
||||
"tools": ["web_search", "browser", "exec"],
|
||||
"max_concurrent_tasks": 2,
|
||||
"notes": "Good at data analysis, slow at creative writing"
|
||||
}
|
||||
```
|
||||
|
||||
**Tool:** `team_tasks action=capabilities agent_id=... capabilities={...}` (lead only)
|
||||
**Query:** Lead agent gọi `team_tasks action=list_members` → thấy capabilities → match với task requirements
|
||||
|
||||
### Layer 3: Auto-learned patterns (future)
|
||||
|
||||
- Track delegation success rate per member per task type
|
||||
- Lead agent learns: "researcher completes research tasks 90% quality, writer only 60%"
|
||||
- Suggest optimal assignment based on history
|
||||
- Needs: task completion quality metric, delegation history analysis
|
||||
|
||||
## Priority
|
||||
|
||||
- **Now:** Không block workspace feature. Ghi note để plan sau.
|
||||
- **Next phase:** Layer 1 (playbook) — ít effort, dùng workspace file + system prompt injection
|
||||
- **Later:** Layer 2 (capabilities) — cần migration + tool changes
|
||||
- **Future:** Layer 3 (auto-learn) — complex, cần metrics infrastructure
|
||||
|
||||
## UI Integration Points
|
||||
|
||||
### 1. Add Member flow — prompt playbook input
|
||||
|
||||
Khi admin add member vào team (UI), hiện thêm section:
|
||||
- **Role description**: textarea mô tả member này làm gì trong team
|
||||
- **Capabilities tags**: multi-select hoặc free-text input (coding, research, writing, review...)
|
||||
- **Notes**: free-text cho lead agent reference
|
||||
|
||||
→ Data lưu vào `agent_team_members.capabilities` JSONB
|
||||
→ Auto-append vào `TEAM_PLAYBOOK.md` section "## Members"
|
||||
|
||||
### 2. Team Settings — Playbook editor
|
||||
|
||||
Trong team detail page, thêm tab hoặc section:
|
||||
- **Playbook editor**: markdown editor cho `TEAM_PLAYBOOK.md`
|
||||
- **Pre-filled template** khi team chưa có playbook:
|
||||
```markdown
|
||||
# Team Playbook
|
||||
|
||||
## Members
|
||||
(auto-generated from member capabilities)
|
||||
|
||||
## Delegation Strategy
|
||||
- Describe how to assign tasks to members...
|
||||
|
||||
## Rules
|
||||
- Max concurrent tasks per member: ...
|
||||
- Escalation policy: ...
|
||||
```
|
||||
- **Preview mode**: render markdown để review trước khi save
|
||||
- **Auto-sync**: khi add/remove member → suggest update playbook
|
||||
|
||||
### 3. Team Review / Audit view
|
||||
|
||||
Khi human review team performance:
|
||||
- **Member workload**: tasks per member, completion rate
|
||||
- **Playbook compliance**: delegation có follow playbook rules không
|
||||
- **Suggest improvements**: based on delegation history patterns
|
||||
- **Playbook diff**: so sánh playbook versions (nếu dùng workspace versioning)
|
||||
|
||||
### 4. Member Detail trong team
|
||||
|
||||
Click vào member → xem:
|
||||
- Capabilities (structured)
|
||||
- Current assigned tasks
|
||||
- Completion history
|
||||
- Lead's notes về member
|
||||
- **Edit capabilities** button (lead only)
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Workspace feature (đang implement) — playbook có thể là workspace file
|
||||
- Task system gaps (đang plan) — task_type giúp track delegation patterns
|
||||
- Escalation policy — playbook define escalation rules per team
|
||||
+675
@@ -0,0 +1,675 @@
|
||||
# Team System — Architecture & Design
|
||||
|
||||
GoClaw's Team System gồm 3 subsystem tích hợp chặt: **Task Management**, **Shared Workspace**, và **Delegation Engine**. Cả 3 hoạt động qua tool layer (LLM gọi), được bổ trợ bởi system-level guardrails (auto followup, stale recovery, advisory lock) để giảm phụ thuộc vào LLM nhớ đúng.
|
||||
|
||||
---
|
||||
|
||||
## 1. Tổng quan kiến trúc
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ User (Telegram / Discord / WS Dashboard / ...) │
|
||||
└──────────────────────────────────┬──────────────────────────────────────────┘
|
||||
│ inbound message
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||
│ gateway_consumer.go │
|
||||
│ ├─ auto-clear followup khi user reply │
|
||||
│ ├─ route → agent loop → scheduler │
|
||||
│ └─ auto-set followup khi lead reply │
|
||||
└──────────────────────┬────────────────────────────────┬─────────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌──────────────────┐ ┌──────────────────────┐
|
||||
│ Agent Loop │ │ TaskTicker (5 min) │
|
||||
│ (think→act→obs) │ │ ├─ processFollowups │
|
||||
│ │ │ ├─ recoverStaleTasks│
|
||||
│ Tools available: │ │ ├─ dispatchPending │
|
||||
│ team_tasks │ │ └─ notifyLead │
|
||||
│ workspace_write │ └──────────────────────┘
|
||||
│ workspace_read │
|
||||
│ team_message │
|
||||
│ delegate │
|
||||
└──────┬───────────┘
|
||||
│ tool calls
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||
│ TeamToolManager │
|
||||
│ ├─ Team cache (5 min TTL, invalidate on mutation) │
|
||||
│ ├─ RBAC: resolveTeamRole(agentID) → lead / member / reviewer │
|
||||
│ ├─ Escalation policy: checkEscalation(team, action) │
|
||||
│ ├─ Settings parser: followup interval, max, quota, access control │
|
||||
│ └─ Event broadcast: WS events → UI realtime update │
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||
│ PostgreSQL │
|
||||
│ ├─ agent_teams (CRUD, settings JSONB) │
|
||||
│ ├─ agent_team_members (lead / member / reviewer) │
|
||||
│ ├─ team_tasks (+ comments, events, attachments) │
|
||||
│ ├─ team_workspace_files (+ versions, comments) │
|
||||
│ ├─ team_messages (mailbox) │
|
||||
│ ├─ team_delegation_history (audit) │
|
||||
│ └─ team_handoff_routes (routing override) │
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Key files
|
||||
|
||||
| File | Vai trò |
|
||||
|------|---------|
|
||||
| `internal/tools/team_tasks_tool.go` | Task CRUD + 14 actions |
|
||||
| `internal/tools/workspace_tool_write.go` | File write, versioning, templates |
|
||||
| `internal/tools/workspace_tool_read.go` | File read, delete, pin, tag, comments |
|
||||
| `internal/tools/team_tool_manager.go` | Shared backend, cache, RBAC, escalation |
|
||||
| `internal/tools/delegate_prep.go` | Auto-task creation, dependency injection |
|
||||
| `internal/tools/delegate_state.go` | Auto-complete/fail, history persistence |
|
||||
| `internal/tools/delegate_policy.go` | Access control, escalation gates |
|
||||
| `internal/tools/team_message_tool.go` | Send/broadcast/read messages |
|
||||
| `internal/tasks/task_ticker.go` | Recovery, re-dispatch, followup ticker |
|
||||
| `internal/store/pg/teams_tasks.go` | Task SQL, lock management, unblock logic |
|
||||
| `internal/store/pg/teams_workspace.go` | File upsert (advisory lock), versioning |
|
||||
| `cmd/gateway_consumer.go` | Auto followup guardrails |
|
||||
|
||||
---
|
||||
|
||||
## 2. Task System
|
||||
|
||||
Khi team có nhiều agents, lead cần chia việc và track ai đang làm gì. Không có task system thì:
|
||||
- Lead delegate xong không biết member đang ở bước nào, kẹt ở đâu
|
||||
- Agent crash giữa chừng → công việc mất, không ai biết để retry
|
||||
- Human không có visibility: task nào pending, ai own, kết quả ra sao
|
||||
- Dependencies giữa tasks (task B chờ task A xong) không có cách enforce
|
||||
|
||||
Task system giải quyết bằng **shared task list với locking, dependency, progress tracking, và auto-recovery**. Mỗi delegation tự động tạo task record — đảm bảo mọi công việc đều được track kể cả khi LLM quên. Khi agent crash, ticker tự detect lock expire và reset task về pending để agent khác nhận lại.
|
||||
|
||||
### 2.1 Data model
|
||||
|
||||
```sql
|
||||
-- Base: migration 003, v2: migration 018, followup: migration 019
|
||||
team_tasks (
|
||||
-- Identity
|
||||
id UUID PRIMARY KEY,
|
||||
team_id UUID NOT NULL,
|
||||
task_number INT, -- sequential per team
|
||||
identifier VARCHAR(20), -- "DEV-42" human-readable
|
||||
|
||||
-- Content
|
||||
subject VARCHAR(500) NOT NULL,
|
||||
description TEXT,
|
||||
task_type VARCHAR(30) DEFAULT 'general', -- general|delegation|escalation|message
|
||||
priority INT DEFAULT 0, -- higher = more important
|
||||
result TEXT, -- completion result / failure reason
|
||||
|
||||
-- Ownership
|
||||
owner_agent_id UUID, -- agent currently working on it
|
||||
created_by_agent_id UUID, -- which agent created it
|
||||
user_id VARCHAR(255), -- human who triggered creation
|
||||
assignee_user_id TEXT, -- human assignee (for human tasks)
|
||||
|
||||
-- Dependencies
|
||||
blocked_by UUID[], -- task IDs that must complete first
|
||||
parent_id UUID, -- subtask reference
|
||||
|
||||
-- Scope (origin conversation)
|
||||
channel VARCHAR(50), -- telegram, discord, ws, ...
|
||||
chat_id VARCHAR(255), -- specific chat/group
|
||||
|
||||
-- Execution locking
|
||||
locked_at TIMESTAMPTZ, -- when claimed
|
||||
lock_expires_at TIMESTAMPTZ, -- 30 min after claim (heartbeat renews)
|
||||
|
||||
-- Progress
|
||||
progress_percent INT DEFAULT 0, -- 0-100
|
||||
progress_step TEXT, -- "Analyzing data", "Writing report"
|
||||
|
||||
-- Follow-up reminders
|
||||
followup_at TIMESTAMPTZ, -- next reminder time
|
||||
followup_count INT DEFAULT 0, -- reminders already sent
|
||||
followup_max INT DEFAULT 0, -- max reminders (0 = unlimited)
|
||||
followup_message TEXT, -- reminder content
|
||||
followup_channel VARCHAR(60), -- where to send reminder
|
||||
followup_chat_id VARCHAR(255), -- target chat
|
||||
|
||||
-- Status
|
||||
status VARCHAR(20) DEFAULT 'pending',
|
||||
-- pending → in_progress → in_review → completed
|
||||
-- → cancelled
|
||||
-- → failed
|
||||
-- blocked (waiting on dependencies)
|
||||
|
||||
-- Metadata
|
||||
metadata JSONB,
|
||||
created_at TIMESTAMPTZ,
|
||||
updated_at TIMESTAMPTZ
|
||||
)
|
||||
|
||||
-- Supplementary tables
|
||||
team_task_comments (id, task_id, agent_id, user_id, content, created_at)
|
||||
team_task_events (id, task_id, event_type, actor_type, actor_id, data, created_at)
|
||||
team_task_attachments (id, task_id, file_id, added_by, created_at) -- links workspace files
|
||||
```
|
||||
|
||||
### 2.2 Status flow
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────┐
|
||||
│ ┌──────────┐ │
|
||||
│ │ blocked │◄──── blocked_by│
|
||||
│ └────┬─────┘ dependency │
|
||||
│ │ unblock │
|
||||
│ ▼ │
|
||||
┌──────────┐ claim ┌─┴───────────┐ review ┌───────────┐ │
|
||||
│ pending │──────────►│ in_progress │─────────►│ in_review │ │
|
||||
└──────────┘ └──────┬──────┘ └─────┬─────┘ │
|
||||
│ ┌───┴───┐ │
|
||||
complete/fail approve reject │
|
||||
│ │ │ │
|
||||
┌────┴────┐ ┌─────┴──┐ ┌────┴───┐│
|
||||
│completed│ │completed│ │cancelled││
|
||||
│ / failed│ └────────┘ └────────┘│
|
||||
└─────────┘ │
|
||||
│
|
||||
cancel (lead only) ──────────────────┘
|
||||
→ cancelled, unblocks dependents
|
||||
```
|
||||
|
||||
### 2.3 Tool actions (team_tasks)
|
||||
|
||||
| Action | Quyền | Mô tả |
|
||||
|--------|-------|-------|
|
||||
| `list` | All (user-scoped ngoài delegate channel) | Active tasks, filter by status/scope |
|
||||
| `get` | All | Chi tiết + comments + events + attachments |
|
||||
| `create` | Lead | Tạo task, auto-generate identifier |
|
||||
| `claim` | Member/Lead | Atomic pending→in_progress, set lock 30min |
|
||||
| `complete` | Owner | Kết thúc task, auto-unblock dependents |
|
||||
| `cancel` | Lead | Hủy task, unblock dependents |
|
||||
| `search` | All | Full-text search subject + description |
|
||||
| `review` | Owner | Submit for human review |
|
||||
| `comment` | Owner | Add comment (max 10K chars) |
|
||||
| `progress` | Owner | Update percent + step description, renew lock |
|
||||
| `update` | Lead | Edit subject/description |
|
||||
| `attach` | Member/Lead | Link workspace file to task |
|
||||
| `await_reply` | Lead/Owner | Set followup reminder (custom delay/max/message) |
|
||||
| `clear_followup` | Lead/Owner | Cancel followup reminders |
|
||||
|
||||
### 2.4 Follow-up reminder system
|
||||
|
||||
Khi lead agent hỏi user câu hỏi (ví dụ: "Anh muốn chọn phương án nào?"), user có thể không trả lời ngay — bận, quên, hoặc đọc rồi lướt qua. Không có follow-up thì task đó sẽ treo mãi ở in_progress, không ai nhắc.
|
||||
|
||||
Follow-up giải quyết bằng cách **tự động gửi reminder qua channel** sau N phút nếu user chưa reply. Khi user reply → auto-clear. Vòng lặp: agent hỏi → set followup → chờ → reminder → user reply → clear → agent tiếp tục.
|
||||
|
||||
Hệ thống hoạt động ở 2 layer:
|
||||
|
||||
**LLM layer** — agent tự biết mình vừa hỏi gì (vì nó sinh ra câu hỏi), gọi `await_reply` với message/delay custom. System prompt hướng dẫn: "Khi bạn đặt câu hỏi cho user và cần chờ trả lời, gọi `await_reply`." Vấn đề: model nhỏ quên gọi tool → không có followup → task treo.
|
||||
|
||||
**System layer** — không phụ thuộc LLM nhớ, dùng heuristic đơn giản:
|
||||
- Agent vừa reply user + có in_progress tasks → **khả năng cao đang chờ user phản hồi** → auto-set followup
|
||||
- User gửi message vào channel → **đã trả lời** → auto-clear tất cả followup cho scope đó
|
||||
- Nếu LLM đã gọi `await_reply` trước (với message riêng, delay riêng) → system không ghi đè
|
||||
|
||||
System không cần phân biệt nội dung input — chỉ dựa vào pattern "agent nói + có task = chờ user" và "user nói = đã trả lời". LLM layer cho phép customize chính xác hơn, system layer là safety net.
|
||||
|
||||
#### Auto followup guardrails chi tiết
|
||||
|
||||
**Auto-clear khi user reply** (`gateway_consumer.go`):
|
||||
- User gửi message vào real channel (không phải system/delegate/dashboard)
|
||||
- System auto-clear tất cả followup cho scope `(channel, chat_id)` đó
|
||||
- Fire-and-forget goroutine, không block message processing
|
||||
|
||||
**Auto-set khi lead agent reply** (`gateway_consumer.go`):
|
||||
- Lead agent trả lời user qua real channel
|
||||
- Resolve agent → team → kiểm tra là lead agent
|
||||
- Set followup trên tất cả in_progress tasks chưa có followup (`followup_at IS NULL`)
|
||||
- Dùng team settings cho interval + max
|
||||
- Followup message = truncated last line of agent response
|
||||
- Nếu LLM đã gọi `await_reply` trước → không ghi đè (respect `followup_at IS NULL`)
|
||||
|
||||
### 2.5 Task ticker (background, 5 phút/lần)
|
||||
|
||||
Thứ tự xử lý mỗi tick cho mỗi team:
|
||||
|
||||
1. **Process followups** — query tasks có `followup_at <= NOW()` AND `status = 'in_progress'`:
|
||||
- Gửi reminder qua outbound bus: `"Reminder (N/M): {message}"`
|
||||
- Increment count, schedule next hoặc stop nếu đạt max
|
||||
- Cooldown: 5 phút per task
|
||||
|
||||
2. **Recover stale tasks** — tasks có `lock_expires_at < NOW()`:
|
||||
- Reset status → pending, clear owner + lock + followup
|
||||
- Startup: force reset ALL in_progress (assume stale)
|
||||
|
||||
3. **Re-dispatch** — pending tasks có assigned owner:
|
||||
- Gửi system message vào agent session
|
||||
- Cooldown: 10 phút per task
|
||||
|
||||
4. **Notify lead** — unassigned pending tasks + idle agents available:
|
||||
- Gửi summary cho lead: "N unassigned tasks, M available agents"
|
||||
- Cooldown: 30 phút per team
|
||||
|
||||
### 2.6 Execution locking
|
||||
|
||||
```
|
||||
ClaimTask → locked_at = now(), lock_expires_at = now() + 30min
|
||||
│
|
||||
├─ progress update → renew lock_expires_at (heartbeat)
|
||||
│
|
||||
├─ complete/fail/cancel → clear lock
|
||||
│
|
||||
└─ lock_expires_at < now() → RecoverStaleTasks reset về pending
|
||||
```
|
||||
|
||||
Lock 30 phút mặc định. `UpdateTaskProgress` renew lock — agent đang hoạt động sẽ không bị recover. Nếu agent crash/timeout → lock expire → ticker auto-recover.
|
||||
|
||||
---
|
||||
|
||||
## 3. Workspace System
|
||||
|
||||
Agents giao tiếp qua message chỉ truyền được text ngắn. Khi output lớn (report, data, code) thì message không đủ — delegation result bị truncate, mất context khi truyền giữa agents, và human không có chỗ review deliverables.
|
||||
|
||||
Workspace giải quyết bằng cách cung cấp **shared file system cho team agents** — nơi agents đọc/ghi files chung trong cùng scope. Ví dụ:
|
||||
|
||||
1. Lead delegate "research market data" cho researcher
|
||||
2. Researcher viết `market-report.md` vào workspace, tag `deliverable`
|
||||
3. Lead delegate "write summary" cho writer → writer đọc `market-report.md` (auto-injected vào context)
|
||||
4. Writer viết `summary.md`, attach vào task
|
||||
5. Human mở dashboard → thấy deliverables, review, approve
|
||||
|
||||
Nói ngắn gọn: **workspace = shared disk cho agents collaborate qua files thay vì chỉ qua messages**.
|
||||
|
||||
### 3.1 Data model
|
||||
|
||||
```sql
|
||||
team_workspace_files (
|
||||
id UUID PRIMARY KEY,
|
||||
team_id UUID NOT NULL,
|
||||
channel VARCHAR(60) NOT NULL,
|
||||
chat_id VARCHAR(255) NOT NULL,
|
||||
file_name VARCHAR(255) NOT NULL,
|
||||
mime_type VARCHAR(100),
|
||||
file_path TEXT NOT NULL, -- disk path
|
||||
size_bytes BIGINT DEFAULT 0,
|
||||
uploaded_by UUID NOT NULL, -- agent
|
||||
task_id UUID, -- optional link to task
|
||||
pinned BOOLEAN DEFAULT FALSE,
|
||||
tags TEXT[], -- deliverable, handoff, reference, draft
|
||||
metadata JSONB,
|
||||
archived_at TIMESTAMPTZ, -- soft-delete on task completion
|
||||
created_at TIMESTAMPTZ,
|
||||
updated_at TIMESTAMPTZ,
|
||||
UNIQUE(team_id, channel, chat_id, file_name)
|
||||
)
|
||||
|
||||
team_workspace_file_versions (
|
||||
id UUID PRIMARY KEY,
|
||||
file_id UUID NOT NULL,
|
||||
version INT NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
size_bytes BIGINT,
|
||||
uploaded_by UUID NOT NULL,
|
||||
created_at TIMESTAMPTZ,
|
||||
UNIQUE(file_id, version)
|
||||
)
|
||||
|
||||
team_workspace_comments (
|
||||
id UUID PRIMARY KEY,
|
||||
file_id UUID NOT NULL,
|
||||
agent_id UUID NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ
|
||||
)
|
||||
```
|
||||
|
||||
### 3.2 Tool actions
|
||||
|
||||
**Workspace Write (`workspace_write`):**
|
||||
|
||||
| Action | Quyền | Mô tả |
|
||||
|--------|-------|-------|
|
||||
| `write` | Member/Lead | Ghi 1-20 files, auto-version, advisory lock |
|
||||
| `set_template` | Lead (escalation) | Define templates auto-seed vào scope mới |
|
||||
|
||||
**Workspace Read (`workspace_read`):**
|
||||
|
||||
| Action | Quyền | Mô tả |
|
||||
|--------|-------|-------|
|
||||
| `list` | All | List files + quota info + pin/tag status |
|
||||
| `read` | All | Content (text) hoặc metadata (binary), support version |
|
||||
| `delete` | Owner / Lead (escalation) | Xóa file, lead xóa file người khác cần escalation |
|
||||
| `pin` | Lead (escalation) | Mark/unmark important files |
|
||||
| `tag` | Lead (escalation) | Set tags: deliverable, handoff, reference, draft |
|
||||
| `history` | All | Version history với sizes + contributors |
|
||||
| `comment` | All | Add file comment (max 10K chars) |
|
||||
| `comments` | All | List all comments on a file |
|
||||
|
||||
### 3.3 Scope model
|
||||
|
||||
```
|
||||
Scope = (team_id, channel, chat_id)
|
||||
|
||||
Ví dụ:
|
||||
(team-ABC, telegram, -100123456) ← Telegram group
|
||||
(team-ABC, discord, 98765) ← Discord channel
|
||||
(team-ABC, ws, user:123) ← WS dashboard session
|
||||
```
|
||||
|
||||
- **Channel scope** (mặc định): mỗi channel+chat có workspace riêng
|
||||
- **Team scope** (`workspace_enabled: true`): shared workspace cross-channel
|
||||
- Scope resolved từ context (agent loop) hoặc explicit args
|
||||
|
||||
### 3.4 Advisory lock & concurrent write safety
|
||||
|
||||
```
|
||||
Agent A writes report.md Agent B writes report.md
|
||||
│ │
|
||||
▼ ▼
|
||||
pg_try_advisory_xact_lock pg_try_advisory_xact_lock
|
||||
(hashtext("team:ch:chat:report.md"))
|
||||
│ │
|
||||
✓ acquired ✗ ErrFileLocked
|
||||
│ │
|
||||
disk write + DB upsert "file being written by
|
||||
│ another agent, try again"
|
||||
tx commit → lock released
|
||||
```
|
||||
|
||||
Lock key = `hashtext(team_id || channel || chat_id || file_name)`. Auto-released khi transaction commit.
|
||||
|
||||
### 3.5 Versioning
|
||||
|
||||
```
|
||||
report.md (current)
|
||||
report.md.v1 (previous)
|
||||
report.md.v2 (2 versions ago)
|
||||
...
|
||||
Max 5 versions. Older auto-pruned (disk + DB).
|
||||
```
|
||||
|
||||
Mỗi lần write file đã tồn tại → rename current → `.vN` → write new. `PruneOldVersions` trả về file paths đã xóa để cleanup disk.
|
||||
|
||||
### 3.6 Templates
|
||||
|
||||
Team settings cho phép define templates auto-seed khi scope được write lần đầu:
|
||||
|
||||
```json
|
||||
{
|
||||
"workspace_templates": [
|
||||
{ "file_name": "README.md", "content": "# Project\n..." },
|
||||
{ "file_name": "NOTES.md", "content": "# Notes\n..." }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Templates được seed cùng lúc với file đầu tiên được write vào scope mới.
|
||||
|
||||
### 3.7 Quota & limits
|
||||
|
||||
| Limit | Default | Configurable |
|
||||
|-------|---------|-------------|
|
||||
| Max file size | 10 MB | Hardcoded |
|
||||
| Max files per scope | 100 | Hardcoded |
|
||||
| Max file name length | 100 chars | Hardcoded |
|
||||
| Workspace quota | Unlimited | `workspace_quota_mb` setting |
|
||||
| Max comment length | 10,000 chars | Hardcoded |
|
||||
|
||||
---
|
||||
|
||||
## 4. Delegation Engine
|
||||
|
||||
### 4.1 Flow tổng quan
|
||||
|
||||
```
|
||||
Lead agent nhận user request
|
||||
│
|
||||
▼
|
||||
Quyết định delegate (LLM)
|
||||
│
|
||||
▼
|
||||
delegate tool → prepareDelegation
|
||||
│
|
||||
├─ Auto-create team task (if team_task_id omitted)
|
||||
│ └─ task_type = "delegation", auto-claim by target
|
||||
│
|
||||
├─ Inject dependency results (blocked_by task results)
|
||||
│
|
||||
├─ Inject workspace context (list files in scope)
|
||||
│
|
||||
└─ Spawn target agent run
|
||||
│
|
||||
├─ Target agent works...
|
||||
│ ├─ progress updates → renew lock
|
||||
│ ├─ workspace writes → deliverables
|
||||
│ └─ team_message → communicate
|
||||
│
|
||||
▼
|
||||
Delegation completes
|
||||
│
|
||||
┌────┴────┐
|
||||
success failure
|
||||
│ │
|
||||
▼ ▼
|
||||
autoCompleteTask autoFailTask
|
||||
│ │
|
||||
├─ update result ├─ mark failed
|
||||
├─ archive workspace ├─ record event
|
||||
├─ record event └─ persist error
|
||||
├─ persist history
|
||||
└─ flush delegate session
|
||||
```
|
||||
|
||||
### 4.2 Auto-task creation
|
||||
|
||||
Khi lead gọi `delegate` mà không pass `team_task_id`:
|
||||
- Tự tạo pending task: subject = truncated prompt, task_type = "delegation"
|
||||
- Auto-claim cho target agent (prevent pending notification spam)
|
||||
- Điều này đảm bảo mọi delegation đều có task record để track
|
||||
|
||||
### 4.3 Dependency injection
|
||||
|
||||
Nếu task có `blocked_by` đã completed, delegation context được prepend:
|
||||
|
||||
```
|
||||
[Dependency results from blocking tasks]
|
||||
Task "Research market data" (completed):
|
||||
Result: Market analysis shows...
|
||||
|
||||
---
|
||||
[Your task]
|
||||
Write report based on the research above.
|
||||
```
|
||||
|
||||
### 4.4 Workspace context injection
|
||||
|
||||
Trước khi delegate, inject danh sách workspace files available:
|
||||
|
||||
```
|
||||
[Team workspace files in this scope]
|
||||
- report-draft.md (12KB, pinned)
|
||||
- data.csv (45KB)
|
||||
Use workspace_read to access file contents.
|
||||
```
|
||||
|
||||
### 4.5 Progress notifications
|
||||
|
||||
Lead agent nhận progress update grouped per tick:
|
||||
|
||||
```
|
||||
[Delegation progress]
|
||||
• tieu-ho-shopee-1: 💭 thinking (2m 30s)
|
||||
• researcher-bot: 🔍 web_search (1m 15s)
|
||||
```
|
||||
|
||||
Controlled by team setting `progress_notifications` (default: on).
|
||||
|
||||
---
|
||||
|
||||
## 5. Team Messaging
|
||||
|
||||
### 5.1 Actions
|
||||
|
||||
| Action | Quyền | Mô tả |
|
||||
|--------|-------|-------|
|
||||
| `send` | All | Direct message to teammate, auto-creates message task |
|
||||
| `broadcast` | Lead | Message all members |
|
||||
| `read` | All | Fetch unread, auto-mark read |
|
||||
|
||||
### 5.2 Message task auto-creation
|
||||
|
||||
Khi agent gửi `send`, system tạo task:
|
||||
- `task_type = "message"`, `status = in_progress`
|
||||
- `owner_agent_id = recipient` (locked 30 min)
|
||||
- Subject = truncated message (100 chars)
|
||||
- Purpose: đảm bảo message không bị lost, recipient có task context
|
||||
|
||||
---
|
||||
|
||||
## 6. Team Settings
|
||||
|
||||
Stored as JSONB in `agent_teams.settings`:
|
||||
|
||||
```json
|
||||
{
|
||||
// Access control
|
||||
"allow_user_ids": ["user:telegram:123"],
|
||||
"deny_user_ids": [],
|
||||
"allow_channels": ["telegram"],
|
||||
"deny_channels": [],
|
||||
|
||||
// Workspace
|
||||
"workspace_enabled": true,
|
||||
"workspace_templates": [
|
||||
{ "file_name": "README.md", "content": "..." }
|
||||
],
|
||||
"workspace_quota_mb": 100,
|
||||
|
||||
// Follow-up reminders
|
||||
"followup_interval_minutes": 30, // default: 30
|
||||
"followup_max_reminders": 5, // default: 0 (unlimited)
|
||||
|
||||
// Delegation
|
||||
"progress_notifications": true,
|
||||
|
||||
// Escalation policy
|
||||
"escalation_mode": "auto", // auto | review | reject
|
||||
"escalation_actions": ["pin", "unpin", "tag", "set_template", "delete_others"]
|
||||
}
|
||||
```
|
||||
|
||||
### Access control
|
||||
|
||||
- `allow_user_ids` + `deny_user_ids`: whitelist/blacklist users
|
||||
- `allow_channels` + `deny_channels`: whitelist/blacklist channels
|
||||
- System channels (delegate, system) luôn bypass
|
||||
|
||||
### Escalation policy
|
||||
|
||||
Lead-only actions nhạy cảm (pin, tag, delete file người khác) qua escalation:
|
||||
|
||||
| Mode | Behavior |
|
||||
|------|----------|
|
||||
| `auto` (default) | LLM tự quyết reject hoặc tạo review task. Không bao giờ tự execute |
|
||||
| `review` | Luôn tạo pending task cho human approve |
|
||||
| `reject` | Từ chối thẳng, không execute |
|
||||
|
||||
---
|
||||
|
||||
## 7. Kết nối giữa các subsystem
|
||||
|
||||
### Tasks ↔ Workspace
|
||||
|
||||
- **Attachments**: task có thể link workspace files qua `team_task_attachments`
|
||||
- **Archive on complete**: khi task completed, workspace files linked bị soft-delete (trừ pinned)
|
||||
- **Deliverables**: delegation result có thể include file paths từ workspace
|
||||
- **Tag "deliverable"**: mark file là output chính của task
|
||||
|
||||
### Tasks ↔ Delegation
|
||||
|
||||
- Mỗi delegation tạo 1 task record (auto hoặc explicit)
|
||||
- Task auto-complete/fail khi delegation kết thúc
|
||||
- Dependency injection: task blocked_by results inject vào delegation context
|
||||
- Delegation history persisted riêng cho analytics
|
||||
|
||||
### Tasks ↔ Messaging
|
||||
|
||||
- `team_message send` tạo message task cho recipient
|
||||
- Team messages stored trong `team_messages` table (mailbox pattern)
|
||||
- Agent `read` unread messages khi cần
|
||||
|
||||
### Workspace ↔ Delegation
|
||||
|
||||
- Workspace context inject vào delegate trước khi chạy
|
||||
- Delegate agent có thể read/write workspace files trong scope
|
||||
- Scope dùng origin channel (không phải "delegate" channel)
|
||||
|
||||
---
|
||||
|
||||
## 8. RBAC
|
||||
|
||||
Roles per team member:
|
||||
|
||||
| Role | Tasks | Workspace | Messaging |
|
||||
|------|-------|-----------|-----------|
|
||||
| **Lead** | Full: create, cancel, update, assign | Full: pin, tag, set_template, delete any | Broadcast + send |
|
||||
| **Member** | Claim, complete, own tasks | Write, delete own, comment | Send + read |
|
||||
| **Reviewer** | Read-only, comment | Read-only, comment | Read |
|
||||
|
||||
---
|
||||
|
||||
## 9. Database schema (migrations)
|
||||
|
||||
| Migration | Nội dung |
|
||||
|-----------|----------|
|
||||
| 003 | `team_tasks` base table |
|
||||
| 004 | FTS: `tsv` tsvector column |
|
||||
| 007 | `metadata` JSONB |
|
||||
| 008 | `user_id`, `channel` columns |
|
||||
| 018 | V2: task_type, task_number, identifier, created_by, assignee, parent_id, chat_id, locking, progress + comments/events/attachments tables |
|
||||
| 019 | Follow-up: followup_at/count/max/message/channel/chat_id |
|
||||
|
||||
---
|
||||
|
||||
## 10. System prompt injection
|
||||
|
||||
Khi agent trong team, system prompt được inject:
|
||||
|
||||
- **TEAM.md**: team name, members list (key + display_name + role), lead agent, access scope
|
||||
- **DELEGATION.md**: available target agents, links, max_concurrent, max_delegation_load
|
||||
- **AVAILABILITY.md**: workspace status, pending tasks count
|
||||
- Skip on bootstrap (first run) để giảm noise
|
||||
|
||||
---
|
||||
|
||||
## 11. Future ideas
|
||||
|
||||
### Team Playbook (planned)
|
||||
|
||||
File `TEAM_PLAYBOOK.md` per team — human-authored strategy cho lead:
|
||||
- Member capabilities: ai giỏi gì
|
||||
- Delegation strategy: task nào assign cho ai
|
||||
- Rules: max concurrent tasks, escalation policy
|
||||
|
||||
### Member Capabilities (planned)
|
||||
|
||||
```json
|
||||
// agent_team_members.capabilities JSONB
|
||||
{
|
||||
"skills": ["coding", "research"],
|
||||
"languages": ["go", "python"],
|
||||
"max_concurrent_tasks": 2
|
||||
}
|
||||
```
|
||||
|
||||
### Auto-learned patterns (future)
|
||||
|
||||
Track delegation success rate per member per task type → suggest optimal assignment.
|
||||
|
||||
---
|
||||
|
||||
## 12. Known design decisions
|
||||
|
||||
1. **Followup on pending = vô nghĩa** — stale recovery clear followup fields khi reset task về pending
|
||||
2. **Process followups TRƯỚC recovery** — đảm bảo followup fire trước khi task bị reset status
|
||||
3. **Advisory lock per file** — `pg_try_advisory_xact_lock(hashtext(...))`, fail-fast thay vì wait
|
||||
4. **Task identifier per team** — sequential number + team prefix, không dùng global sequence
|
||||
5. **30 min lock** — progress update renew, balance giữa agent timeout và stale recovery
|
||||
6. **Auto followup là fallback** — LLM vẫn có thể gọi `await_reply` để customize, system chỉ fill default khi LLM không gọi
|
||||
+12
-1
@@ -36,6 +36,7 @@ import (
|
||||
"github.com/nextlevelbuilder/goclaw/internal/skills"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store/pg"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/tasks"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/tools"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/tracing"
|
||||
"github.com/nextlevelbuilder/goclaw/pkg/browser"
|
||||
@@ -1027,6 +1028,13 @@ func runGateway() {
|
||||
|
||||
go consumeInboundMessages(ctx, msgBus, agentRouter, cfg, sched, channelMgr, consumerTeamStore, quotaChecker, delegateMgr, pgStores.Sessions, pgStores.Agents, contactCollector)
|
||||
|
||||
// Task recovery ticker: re-dispatches stale/pending team tasks on startup and periodically.
|
||||
var taskTicker *tasks.TaskTicker
|
||||
if pgStores.Teams != nil {
|
||||
taskTicker = tasks.NewTaskTicker(pgStores.Teams, pgStores.Agents, msgBus, cfg.Gateway.TaskRecoveryIntervalSec)
|
||||
taskTicker.Start()
|
||||
}
|
||||
|
||||
go func() {
|
||||
sig := <-sigCh
|
||||
slog.Info("graceful shutdown initiated", "signal", sig)
|
||||
@@ -1034,9 +1042,12 @@ func runGateway() {
|
||||
// Broadcast shutdown event
|
||||
server.BroadcastEvent(*protocol.NewEvent(protocol.EventShutdown, nil))
|
||||
|
||||
// Stop channels and cron
|
||||
// Stop channels, cron, and task ticker
|
||||
channelMgr.StopAll(context.Background())
|
||||
pgStores.Cron.Stop()
|
||||
if taskTicker != nil {
|
||||
taskTicker.Stop()
|
||||
}
|
||||
|
||||
// Drain audit log queue before closing DB
|
||||
if auditCh != nil {
|
||||
|
||||
@@ -124,6 +124,12 @@ func builtinToolSeedData() []store.BuiltinToolDef {
|
||||
{Name: "team_message", DisplayName: "Team Message", Description: "Send a direct message or broadcast to teammates in the agent team", Category: "teams", Enabled: true,
|
||||
Requires: []string{"managed_mode", "teams"},
|
||||
},
|
||||
{Name: "workspace_write", DisplayName: "Workspace Write", Description: "Write files to the team shared workspace visible to all team members", Category: "teams", Enabled: true,
|
||||
Requires: []string{"managed_mode", "teams"},
|
||||
},
|
||||
{Name: "workspace_read", DisplayName: "Workspace Read", Description: "Read, list, delete, pin, and tag files in the team shared workspace", Category: "teams", Enabled: true,
|
||||
Requires: []string{"managed_mode", "teams"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+173
-9
@@ -2,6 +2,7 @@ package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
@@ -16,6 +17,7 @@ import (
|
||||
"github.com/nextlevelbuilder/goclaw/internal/channels"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/config"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/i18n"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/scheduler"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/sessions"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
@@ -159,6 +161,18 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents
|
||||
quotaChecker.Increment(userID)
|
||||
}
|
||||
|
||||
// Auto-clear followup reminders when user sends a message on a real channel.
|
||||
// Fire-and-forget: don't block message processing.
|
||||
if teamStore != nil && msg.Channel != tools.ChannelSystem && msg.Channel != tools.ChannelDelegate && msg.Channel != tools.ChannelDashboard {
|
||||
go func(ch, cid string) {
|
||||
if n, err := teamStore.ClearFollowupByScope(ctx, ch, cid); err != nil {
|
||||
slog.Warn("auto-clear followup failed", "channel", ch, "chat_id", cid, "error", err)
|
||||
} else if n > 0 {
|
||||
slog.Info("auto-clear followup: cleared", "channel", ch, "chat_id", cid, "count", n)
|
||||
}
|
||||
}(msg.Channel, msg.ChatID)
|
||||
}
|
||||
|
||||
slog.Info("inbound: scheduling message (main lane)",
|
||||
"channel", msg.Channel,
|
||||
"chat_id", msg.ChatID,
|
||||
@@ -324,7 +338,7 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents
|
||||
})
|
||||
|
||||
// Handle result asynchronously to not block the flush callback.
|
||||
go func(channel, chatID, session, rID string, meta map[string]string, blockReplyEnabled bool) {
|
||||
go func(agentKey, channel, chatID, session, rID string, meta map[string]string, blockReplyEnabled bool) {
|
||||
outcome := <-outCh
|
||||
|
||||
// Clean up run tracking (in case HandleAgentEvent didn't fire for terminal events)
|
||||
@@ -398,7 +412,12 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents
|
||||
appendMediaToOutbound(&outMsg, outcome.Result.Media)
|
||||
|
||||
msgBus.PublishOutbound(outMsg)
|
||||
}(msg.Channel, msg.ChatID, sessionKey, runID, outMeta, blockReply)
|
||||
|
||||
// Auto-set followup when lead agent replies on a real channel with in_progress tasks.
|
||||
if teamStore != nil && channel != tools.ChannelSystem && channel != tools.ChannelDelegate && channel != tools.ChannelDashboard {
|
||||
go autoSetFollowup(ctx, teamStore, agentStore, agentKey, channel, chatID, outcome.Result.Content)
|
||||
}
|
||||
}(agentID, msg.Channel, msg.ChatID, sessionKey, runID, outMeta, blockReply)
|
||||
}
|
||||
|
||||
// Inbound debounce: merge rapid messages from the same sender before processing.
|
||||
@@ -432,7 +451,7 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents
|
||||
}
|
||||
|
||||
// --- Subagent announce: bypass debounce, inject into parent agent session ---
|
||||
if msg.Channel == "system" && strings.HasPrefix(msg.SenderID, "subagent:") {
|
||||
if msg.Channel == tools.ChannelSystem && strings.HasPrefix(msg.SenderID, "subagent:") {
|
||||
origChannel := msg.Metadata["origin_channel"]
|
||||
origPeerKind := msg.Metadata["origin_peer_kind"]
|
||||
origLocalKey := msg.Metadata["origin_local_key"]
|
||||
@@ -566,7 +585,7 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents
|
||||
|
||||
// --- Delegate announce: bypass debounce, inject into parent agent session ---
|
||||
// Same pattern as subagent announce above, using "delegate" lane.
|
||||
if msg.Channel == "system" && strings.HasPrefix(msg.SenderID, "delegate:") {
|
||||
if msg.Channel == tools.ChannelSystem && strings.HasPrefix(msg.SenderID, "delegate:") {
|
||||
origChannel := msg.Metadata["origin_channel"]
|
||||
origPeerKind := msg.Metadata["origin_peer_kind"]
|
||||
origLocalKey := msg.Metadata["origin_local_key"]
|
||||
@@ -690,7 +709,7 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents
|
||||
|
||||
// --- Handoff announce: route initial message to target agent session ---
|
||||
// Same pattern as teammate message routing, using "delegate" lane.
|
||||
if msg.Channel == "system" && strings.HasPrefix(msg.SenderID, "handoff:") {
|
||||
if msg.Channel == tools.ChannelSystem && strings.HasPrefix(msg.SenderID, "handoff:") {
|
||||
origChannel := msg.Metadata["origin_channel"]
|
||||
origPeerKind := msg.Metadata["origin_peer_kind"]
|
||||
origLocalKey := msg.Metadata["origin_local_key"]
|
||||
@@ -760,7 +779,7 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents
|
||||
|
||||
// --- Teammate message: bypass debounce, route to target agent session ---
|
||||
// Same pattern as delegate announce, using "delegate" lane.
|
||||
if msg.Channel == "system" && strings.HasPrefix(msg.SenderID, "teammate:") {
|
||||
if msg.Channel == tools.ChannelSystem && strings.HasPrefix(msg.SenderID, "teammate:") {
|
||||
origChannel := msg.Metadata["origin_channel"]
|
||||
origPeerKind := msg.Metadata["origin_peer_kind"]
|
||||
origLocalKey := msg.Metadata["origin_local_key"]
|
||||
@@ -774,7 +793,13 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents
|
||||
}
|
||||
|
||||
if origChannel == "" || msg.ChatID == "" {
|
||||
slog.Warn("teammate message: missing origin", "sender", msg.SenderID)
|
||||
slog.Warn("teammate message: missing origin — DROPPED",
|
||||
"sender", msg.SenderID,
|
||||
"target", targetAgent,
|
||||
"origin_channel", origChannel,
|
||||
"chat_id", msg.ChatID,
|
||||
"user_id", msg.UserID,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -807,8 +832,45 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents
|
||||
Stream: false,
|
||||
})
|
||||
|
||||
go func(origCh, chatID, senderID string, meta map[string]string) {
|
||||
go func(origCh, chatID, senderID string, meta, inMeta map[string]string) {
|
||||
outcome := <-outCh
|
||||
|
||||
// Auto-complete/fail the associated team task (v2 only).
|
||||
if taskIDStr := inMeta["team_task_id"]; taskIDStr != "" {
|
||||
teamTaskID, _ := uuid.Parse(taskIDStr)
|
||||
teamID, _ := uuid.Parse(inMeta["team_id"])
|
||||
if teamTaskID != uuid.Nil && teamStore != nil {
|
||||
// Only auto-complete/fail for v2 teams.
|
||||
team, _ := teamStore.GetTeam(ctx, teamID)
|
||||
if team != nil && isConsumerTeamV2(team) {
|
||||
if outcome.Err != nil {
|
||||
_ = teamStore.FailTask(ctx, teamTaskID, teamID, outcome.Err.Error())
|
||||
_ = teamStore.RecordTaskEvent(ctx, &store.TeamTaskEventData{
|
||||
TaskID: teamTaskID,
|
||||
EventType: "failed",
|
||||
ActorType: "agent",
|
||||
ActorID: inMeta["to_agent"],
|
||||
})
|
||||
} else {
|
||||
result := outcome.Result.Content
|
||||
if len(outcome.Result.Deliverables) > 0 {
|
||||
result = strings.Join(outcome.Result.Deliverables, "\n\n---\n\n")
|
||||
}
|
||||
if len(result) > 100_000 {
|
||||
result = result[:100_000] + "\n[truncated]"
|
||||
}
|
||||
_ = teamStore.CompleteTask(ctx, teamTaskID, teamID, result)
|
||||
_ = teamStore.RecordTaskEvent(ctx, &store.TeamTaskEventData{
|
||||
TaskID: teamTaskID,
|
||||
EventType: "completed",
|
||||
ActorType: "agent",
|
||||
ActorID: inMeta["to_agent"],
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if outcome.Err != nil {
|
||||
slog.Error("teammate message: agent run failed", "error", outcome.Err)
|
||||
return
|
||||
@@ -827,7 +889,32 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents
|
||||
}
|
||||
appendMediaToOutbound(&outMsg, outcome.Result.Media)
|
||||
msgBus.PublishOutbound(outMsg)
|
||||
}(origChannel, msg.ChatID, msg.SenderID, outMeta)
|
||||
}(origChannel, msg.ChatID, msg.SenderID, outMeta, msg.Metadata)
|
||||
continue
|
||||
}
|
||||
|
||||
// --- Command: /reset — clear session history ---
|
||||
if msg.Metadata["command"] == "reset" {
|
||||
agentID := msg.AgentID
|
||||
if agentID == "" {
|
||||
agentID = resolveAgentRoute(cfg, msg.Channel, msg.ChatID, msg.PeerKind)
|
||||
}
|
||||
peerKind := msg.PeerKind
|
||||
if peerKind == "" {
|
||||
peerKind = string(sessions.PeerDirect)
|
||||
}
|
||||
sessionKey := sessions.BuildScopedSessionKey(agentID, msg.Channel, sessions.PeerKind(peerKind), msg.ChatID, cfg.Sessions.Scope, cfg.Sessions.DmScope, cfg.Sessions.MainKey)
|
||||
if msg.Metadata["is_forum"] == "true" && peerKind == string(sessions.PeerGroup) {
|
||||
var topicID int
|
||||
fmt.Sscanf(msg.Metadata["message_thread_id"], "%d", &topicID)
|
||||
if topicID > 0 {
|
||||
sessionKey = sessions.BuildGroupTopicSessionKey(agentID, msg.Channel, msg.ChatID, topicID)
|
||||
}
|
||||
}
|
||||
sessStore.Reset(sessionKey)
|
||||
sessStore.Save(sessionKey)
|
||||
providers.ResetCLISession("", sessionKey)
|
||||
slog.Info("inbound: /reset command", "session", sessionKey)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -903,6 +990,83 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents
|
||||
}
|
||||
}
|
||||
|
||||
// autoSetFollowup sets followup reminders on in_progress tasks when the lead agent
|
||||
// replies on a real channel. Only sets followup if the task doesn't already have one
|
||||
// (respects LLM-initiated await_reply). Fire-and-forget, logs errors.
|
||||
func autoSetFollowup(ctx context.Context, teamStore store.TeamStore, agentStore store.AgentStore, agentKey, channel, chatID, content string) {
|
||||
if agentStore == nil {
|
||||
return
|
||||
}
|
||||
// agentKey may be a slug ("default") or a UUID string (from WS clients).
|
||||
var ag *store.AgentData
|
||||
var err error
|
||||
if id, parseErr := uuid.Parse(agentKey); parseErr == nil {
|
||||
ag, err = agentStore.GetByID(ctx, id)
|
||||
} else {
|
||||
ag, err = agentStore.GetByKey(ctx, agentKey)
|
||||
}
|
||||
if err != nil || ag == nil {
|
||||
return
|
||||
}
|
||||
team, err := teamStore.GetTeamForAgent(ctx, ag.ID)
|
||||
if err != nil || team == nil || team.LeadAgentID != ag.ID {
|
||||
return // only lead agent triggers auto-set
|
||||
}
|
||||
// Followup is a v2 feature.
|
||||
if !isConsumerTeamV2(team) {
|
||||
return
|
||||
}
|
||||
|
||||
interval, max := parseFollowupSettings(team)
|
||||
followupAt := time.Now().Add(interval)
|
||||
msg := truncateForReminder(content, 200)
|
||||
|
||||
n, err := teamStore.SetFollowupForActiveTasks(ctx, team.ID, channel, chatID, followupAt, max, msg)
|
||||
if err != nil {
|
||||
slog.Warn("auto-set followup failed", "channel", channel, "chat_id", chatID, "error", err)
|
||||
} else if n > 0 {
|
||||
slog.Info("auto-set followup: set", "channel", channel, "chat_id", chatID, "count", n, "followup_at", followupAt)
|
||||
}
|
||||
}
|
||||
|
||||
// isConsumerTeamV2 delegates to tools.IsTeamV2 for version checking.
|
||||
var isConsumerTeamV2 = tools.IsTeamV2
|
||||
|
||||
// parseFollowupSettings extracts followup interval and max reminders from team settings.
|
||||
func parseFollowupSettings(team *store.TeamData) (time.Duration, int) {
|
||||
const (
|
||||
defaultIntervalMins = 30
|
||||
defaultMax = 0 // unlimited
|
||||
)
|
||||
if team.Settings == nil {
|
||||
return time.Duration(defaultIntervalMins) * time.Minute, defaultMax
|
||||
}
|
||||
var settings map[string]any
|
||||
if json.Unmarshal(team.Settings, &settings) != nil {
|
||||
return time.Duration(defaultIntervalMins) * time.Minute, defaultMax
|
||||
}
|
||||
interval := defaultIntervalMins
|
||||
if v, ok := settings["followup_interval_minutes"].(float64); ok && v > 0 {
|
||||
interval = int(v)
|
||||
}
|
||||
max := defaultMax
|
||||
if v, ok := settings["followup_max_reminders"].(float64); ok && v >= 0 {
|
||||
max = int(v)
|
||||
}
|
||||
return time.Duration(interval) * time.Minute, max
|
||||
}
|
||||
|
||||
// truncateForReminder truncates content to maxLen chars, taking the last line as context.
|
||||
func truncateForReminder(content string, maxLen int) string {
|
||||
// Use last non-empty line as it's typically the most relevant.
|
||||
lines := strings.Split(strings.TrimSpace(content), "\n")
|
||||
msg := lines[len(lines)-1]
|
||||
if len(msg) > maxLen {
|
||||
msg = msg[:maxLen] + "..."
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
// appendMediaToOutbound converts agent MediaResults to outbound MediaAttachments
|
||||
// on the given OutboundMessage. Handles voice annotation when applicable.
|
||||
func appendMediaToOutbound(msg *bus.OutboundMessage, media []agent.MediaResult) {
|
||||
|
||||
@@ -406,6 +406,8 @@ func wireExtras(
|
||||
TeamID: req.TeamID,
|
||||
TeamTaskID: req.TeamTaskID,
|
||||
ParentAgentID: req.ParentAgentID,
|
||||
WorkspaceChannel: req.WorkspaceChannel,
|
||||
WorkspaceChatID: req.WorkspaceChatID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -450,7 +452,7 @@ func wireExtras(
|
||||
toolsReg.Register(tools.NewEvaluateLoopTool(delegateMgr))
|
||||
|
||||
// Handoff tool (agent-to-agent conversation transfer)
|
||||
toolsReg.Register(tools.NewHandoffTool(delegateMgr, stores.Teams, stores.Sessions, msgBus))
|
||||
toolsReg.Register(tools.NewHandoffTool(delegateMgr, stores.Teams, stores.Sessions, msgBus, workspace))
|
||||
|
||||
// Inject delegation capability into existing SpawnTool
|
||||
if st, ok := toolsReg.Get("spawn"); ok {
|
||||
@@ -484,7 +486,7 @@ func wireExtras(
|
||||
slog.Info("delegate + delegate_search tools registered")
|
||||
}
|
||||
|
||||
// Register team tools (team_tasks + team_message) if team store is available.
|
||||
// Register team tools (team_tasks + team_message + workspace) if team store is available.
|
||||
if stores.Teams != nil && stores.Agents != nil {
|
||||
teamMgr := tools.NewTeamToolManager(stores.Teams, stores.Agents, msgBus)
|
||||
if delegateMgr != nil {
|
||||
@@ -492,6 +494,9 @@ func wireExtras(
|
||||
}
|
||||
toolsReg.Register(tools.NewTeamTasksTool(teamMgr))
|
||||
toolsReg.Register(tools.NewTeamMessageTool(teamMgr))
|
||||
toolsReg.Register(tools.NewWorkspaceWriteTool(teamMgr, workspace))
|
||||
toolsReg.Register(tools.NewWorkspaceReadTool(teamMgr, workspace))
|
||||
slog.Info("team + workspace tools registered", "workspace", workspace)
|
||||
|
||||
// Team cache invalidation via pub/sub
|
||||
msgBus.Subscribe(bus.TopicCacheTeam, func(event bus.Event) {
|
||||
|
||||
+18
-6
@@ -78,6 +78,14 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
|
||||
ctx = tools.WithSandboxConfig(ctx, l.sandboxCfg)
|
||||
}
|
||||
|
||||
// Workspace scope propagation (delegation origin → workspace tools).
|
||||
if req.WorkspaceChannel != "" {
|
||||
ctx = tools.WithWorkspaceChannel(ctx, req.WorkspaceChannel)
|
||||
}
|
||||
if req.WorkspaceChatID != "" {
|
||||
ctx = tools.WithWorkspaceChatID(ctx, req.WorkspaceChatID)
|
||||
}
|
||||
|
||||
// Per-user workspace isolation.
|
||||
// Workspace path comes from user_agent_profiles (includes channel segment
|
||||
// for cross-channel isolation). Cached in userWorkspaces to avoid repeated DB queries.
|
||||
@@ -289,13 +297,17 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
|
||||
l.enrichVideoIDs(messages, mediaRefs)
|
||||
}
|
||||
|
||||
// 2e. Cross-session recovery: notify team leads about orphaned pending tasks
|
||||
// and in-progress tasks being handled by delegates.
|
||||
// 2e. Cross-session recovery: recover stale locked tasks and notify team leads
|
||||
// about orphaned pending tasks and in-progress tasks being handled by delegates.
|
||||
// Safe because Bước 1 (early ClaimTask) ensures running tasks are in_progress,
|
||||
// so only truly un-spawned tasks remain pending.
|
||||
if l.teamStore != nil && l.agentUUID != uuid.Nil {
|
||||
if team, _ := l.teamStore.GetTeamForAgent(ctx, l.agentUUID); team != nil && team.LeadAgentID == l.agentUUID {
|
||||
if tasks, err := l.teamStore.ListTasks(ctx, team.ID, "newest", "", req.UserID); err == nil {
|
||||
// Recover tasks with expired locks (stale in_progress → pending)
|
||||
if recovered, err := l.teamStore.RecoverStaleTasks(ctx, team.ID); err == nil && recovered > 0 {
|
||||
slog.Info("recovered stale tasks", "team", team.ID, "count", recovered)
|
||||
}
|
||||
if tasks, err := l.teamStore.ListTasks(ctx, team.ID, "newest", "", req.UserID, "", ""); err == nil {
|
||||
var stale []string
|
||||
var inProgress []string
|
||||
for _, t := range tasks {
|
||||
@@ -551,13 +563,13 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
|
||||
|
||||
// No tool calls → done
|
||||
if len(resp.ToolCalls) == 0 {
|
||||
// Guard: detect orphaned team_tasks create (created but not spawned).
|
||||
// Guard: detect orphaned team_tasks create (created but not spawned) — v2 only.
|
||||
// Query DB for actual pending tasks instead of just counting tool calls,
|
||||
// because auto-created tasks (from spawn without team_task_id) bypass the counter.
|
||||
if teamTaskCreates > teamTaskSpawns && !teamTaskRetried {
|
||||
if l.teamStore != nil && l.agentUUID != uuid.Nil {
|
||||
if team, _ := l.teamStore.GetTeamForAgent(ctx, l.agentUUID); team != nil {
|
||||
if tasks, err := l.teamStore.ListTasks(ctx, team.ID, "newest", "", req.UserID); err == nil {
|
||||
if team, _ := l.teamStore.GetTeamForAgent(ctx, l.agentUUID); team != nil && tools.IsTeamV2(team) {
|
||||
if tasks, err := l.teamStore.ListTasks(ctx, team.ID, "newest", "", req.UserID, "", ""); err == nil {
|
||||
var pendingIDs []string
|
||||
for _, t := range tasks {
|
||||
if t.Status == store.TeamTaskStatusPending {
|
||||
|
||||
@@ -344,6 +344,10 @@ type RunRequest struct {
|
||||
TeamID string // team ID (if delegation is team-scoped)
|
||||
TeamTaskID string // team task ID (if delegation has an associated task)
|
||||
ParentAgentID string // parent agent key that initiated the delegation
|
||||
|
||||
// Workspace scope propagation (set by delegation, read by workspace tools)
|
||||
WorkspaceChannel string
|
||||
WorkspaceChatID string
|
||||
}
|
||||
|
||||
// RunResult is the output of a completed agent run.
|
||||
|
||||
@@ -189,7 +189,7 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc {
|
||||
hasTeam = true
|
||||
contextFiles = append(contextFiles, bootstrap.ContextFile{
|
||||
Path: bootstrap.TeamFile,
|
||||
Content: buildTeamMD(team, members, ag.ID),
|
||||
Content: buildTeamMD(team, members, ag.ID, tools.IsTeamV2(team)),
|
||||
})
|
||||
// Detect lead role for tool policy
|
||||
for _, m := range members {
|
||||
@@ -366,7 +366,7 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc {
|
||||
Sessions: deps.Sessions,
|
||||
Tools: toolsReg,
|
||||
ToolPolicy: deps.ToolPolicy,
|
||||
AgentToolPolicy: agentToolPolicyForTeam(agentToolPolicyWithMCP(ag.ParseToolsConfig(), hasMCPTools), isTeamLead),
|
||||
AgentToolPolicy: agentToolPolicyForTeam(agentToolPolicyWithWorkspace(agentToolPolicyWithMCP(ag.ParseToolsConfig(), hasMCPTools), hasTeam), isTeamLead),
|
||||
SkillsLoader: deps.Skills,
|
||||
SkillAllowList: skillAllowList,
|
||||
HasMemory: hasMemory,
|
||||
|
||||
@@ -82,7 +82,8 @@ Do NOT guess agent keys. Always search first.
|
||||
|
||||
// buildTeamMD generates compact TEAM.md content for an agent that is part of a team.
|
||||
// Kept minimal — tool descriptions already live in tool Parameters()/Description().
|
||||
func buildTeamMD(team *store.TeamData, members []store.TeamMemberData, selfID uuid.UUID) string {
|
||||
// isV2 controls whether advanced sections (orchestration, followup, review) are rendered.
|
||||
func buildTeamMD(team *store.TeamData, members []store.TeamMemberData, selfID uuid.UUID, isV2 bool) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("# Team: " + team.Name + "\n")
|
||||
if team.Description != "" {
|
||||
@@ -144,65 +145,34 @@ func buildTeamMD(team *store.TeamData, members []store.TeamMemberData, selfID uu
|
||||
// Workflow guidance
|
||||
sb.WriteString("\n## Workflow\n\n")
|
||||
if selfRole == store.TeamRoleLead {
|
||||
sb.WriteString("**ONE delegation = ONE spawn call.** The system auto-creates a tracking task for each delegation.\n")
|
||||
sb.WriteString("When delegating to multiple agents, call `spawn` once per agent.\n\n")
|
||||
sb.WriteString("Example (2 agents):\n")
|
||||
sb.WriteString("```\n")
|
||||
sb.WriteString("spawn agent=artist, task=\"Create illustration for...\", label=\"Create illustration\"\n")
|
||||
sb.WriteString("spawn agent=writer, task=\"Write caption for...\", label=\"Write caption\"\n")
|
||||
sb.WriteString("```\n\n")
|
||||
sb.WriteString("The `label` parameter sets the task title on the board (keep it short).\n")
|
||||
sb.WriteString("Each task auto-completes when its delegation finishes.\n")
|
||||
sb.WriteString("Do NOT respond with text before spawning — call all spawns first, then briefly tell the user what you delegated.\n")
|
||||
sb.WriteString("Do NOT add confirmations like \"Done!\", \"Xong rồi!\", \"Got it!\" — just state what was assigned.\n\n")
|
||||
sb.WriteString("When multiple delegations run in parallel, the system collects ALL results and delivers\n")
|
||||
sb.WriteString("them to you in a single combined notification. Do NOT present partial results.\n\n")
|
||||
sb.WriteString("Advanced: For dependency chains, use `team_tasks` action=create with blocked_by,\n")
|
||||
sb.WriteString("then `spawn` with team_task_id=<the created id>.\n\n")
|
||||
sb.WriteString("## Orchestration Patterns\n\n")
|
||||
sb.WriteString("You can orchestrate multiple rounds — not just one-shot parallel delegation:\n")
|
||||
sb.WriteString("- **Sequential**: A finishes → review result → delegate to B with A's output as context\n")
|
||||
sb.WriteString("- **Iterative**: A produces draft → delegate to B for review → delegate back to A with feedback\n")
|
||||
sb.WriteString("- **Mixed**: A+B in parallel → review both → delegate to C combining their outputs\n\n")
|
||||
sb.WriteString("After receiving delegation results, decide: present to user (if done) or continue orchestrating.\n\n")
|
||||
sb.WriteString("**Communication**: When updating the user, distinguish between:\n")
|
||||
sb.WriteString("- First delegation round → \"assigning to team\" / notifying who is working on what\n")
|
||||
sb.WriteString("- Follow-up rounds (after receiving results) → \"updating tasks\" / sharing progress and next steps\n")
|
||||
sb.WriteString("Never repeat the same announcement phrasing for follow-up delegations.\n\n")
|
||||
sb.WriteString("`team_tasks` actions:\n")
|
||||
sb.WriteString("- action=list → active tasks (pending/in_progress/blocked), no results shown\n")
|
||||
sb.WriteString("- action=list, status=all → all tasks including completed\n")
|
||||
sb.WriteString("- action=get, task_id=<id> → full task detail with result\n")
|
||||
sb.WriteString("- action=search, query=<text> → search tasks by subject/description\n")
|
||||
sb.WriteString("- action=complete, task_id=<id>, result=<summary> → manually complete a task\n")
|
||||
sb.WriteString("- action=cancel, task_id=<id>, reason=<why> → cancel a pending task that is no longer needed\n\n")
|
||||
sb.WriteString("For simple questions about team composition, answer directly from the member list above.\n\n")
|
||||
sb.WriteString("## Quality Review (evaluate_loop)\n\n")
|
||||
sb.WriteString("For important tasks that need quality review, use `evaluate_loop`:\n")
|
||||
sb.WriteString("- generator: the agent who creates content\n")
|
||||
sb.WriteString("- evaluator: a team reviewer (see Reviewers section above)\n")
|
||||
sb.WriteString("- Use when: content for publishing, formal documents, quality-critical output\n")
|
||||
sb.WriteString("- Don't use for: simple questions, quick tasks, internal notes\n\n")
|
||||
sb.WriteString("Example: `evaluate_loop(generator=\"writer\", evaluator=\"reviewer\", task=\"...\", pass_criteria=\"...\")`\n\n")
|
||||
sb.WriteString("## Handoff vs Spawn\n\n")
|
||||
sb.WriteString("- Do NOT use `handoff` to delegate tasks. Use `spawn` instead.\n")
|
||||
sb.WriteString("- `handoff` transfers the ENTIRE conversation — the user will talk directly to the other agent.\n")
|
||||
sb.WriteString("- Only use `handoff` when the user explicitly asks to be transferred/switched to another agent.\n")
|
||||
sb.WriteString("**ONE delegation = ONE spawn call.** System auto-creates a tracking task per delegation.\n")
|
||||
sb.WriteString("The `label` parameter sets the task title (keep it short). Tasks auto-complete when delegation finishes.\n\n")
|
||||
sb.WriteString("Rules:\n")
|
||||
sb.WriteString("- Call all spawns first, then briefly tell the user what you delegated\n")
|
||||
sb.WriteString("- Do NOT add confirmations (\"Done!\", \"Got it!\") — just state what was assigned\n")
|
||||
sb.WriteString("- Parallel results arrive in a single combined notification — do NOT present partial results\n")
|
||||
sb.WriteString("- For dependency chains: `team_tasks` create with blocked_by, then `spawn` with team_task_id\n")
|
||||
|
||||
if isV2 {
|
||||
sb.WriteString("\n## Orchestration Patterns\n\n")
|
||||
sb.WriteString("- **Sequential**: A finishes → review → delegate to B with A's output\n")
|
||||
sb.WriteString("- **Iterative**: A drafts → B reviews → A revises with feedback\n")
|
||||
sb.WriteString("- **Mixed**: A+B parallel → review both → C combines outputs\n\n")
|
||||
sb.WriteString("After results: present to user (if done) or continue orchestrating.\n")
|
||||
sb.WriteString("Vary announcement phrasing between delegation rounds.\n")
|
||||
|
||||
sb.WriteString("\n## Follow-up Reminders\n\n")
|
||||
sb.WriteString("When waiting for user reply: create+claim task, then `await_reply` with text=<reminder>.\n")
|
||||
sb.WriteString("System auto-sends reminders. Call `clear_followup` when user replies.\n")
|
||||
}
|
||||
|
||||
sb.WriteString("\nFor simple questions about team composition, answer directly from the member list above.\n")
|
||||
} else {
|
||||
if selfRole == store.TeamRoleReviewer {
|
||||
sb.WriteString("You are a **reviewer**. You may be used as an evaluator in `evaluate_loop`.\n")
|
||||
sb.WriteString("When evaluating, respond with **APPROVED** or **REJECTED: <feedback>**.\n\n")
|
||||
sb.WriteString("You are a **reviewer**. When evaluating, respond with **APPROVED** or **REJECTED: <feedback>**.\n\n")
|
||||
}
|
||||
sb.WriteString("As a member, when you receive a delegated task, just do the work.\n")
|
||||
sb.WriteString("Task completion is handled automatically by the system.\n\n")
|
||||
sb.WriteString("For long-running tasks, send progress updates to your lead:\n")
|
||||
sb.WriteString("`team_message` action=send, to=<lead_key>, text=<progress update>\n\n")
|
||||
sb.WriteString("`team_tasks` actions:\n")
|
||||
sb.WriteString("- action=list → check team task board (active tasks)\n")
|
||||
sb.WriteString("- action=get, task_id=<id> → read a completed task's full result\n")
|
||||
sb.WriteString("- action=search, query=<text> → search tasks\n\n")
|
||||
sb.WriteString("Use `team_message` to send progress updates to your team lead (one-way, no response expected).\n\n")
|
||||
sb.WriteString("For simple questions about team composition, answer directly from the member list above.\n")
|
||||
sb.WriteString("As a member, just do the delegated work. Task completion is automatic.\n")
|
||||
sb.WriteString("For long-running tasks, send progress updates via `team_message` action=send.\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
@@ -241,3 +211,21 @@ func agentToolPolicyWithMCP(policy *config.ToolPolicySpec, hasMCP bool) *config.
|
||||
policy.AlsoAllow = append(policy.AlsoAllow, "group:mcp")
|
||||
return policy
|
||||
}
|
||||
|
||||
// agentToolPolicyWithWorkspace injects workspace_write and workspace_read into
|
||||
// alsoAllow when the agent belongs to a team, ensuring the PolicyEngine doesn't
|
||||
// block them even if the agent has a restrictive allow list.
|
||||
func agentToolPolicyWithWorkspace(policy *config.ToolPolicySpec, hasTeam bool) *config.ToolPolicySpec {
|
||||
if !hasTeam {
|
||||
return policy
|
||||
}
|
||||
if policy == nil {
|
||||
policy = &config.ToolPolicySpec{}
|
||||
}
|
||||
for _, tool := range []string{"workspace_write", "workspace_read"} {
|
||||
if !slices.Contains(policy.AlsoAllow, tool) {
|
||||
policy.AlsoAllow = append(policy.AlsoAllow, tool)
|
||||
}
|
||||
}
|
||||
return policy
|
||||
}
|
||||
@@ -88,6 +88,8 @@ var coreToolSummaries = map[string]string{
|
||||
"delegate_search": "Search for agents by expertise to find the right delegation target",
|
||||
"team_tasks": "Manage team task board (list, create, complete, cancel tasks)",
|
||||
"team_message": "Send messages to teammates (progress updates, questions)",
|
||||
"workspace_write": "Write files to the team shared workspace (visible to all team members)",
|
||||
"workspace_read": "Read, list, delete, pin, tag files in the team shared workspace",
|
||||
|
||||
// Claude Code tool aliases — enable Claude Code skills without modification
|
||||
"Read": "Alias for read_file — Read file contents",
|
||||
@@ -187,6 +189,11 @@ func BuildSystemPrompt(cfg SystemPromptConfig) string {
|
||||
// 6. ## Workspace (sandbox-aware: show container workdir when sandboxed)
|
||||
lines = append(lines, buildWorkspaceSection(cfg.Workspace, cfg.SandboxEnabled, cfg.SandboxContainerDir)...)
|
||||
|
||||
// 6.3. ## Team Workspace (when workspace tools are available)
|
||||
if hasTeamWorkspace(cfg.ToolNames) {
|
||||
lines = append(lines, buildTeamWorkspaceSection()...)
|
||||
}
|
||||
|
||||
// 6.5 ## Sandbox (matching TS sandboxInfo section)
|
||||
if cfg.SandboxEnabled {
|
||||
lines = append(lines, buildSandboxSection(cfg)...)
|
||||
@@ -320,6 +327,10 @@ func buildToolingSection(toolNames []string, hasSandbox bool) []string {
|
||||
)
|
||||
}
|
||||
lines = append(lines,
|
||||
"",
|
||||
"IMPORTANT: The tool list above is the AUTHORITATIVE set of currently available tools, re-evaluated every turn.",
|
||||
"If earlier messages in this conversation say a tool is \"not available\" or \"not configured\", IGNORE those statements — they are outdated.",
|
||||
"Only this system prompt reflects the current tool availability. Trust this list, not conversation history.",
|
||||
"",
|
||||
"TOOLS.md (if present in workspace) is user guidance — it does NOT control tool availability.",
|
||||
"Do not poll subagents or sessions in loops; completion is push-based.",
|
||||
|
||||
@@ -3,6 +3,7 @@ package agent
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -377,3 +378,28 @@ func hasBootstrapFile(files []bootstrap.ContextFile) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// hasTeamWorkspace checks if workspace_write is in the tool list.
|
||||
func hasTeamWorkspace(toolNames []string) bool {
|
||||
return slices.Contains(toolNames, "workspace_write")
|
||||
}
|
||||
|
||||
// buildTeamWorkspaceSection generates guidance for team workspace tools.
|
||||
func buildTeamWorkspaceSection() []string {
|
||||
return []string{
|
||||
"## Team Shared Workspace",
|
||||
"",
|
||||
"You are part of a team. Use the shared workspace to collaborate with teammates:",
|
||||
"",
|
||||
"- **workspace_write**: Write files to share with the team (reports, data, code, notes).",
|
||||
" Use this instead of write_file when the output needs to be shared with teammates.",
|
||||
"- **workspace_read**: List, read, delete, pin, or tag shared files.",
|
||||
"",
|
||||
"Guidelines:",
|
||||
"- When producing deliverables or intermediate results for the team, ALWAYS use workspace_write (not write_file).",
|
||||
"- write_file is for your private workspace only. workspace_write is for team-shared files.",
|
||||
"- Use workspace_read action=list to see what files teammates have shared.",
|
||||
"- Before starting work, check the workspace for relevant files from other team members.",
|
||||
"",
|
||||
}
|
||||
}
|
||||
+26
-2
@@ -22,18 +22,30 @@ type MessageBus struct {
|
||||
|
||||
func New() *MessageBus {
|
||||
return &MessageBus{
|
||||
inbound: make(chan InboundMessage, 100),
|
||||
outbound: make(chan OutboundMessage, 100),
|
||||
inbound: make(chan InboundMessage, 500),
|
||||
outbound: make(chan OutboundMessage, 500),
|
||||
handlers: make(map[string]MessageHandler),
|
||||
subscribers: make(map[string]EventHandler),
|
||||
}
|
||||
}
|
||||
|
||||
// PublishInbound queues an inbound message from a channel.
|
||||
// Blocks if the inbound buffer is full.
|
||||
func (mb *MessageBus) PublishInbound(msg InboundMessage) {
|
||||
mb.inbound <- msg
|
||||
}
|
||||
|
||||
// TryPublishInbound attempts to queue an inbound message without blocking.
|
||||
// Returns false if the inbound buffer is full (message dropped).
|
||||
func (mb *MessageBus) TryPublishInbound(msg InboundMessage) bool {
|
||||
select {
|
||||
case mb.inbound <- msg:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ConsumeInbound blocks until an inbound message is available or ctx is cancelled.
|
||||
func (mb *MessageBus) ConsumeInbound(ctx context.Context) (InboundMessage, bool) {
|
||||
select {
|
||||
@@ -45,10 +57,22 @@ func (mb *MessageBus) ConsumeInbound(ctx context.Context) (InboundMessage, bool)
|
||||
}
|
||||
|
||||
// PublishOutbound queues an outbound message to a channel.
|
||||
// Blocks if the outbound buffer is full.
|
||||
func (mb *MessageBus) PublishOutbound(msg OutboundMessage) {
|
||||
mb.outbound <- msg
|
||||
}
|
||||
|
||||
// TryPublishOutbound attempts to queue an outbound message without blocking.
|
||||
// Returns false if the outbound buffer is full (message dropped).
|
||||
func (mb *MessageBus) TryPublishOutbound(msg OutboundMessage) bool {
|
||||
select {
|
||||
case mb.outbound <- msg:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// SubscribeOutbound blocks until an outbound message is available or ctx is cancelled.
|
||||
func (mb *MessageBus) SubscribeOutbound(ctx context.Context) (OutboundMessage, bool) {
|
||||
select {
|
||||
|
||||
@@ -81,7 +81,7 @@ func (c *Channel) handleTasksList(ctx context.Context, chatID int64, isGroup boo
|
||||
return
|
||||
}
|
||||
|
||||
tasks, err := c.teamStore.ListTasks(ctx, team.ID, "newest", store.TeamTaskFilterAll, taskUserID(c.Name(), chatID, isGroup))
|
||||
tasks, err := c.teamStore.ListTasks(ctx, team.ID, "newest", store.TeamTaskFilterAll, taskUserID(c.Name(), chatID, isGroup), "", "")
|
||||
if err != nil {
|
||||
slog.Warn("tasks command: ListTasks failed", "error", err)
|
||||
send("Failed to list tasks. Please try again.")
|
||||
@@ -173,7 +173,7 @@ func (c *Channel) handleTaskDetail(ctx context.Context, chatID int64, text strin
|
||||
return
|
||||
}
|
||||
|
||||
tasks, err := c.teamStore.ListTasks(ctx, team.ID, "newest", store.TeamTaskFilterAll, taskUserID(c.Name(), chatID, isGroup))
|
||||
tasks, err := c.teamStore.ListTasks(ctx, team.ID, "newest", store.TeamTaskFilterAll, taskUserID(c.Name(), chatID, isGroup), "", "")
|
||||
if err != nil {
|
||||
slog.Warn("task_detail command: ListTasks failed", "error", err)
|
||||
send("Failed to list tasks. Please try again.")
|
||||
@@ -236,7 +236,7 @@ func (c *Channel) handleCallbackQuery(ctx context.Context, query *telego.Callbac
|
||||
return
|
||||
}
|
||||
|
||||
tasks, err := c.teamStore.ListTasks(ctx, team.ID, "newest", store.TeamTaskFilterAll, taskUserID(c.Name(), chatID, isGroup))
|
||||
tasks, err := c.teamStore.ListTasks(ctx, team.ID, "newest", store.TeamTaskFilterAll, taskUserID(c.Name(), chatID, isGroup), "", "")
|
||||
if err != nil {
|
||||
send("Failed to list tasks.")
|
||||
return
|
||||
|
||||
@@ -276,8 +276,9 @@ type GatewayConfig struct {
|
||||
InjectionAction string `json:"injection_action,omitempty"` // prompt injection action: "log", "warn" (default), "block", "off"
|
||||
InboundDebounceMs int `json:"inbound_debounce_ms,omitempty"` // merge rapid messages from same sender (default 1000ms, -1 = disabled)
|
||||
Quota *QuotaConfig `json:"quota,omitempty"` // per-user/group request quotas
|
||||
BlockReply *bool `json:"block_reply,omitempty"` // deliver intermediate text during tool iterations (default false)
|
||||
ToolStatus *bool `json:"tool_status,omitempty"` // show tool name in streaming preview during tool execution (default true)
|
||||
BlockReply *bool `json:"block_reply,omitempty"` // deliver intermediate text during tool iterations (default false)
|
||||
ToolStatus *bool `json:"tool_status,omitempty"` // show tool name in streaming preview during tool execution (default true)
|
||||
TaskRecoveryIntervalSec int `json:"task_recovery_interval_sec,omitempty"` // team task recovery ticker interval in seconds (default 300 = 5min)
|
||||
}
|
||||
|
||||
// ToolsConfig controls tool availability, policy, and web search.
|
||||
|
||||
@@ -3,6 +3,7 @@ package methods
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/agent"
|
||||
@@ -50,6 +51,13 @@ func (m *TeamsMethods) Register(router *gateway.MethodRouter) {
|
||||
router.Register(protocol.MethodTeamsMembersRemove, m.handleRemoveMember)
|
||||
router.Register(protocol.MethodTeamsUpdate, m.handleUpdate)
|
||||
router.Register(protocol.MethodTeamsKnownUsers, m.handleKnownUsers)
|
||||
router.Register(protocol.MethodTeamsScopes, m.handleScopes)
|
||||
|
||||
// Workspace handlers
|
||||
m.RegisterWorkspace(router)
|
||||
|
||||
// Task detail handlers
|
||||
m.RegisterTasks(router)
|
||||
}
|
||||
|
||||
// --- List ---
|
||||
@@ -112,6 +120,13 @@ func (m *TeamsMethods) handleCreate(ctx context.Context, client *gateway.Client,
|
||||
return
|
||||
}
|
||||
|
||||
// Enforce single-team leadership: an agent can only lead one team.
|
||||
if existingTeam, _ := m.teamStore.GetTeamForAgent(ctx, leadAgent.ID); existingTeam != nil && existingTeam.LeadAgentID == leadAgent.ID {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest,
|
||||
fmt.Sprintf("agent %q already leads team %q — each agent can only lead one team", params.Lead, existingTeam.Name)))
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve member agents
|
||||
var memberAgents []*store.AgentData
|
||||
for _, memberKey := range params.Members {
|
||||
|
||||
@@ -3,6 +3,7 @@ package methods
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
@@ -125,9 +126,10 @@ func (m *TeamsMethods) handleDelete(ctx context.Context, client *gateway.Client,
|
||||
// --- Task List (admin view) ---
|
||||
|
||||
type teamsTaskListParams struct {
|
||||
TeamID string `json:"teamId"`
|
||||
StatusFilter string `json:"statusFilter,omitempty"`
|
||||
UserID string `json:"userId,omitempty"`
|
||||
TeamID string `json:"teamId"`
|
||||
Status string `json:"status"` // "" = active, "completed", "all"
|
||||
Channel string `json:"channel"` // scope filter
|
||||
ChatID string `json:"chatId"` // scope filter
|
||||
}
|
||||
|
||||
func (m *TeamsMethods) handleTaskList(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
||||
@@ -154,13 +156,9 @@ func (m *TeamsMethods) handleTaskList(ctx context.Context, client *gateway.Clien
|
||||
return
|
||||
}
|
||||
|
||||
statusFilter := params.StatusFilter
|
||||
if statusFilter == "" {
|
||||
statusFilter = store.TeamTaskFilterAll
|
||||
}
|
||||
|
||||
tasks, err := m.teamStore.ListTasks(ctx, teamID, "newest", statusFilter, params.UserID)
|
||||
tasks, err := m.teamStore.ListTasks(ctx, teamID, "newest", params.Status, "", params.Channel, params.ChatID)
|
||||
if err != nil {
|
||||
slog.Warn("teams.tasks.list failed", "team_id", teamID, "status_filter", params.Status, "error", err)
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error()))
|
||||
return
|
||||
}
|
||||
@@ -209,13 +207,21 @@ func (m *TeamsMethods) handleUpdate(ctx context.Context, client *gateway.Client,
|
||||
return
|
||||
}
|
||||
|
||||
// Validate settings against teamAccessSettings schema (strip unknown fields)
|
||||
// Validate settings against teamAccessSettings schema (strip unknown fields).
|
||||
// CRITICAL: version field MUST be here — otherwise it gets stripped on save.
|
||||
type teamAccessSettings struct {
|
||||
Version *int `json:"version,omitempty"`
|
||||
AllowUserIDs []string `json:"allow_user_ids"`
|
||||
DenyUserIDs []string `json:"deny_user_ids"`
|
||||
AllowChannels []string `json:"allow_channels"`
|
||||
DenyChannels []string `json:"deny_channels"`
|
||||
ProgressNotifications *bool `json:"progress_notifications,omitempty"`
|
||||
FollowupIntervalMins *int `json:"followup_interval_minutes,omitempty"`
|
||||
FollowupMaxReminders *int `json:"followup_max_reminders,omitempty"`
|
||||
EscalationMode string `json:"escalation_mode,omitempty"`
|
||||
EscalationActions []string `json:"escalation_actions,omitempty"`
|
||||
WorkspaceScope string `json:"workspace_scope,omitempty"`
|
||||
WorkspaceQuotaMB *int `json:"workspace_quota_mb,omitempty"`
|
||||
}
|
||||
raw, _ := json.Marshal(params.Settings)
|
||||
var access teamAccessSettings
|
||||
@@ -294,6 +300,48 @@ func (m *TeamsMethods) handleKnownUsers(ctx context.Context, client *gateway.Cli
|
||||
}))
|
||||
}
|
||||
|
||||
// --- Scopes ---
|
||||
|
||||
type teamsScopesParams struct {
|
||||
TeamID string `json:"teamId"`
|
||||
}
|
||||
|
||||
func (m *TeamsMethods) handleScopes(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
||||
locale := store.LocaleFromContext(ctx)
|
||||
if m.teamStore == nil {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgTeamsNotConfigured)))
|
||||
return
|
||||
}
|
||||
|
||||
var params teamsScopesParams
|
||||
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
|
||||
return
|
||||
}
|
||||
|
||||
if params.TeamID == "" {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "teamId")))
|
||||
return
|
||||
}
|
||||
|
||||
teamID, err := uuid.Parse(params.TeamID)
|
||||
if err != nil {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "teamId")))
|
||||
return
|
||||
}
|
||||
|
||||
scopes, err := m.teamStore.ListTaskScopes(ctx, teamID)
|
||||
if err != nil {
|
||||
slog.Warn("teams.scopes failed", "team_id", teamID, "error", err)
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
|
||||
"scopes": scopes,
|
||||
}))
|
||||
}
|
||||
|
||||
// invalidateTeamCaches invalidates agent caches for all members of a team
|
||||
// and emits a pub/sub event for TeamToolManager cache invalidation.
|
||||
func (m *TeamsMethods) invalidateTeamCaches(ctx context.Context, teamID uuid.UUID) {
|
||||
|
||||
@@ -0,0 +1,653 @@
|
||||
package methods
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/gateway"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/i18n"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
||||
)
|
||||
|
||||
// maxCommentLength caps comment/reason content to prevent DB bloat.
|
||||
const maxCommentLength = 10000
|
||||
|
||||
func taskBusEvent(name string, payload any) bus.Event {
|
||||
return bus.Event{Name: name, Payload: payload}
|
||||
}
|
||||
|
||||
func taskNowUTC() string {
|
||||
return time.Now().UTC().Format("2006-01-02T15:04:05Z")
|
||||
}
|
||||
|
||||
// parseTaskParams unmarshals params and checks teamStore availability.
|
||||
// Returns locale and false if an error response was already sent.
|
||||
func (m *TeamsMethods) parseTaskParams(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame, dst any) (string, bool) {
|
||||
locale := store.LocaleFromContext(ctx)
|
||||
if m.teamStore == nil {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgTeamsNotConfigured)))
|
||||
return locale, false
|
||||
}
|
||||
if err := json.Unmarshal(req.Params, dst); err != nil {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
|
||||
return locale, false
|
||||
}
|
||||
return locale, true
|
||||
}
|
||||
|
||||
// RegisterTasks registers teams.tasks.* RPC handlers.
|
||||
func (m *TeamsMethods) RegisterTasks(router *gateway.MethodRouter) {
|
||||
router.Register(protocol.MethodTeamsTaskGet, m.handleTaskGet)
|
||||
router.Register(protocol.MethodTeamsTaskApprove, m.handleTaskApprove)
|
||||
router.Register(protocol.MethodTeamsTaskReject, m.handleTaskReject)
|
||||
router.Register(protocol.MethodTeamsTaskComment, m.handleTaskComment)
|
||||
router.Register(protocol.MethodTeamsTaskComments, m.handleTaskComments)
|
||||
router.Register(protocol.MethodTeamsTaskEvents, m.handleTaskEvents)
|
||||
router.Register(protocol.MethodTeamsTaskCreate, m.handleTaskCreate)
|
||||
router.Register(protocol.MethodTeamsTaskAssign, m.handleTaskAssign)
|
||||
}
|
||||
|
||||
// --- Task Get (with comments + events + attachments) ---
|
||||
|
||||
type taskGetParams struct {
|
||||
TeamID string `json:"teamId"`
|
||||
TaskID string `json:"taskId"`
|
||||
}
|
||||
|
||||
func (m *TeamsMethods) handleTaskGet(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
||||
var params taskGetParams
|
||||
locale, ok := m.parseTaskParams(ctx, client, req, ¶ms)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
teamID, err := uuid.Parse(params.TeamID)
|
||||
if err != nil {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "teamId")))
|
||||
return
|
||||
}
|
||||
taskID, err := uuid.Parse(params.TaskID)
|
||||
if err != nil {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "taskId")))
|
||||
return
|
||||
}
|
||||
|
||||
task, err := m.teamStore.GetTask(ctx, taskID)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrTaskNotFound) {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "task", "")))
|
||||
} else {
|
||||
slog.Warn("teams.tasks.get failed", "task_id", taskID, "error", err)
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, "")))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Validate task belongs to the requested team (prevent IDOR).
|
||||
if task.TeamID != teamID {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "task", "")))
|
||||
return
|
||||
}
|
||||
|
||||
comments, _ := m.teamStore.ListTaskComments(ctx, taskID)
|
||||
events, _ := m.teamStore.ListTaskEvents(ctx, taskID)
|
||||
attachments, _ := m.teamStore.ListTaskAttachments(ctx, taskID)
|
||||
|
||||
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
|
||||
"task": task,
|
||||
"comments": comments,
|
||||
"events": events,
|
||||
"attachments": attachments,
|
||||
}))
|
||||
}
|
||||
|
||||
// --- Task Approve ---
|
||||
|
||||
type taskApproveParams struct {
|
||||
TeamID string `json:"teamId"`
|
||||
TaskID string `json:"taskId"`
|
||||
Comment string `json:"comment"`
|
||||
}
|
||||
|
||||
func (m *TeamsMethods) handleTaskApprove(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
||||
var params taskApproveParams
|
||||
locale, ok := m.parseTaskParams(ctx, client, req, ¶ms)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
teamID, err := uuid.Parse(params.TeamID)
|
||||
if err != nil {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "teamId")))
|
||||
return
|
||||
}
|
||||
taskID, err := uuid.Parse(params.TaskID)
|
||||
if err != nil {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "taskId")))
|
||||
return
|
||||
}
|
||||
|
||||
if len(params.Comment) > maxCommentLength {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "comment too long"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := m.teamStore.ApproveTask(ctx, taskID, teamID, params.Comment); err != nil {
|
||||
slog.Warn("teams.tasks.approve failed", "task_id", taskID, "error", err)
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, "")))
|
||||
return
|
||||
}
|
||||
|
||||
// Record audit event.
|
||||
if err := m.teamStore.RecordTaskEvent(ctx, &store.TeamTaskEventData{
|
||||
TaskID: taskID,
|
||||
EventType: "approved",
|
||||
ActorType: "human",
|
||||
ActorID: client.UserID(),
|
||||
}); err != nil {
|
||||
slog.Warn("audit.record_failed", "task_id", taskID, "event", "approved", "error", err)
|
||||
}
|
||||
|
||||
// Add optional comment.
|
||||
if params.Comment != "" {
|
||||
if err := m.teamStore.AddTaskComment(ctx, &store.TeamTaskCommentData{
|
||||
TaskID: taskID,
|
||||
UserID: client.UserID(),
|
||||
Content: params.Comment,
|
||||
}); err != nil {
|
||||
slog.Warn("audit.comment_failed", "task_id", taskID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{"ok": true}))
|
||||
|
||||
if m.msgBus != nil {
|
||||
m.msgBus.Broadcast(taskBusEvent(protocol.EventTeamTaskApproved, protocol.TeamTaskEventPayload{
|
||||
TeamID: teamID.String(),
|
||||
TaskID: taskID.String(),
|
||||
Status: store.TeamTaskStatusCompleted,
|
||||
UserID: client.UserID(),
|
||||
Channel: "dashboard",
|
||||
Timestamp: taskNowUTC(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Task Reject ---
|
||||
|
||||
type taskRejectParams struct {
|
||||
TeamID string `json:"teamId"`
|
||||
TaskID string `json:"taskId"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
func (m *TeamsMethods) handleTaskReject(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
||||
var params taskRejectParams
|
||||
locale, ok := m.parseTaskParams(ctx, client, req, ¶ms)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
teamID, err := uuid.Parse(params.TeamID)
|
||||
if err != nil {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "teamId")))
|
||||
return
|
||||
}
|
||||
taskID, err := uuid.Parse(params.TaskID)
|
||||
if err != nil {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "taskId")))
|
||||
return
|
||||
}
|
||||
|
||||
reason := params.Reason
|
||||
if reason == "" {
|
||||
reason = "Rejected by human"
|
||||
}
|
||||
if len(reason) > maxCommentLength {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "reason too long"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := m.teamStore.RejectTask(ctx, taskID, teamID, reason); err != nil {
|
||||
slog.Warn("teams.tasks.reject failed", "task_id", taskID, "error", err)
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, "")))
|
||||
return
|
||||
}
|
||||
|
||||
// Record audit event.
|
||||
if err := m.teamStore.RecordTaskEvent(ctx, &store.TeamTaskEventData{
|
||||
TaskID: taskID,
|
||||
EventType: "rejected",
|
||||
ActorType: "human",
|
||||
ActorID: client.UserID(),
|
||||
}); err != nil {
|
||||
slog.Warn("audit.record_failed", "task_id", taskID, "event", "rejected", "error", err)
|
||||
}
|
||||
|
||||
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{"ok": true}))
|
||||
|
||||
if m.msgBus != nil {
|
||||
m.msgBus.Broadcast(taskBusEvent(protocol.EventTeamTaskRejected, protocol.TeamTaskEventPayload{
|
||||
TeamID: teamID.String(),
|
||||
TaskID: taskID.String(),
|
||||
Status: store.TeamTaskStatusCancelled,
|
||||
Reason: reason,
|
||||
UserID: client.UserID(),
|
||||
Channel: "dashboard",
|
||||
Timestamp: taskNowUTC(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Task Comment (human adds comment) ---
|
||||
|
||||
type taskCommentParams struct {
|
||||
TeamID string `json:"teamId"`
|
||||
TaskID string `json:"taskId"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
func (m *TeamsMethods) handleTaskComment(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
||||
var params taskCommentParams
|
||||
locale, ok := m.parseTaskParams(ctx, client, req, ¶ms)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
teamID, err := uuid.Parse(params.TeamID)
|
||||
if err != nil {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "teamId")))
|
||||
return
|
||||
}
|
||||
taskID, err := uuid.Parse(params.TaskID)
|
||||
if err != nil {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "taskId")))
|
||||
return
|
||||
}
|
||||
|
||||
if params.Content == "" {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "content")))
|
||||
return
|
||||
}
|
||||
if len(params.Content) > maxCommentLength {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "comment too long"))
|
||||
return
|
||||
}
|
||||
|
||||
// Validate task belongs to team (prevent IDOR).
|
||||
task, err := m.teamStore.GetTask(ctx, taskID)
|
||||
if err != nil || task.TeamID != teamID {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "task", "")))
|
||||
return
|
||||
}
|
||||
|
||||
if err := m.teamStore.AddTaskComment(ctx, &store.TeamTaskCommentData{
|
||||
TaskID: taskID,
|
||||
UserID: client.UserID(),
|
||||
Content: params.Content,
|
||||
}); err != nil {
|
||||
slog.Warn("teams.tasks.comment failed", "task_id", taskID, "error", err)
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, "")))
|
||||
return
|
||||
}
|
||||
|
||||
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{"ok": true}))
|
||||
|
||||
if m.msgBus != nil {
|
||||
m.msgBus.Broadcast(taskBusEvent(protocol.EventTeamTaskCommented, protocol.TeamTaskEventPayload{
|
||||
TeamID: teamID.String(),
|
||||
TaskID: taskID.String(),
|
||||
UserID: client.UserID(),
|
||||
Channel: "dashboard",
|
||||
Timestamp: taskNowUTC(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Task Comments list ---
|
||||
|
||||
type taskCommentsParams struct {
|
||||
TeamID string `json:"teamId"`
|
||||
TaskID string `json:"taskId"`
|
||||
}
|
||||
|
||||
func (m *TeamsMethods) handleTaskComments(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
||||
var params taskCommentsParams
|
||||
locale, ok := m.parseTaskParams(ctx, client, req, ¶ms)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
teamID, err := uuid.Parse(params.TeamID)
|
||||
if err != nil {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "teamId")))
|
||||
return
|
||||
}
|
||||
taskID, err := uuid.Parse(params.TaskID)
|
||||
if err != nil {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "taskId")))
|
||||
return
|
||||
}
|
||||
|
||||
// Validate task belongs to team (prevent IDOR).
|
||||
task, err := m.teamStore.GetTask(ctx, taskID)
|
||||
if err != nil || task.TeamID != teamID {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "task", "")))
|
||||
return
|
||||
}
|
||||
|
||||
comments, err := m.teamStore.ListTaskComments(ctx, taskID)
|
||||
if err != nil {
|
||||
slog.Warn("teams.tasks.comments failed", "task_id", taskID, "error", err)
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, "")))
|
||||
return
|
||||
}
|
||||
|
||||
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
|
||||
"comments": comments,
|
||||
}))
|
||||
}
|
||||
|
||||
// --- Task Events list ---
|
||||
|
||||
type taskEventsParams struct {
|
||||
TeamID string `json:"teamId"`
|
||||
TaskID string `json:"taskId"`
|
||||
}
|
||||
|
||||
func (m *TeamsMethods) handleTaskEvents(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
||||
var params taskEventsParams
|
||||
locale, ok := m.parseTaskParams(ctx, client, req, ¶ms)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
teamID, err := uuid.Parse(params.TeamID)
|
||||
if err != nil {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "teamId")))
|
||||
return
|
||||
}
|
||||
taskID, err := uuid.Parse(params.TaskID)
|
||||
if err != nil {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "taskId")))
|
||||
return
|
||||
}
|
||||
|
||||
// Validate task belongs to team (prevent IDOR).
|
||||
task, err := m.teamStore.GetTask(ctx, taskID)
|
||||
if err != nil || task.TeamID != teamID {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "task", "")))
|
||||
return
|
||||
}
|
||||
|
||||
events, err := m.teamStore.ListTaskEvents(ctx, taskID)
|
||||
if err != nil {
|
||||
slog.Warn("teams.tasks.events failed", "task_id", taskID, "error", err)
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, "")))
|
||||
return
|
||||
}
|
||||
|
||||
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
|
||||
"events": events,
|
||||
}))
|
||||
}
|
||||
|
||||
// --- Task Create ---
|
||||
|
||||
type taskCreateParams struct {
|
||||
TeamID string `json:"teamId"`
|
||||
Subject string `json:"subject"`
|
||||
Description string `json:"description"`
|
||||
Priority int `json:"priority"`
|
||||
TaskType string `json:"taskType"`
|
||||
AssignTo string `json:"assignTo"` // optional agent UUID — assign immediately after creation
|
||||
Channel string `json:"channel"` // optional scope — defaults to "dashboard"
|
||||
ChatID string `json:"chatId"` // optional scope — defaults to teamID
|
||||
}
|
||||
|
||||
func (m *TeamsMethods) handleTaskCreate(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
||||
var params taskCreateParams
|
||||
locale, ok := m.parseTaskParams(ctx, client, req, ¶ms)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
teamID, err := uuid.Parse(params.TeamID)
|
||||
if err != nil {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "teamId")))
|
||||
return
|
||||
}
|
||||
|
||||
if params.Subject == "" {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "subject")))
|
||||
return
|
||||
}
|
||||
if len(params.Subject) > 500 {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "subject too long"))
|
||||
return
|
||||
}
|
||||
if len(params.Description) > maxCommentLength {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "description too long"))
|
||||
return
|
||||
}
|
||||
|
||||
taskType := params.TaskType
|
||||
if taskType == "" {
|
||||
taskType = "general"
|
||||
}
|
||||
|
||||
ch := params.Channel
|
||||
if ch == "" {
|
||||
ch = "dashboard"
|
||||
}
|
||||
cid := params.ChatID
|
||||
if cid == "" {
|
||||
cid = teamID.String()
|
||||
}
|
||||
|
||||
task := &store.TeamTaskData{
|
||||
TeamID: teamID,
|
||||
Subject: params.Subject,
|
||||
Description: params.Description,
|
||||
Status: store.TeamTaskStatusPending,
|
||||
Priority: params.Priority,
|
||||
TaskType: taskType,
|
||||
UserID: client.UserID(),
|
||||
Channel: ch,
|
||||
ChatID: cid,
|
||||
}
|
||||
|
||||
if err := m.teamStore.CreateTask(ctx, task); err != nil {
|
||||
slog.Warn("teams.tasks.create failed", "team_id", teamID, "error", err)
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, "")))
|
||||
return
|
||||
}
|
||||
|
||||
// Record audit event.
|
||||
if err := m.teamStore.RecordTaskEvent(ctx, &store.TeamTaskEventData{
|
||||
TaskID: task.ID,
|
||||
EventType: "created",
|
||||
ActorType: "human",
|
||||
ActorID: client.UserID(),
|
||||
}); err != nil {
|
||||
slog.Warn("audit.record_failed", "task_id", task.ID, "event", "created", "error", err)
|
||||
}
|
||||
|
||||
// Auto-assign: use explicit assignTo, otherwise fall back to team lead.
|
||||
assignTo := params.AssignTo
|
||||
if assignTo == "" {
|
||||
team, err := m.teamStore.GetTeam(ctx, teamID)
|
||||
if err == nil && team != nil && team.LeadAgentID != uuid.Nil {
|
||||
assignTo = team.LeadAgentID.String()
|
||||
}
|
||||
}
|
||||
var autoAssignedAgentID uuid.UUID
|
||||
if assignTo != "" {
|
||||
agentID, err := uuid.Parse(assignTo)
|
||||
if err == nil {
|
||||
if err := m.teamStore.AssignTask(ctx, task.ID, agentID, teamID); err != nil {
|
||||
slog.Warn("teams.tasks.create auto-assign failed", "task_id", task.ID, "agent_id", agentID, "error", err)
|
||||
} else {
|
||||
task.Status = store.TeamTaskStatusInProgress
|
||||
task.OwnerAgentID = &agentID
|
||||
autoAssignedAgentID = agentID
|
||||
if err := m.teamStore.RecordTaskEvent(ctx, &store.TeamTaskEventData{
|
||||
TaskID: task.ID,
|
||||
EventType: "assigned",
|
||||
ActorType: "human",
|
||||
ActorID: client.UserID(),
|
||||
Data: json.RawMessage(`{"agent_id":"` + agentID.String() + `"}`),
|
||||
}); err != nil {
|
||||
slog.Warn("audit.record_failed", "task_id", task.ID, "event", "assigned", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{"task": task}))
|
||||
|
||||
if m.msgBus != nil {
|
||||
m.msgBus.Broadcast(taskBusEvent(protocol.EventTeamTaskCreated, protocol.TeamTaskEventPayload{
|
||||
TeamID: teamID.String(),
|
||||
TaskID: task.ID.String(),
|
||||
Status: store.TeamTaskStatusPending,
|
||||
UserID: client.UserID(),
|
||||
Channel: "dashboard",
|
||||
Timestamp: taskNowUTC(),
|
||||
}))
|
||||
|
||||
// Dispatch to assigned agent if auto-assigned during creation.
|
||||
if autoAssignedAgentID != uuid.Nil {
|
||||
m.dispatchTaskToAgent(ctx, task, task.ID, teamID, autoAssignedAgentID, client.UserID())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Task Assign ---
|
||||
|
||||
type taskAssignParams struct {
|
||||
TeamID string `json:"teamId"`
|
||||
TaskID string `json:"taskId"`
|
||||
AgentID string `json:"agentId"`
|
||||
}
|
||||
|
||||
func (m *TeamsMethods) handleTaskAssign(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
||||
var params taskAssignParams
|
||||
locale, ok := m.parseTaskParams(ctx, client, req, ¶ms)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
teamID, err := uuid.Parse(params.TeamID)
|
||||
if err != nil {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "teamId")))
|
||||
return
|
||||
}
|
||||
taskID, err := uuid.Parse(params.TaskID)
|
||||
if err != nil {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "taskId")))
|
||||
return
|
||||
}
|
||||
agentID, err := uuid.Parse(params.AgentID)
|
||||
if err != nil {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "agentId")))
|
||||
return
|
||||
}
|
||||
|
||||
// Validate task belongs to team (prevent IDOR).
|
||||
task, err := m.teamStore.GetTask(ctx, taskID)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrTaskNotFound) {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "task", "")))
|
||||
} else {
|
||||
slog.Warn("teams.tasks.assign get failed", "task_id", taskID, "error", err)
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, "")))
|
||||
}
|
||||
return
|
||||
}
|
||||
if task.TeamID != teamID {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "task", "")))
|
||||
return
|
||||
}
|
||||
|
||||
if err := m.teamStore.AssignTask(ctx, taskID, agentID, teamID); err != nil {
|
||||
slog.Warn("teams.tasks.assign failed", "task_id", taskID, "agent_id", agentID, "error", err)
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, "")))
|
||||
return
|
||||
}
|
||||
|
||||
// Record audit event.
|
||||
if err := m.teamStore.RecordTaskEvent(ctx, &store.TeamTaskEventData{
|
||||
TaskID: taskID,
|
||||
EventType: "assigned",
|
||||
ActorType: "human",
|
||||
ActorID: client.UserID(),
|
||||
Data: json.RawMessage(`{"agent_id":"` + agentID.String() + `"}`),
|
||||
}); err != nil {
|
||||
slog.Warn("audit.record_failed", "task_id", taskID, "event", "assigned", "error", err)
|
||||
}
|
||||
|
||||
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{"ok": true}))
|
||||
|
||||
if m.msgBus != nil {
|
||||
m.msgBus.Broadcast(taskBusEvent(protocol.EventTeamTaskAssigned, protocol.TeamTaskEventPayload{
|
||||
TeamID: teamID.String(),
|
||||
TaskID: taskID.String(),
|
||||
Status: store.TeamTaskStatusInProgress,
|
||||
UserID: client.UserID(),
|
||||
Channel: "dashboard",
|
||||
Timestamp: taskNowUTC(),
|
||||
}))
|
||||
|
||||
// Dispatch task to the assigned agent via message bus so the consumer
|
||||
// routes it through the agent loop (same pattern as team_message).
|
||||
m.dispatchTaskToAgent(ctx, task, taskID, teamID, agentID, client.UserID())
|
||||
}
|
||||
}
|
||||
|
||||
// dispatchTaskToAgent publishes a teammate-style inbound message so the
|
||||
// gateway consumer picks it up and runs the assigned agent, then auto-completes
|
||||
// the task on success or auto-fails on error.
|
||||
func (m *TeamsMethods) dispatchTaskToAgent(ctx context.Context, task *store.TeamTaskData, taskID, teamID, agentID uuid.UUID, userID string) {
|
||||
ag, err := m.agentStore.GetByID(ctx, agentID)
|
||||
if err != nil {
|
||||
slog.Warn("teams.tasks.dispatch: cannot resolve agent", "agent_id", agentID, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Build task prompt for the agent.
|
||||
content := fmt.Sprintf("[Assigned task #%d]: %s", task.TaskNumber, task.Subject)
|
||||
if task.Description != "" {
|
||||
content += "\n\n" + task.Description
|
||||
}
|
||||
|
||||
m.msgBus.PublishInbound(bus.InboundMessage{
|
||||
Channel: "system",
|
||||
SenderID: fmt.Sprintf("teammate:dashboard"),
|
||||
ChatID: teamID.String(),
|
||||
Content: content,
|
||||
UserID: userID,
|
||||
AgentID: ag.AgentKey,
|
||||
Metadata: map[string]string{
|
||||
"origin_channel": "dashboard",
|
||||
"origin_peer_kind": "direct",
|
||||
"from_agent": "dashboard",
|
||||
"to_agent": ag.AgentKey,
|
||||
"team_task_id": taskID.String(),
|
||||
"team_id": teamID.String(),
|
||||
},
|
||||
})
|
||||
slog.Info("teams.tasks.dispatch: sent task to agent",
|
||||
"task_id", taskID,
|
||||
"agent_key", ag.AgentKey,
|
||||
"team_id", teamID,
|
||||
)
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
package methods
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/gateway"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/i18n"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
||||
)
|
||||
|
||||
// --- Task Approve ---
|
||||
|
||||
type teamsTaskApproveParams struct {
|
||||
TaskID string `json:"task_id"`
|
||||
}
|
||||
|
||||
func (m *TeamsMethods) handleTaskApprove(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
||||
locale := store.LocaleFromContext(ctx)
|
||||
if m.teamStore == nil {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgTeamsNotConfigured)))
|
||||
return
|
||||
}
|
||||
|
||||
var params teamsTaskApproveParams
|
||||
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
|
||||
return
|
||||
}
|
||||
if params.TaskID == "" {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "task_id")))
|
||||
return
|
||||
}
|
||||
|
||||
taskID, err := uuid.Parse(params.TaskID)
|
||||
if err != nil {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "task_id")))
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch task to get team_id, subject, and verify status
|
||||
task, err := m.teamStore.GetTask(ctx, taskID)
|
||||
if err != nil {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error()))
|
||||
return
|
||||
}
|
||||
if task.Status != store.TeamTaskStatusPendingApproval {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest,
|
||||
fmt.Sprintf("task is not pending approval (current status: %s)", task.Status)))
|
||||
return
|
||||
}
|
||||
|
||||
// Atomic transition: pending_approval -> pending (or blocked if blockers exist)
|
||||
if err := m.teamStore.ApproveTask(ctx, taskID, task.TeamID); err != nil {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// Re-fetch to get actual post-approval status (pending or blocked)
|
||||
updated, err := m.teamStore.GetTask(ctx, taskID)
|
||||
if err != nil {
|
||||
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{"ok": true}))
|
||||
} else {
|
||||
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{"task": updated}))
|
||||
}
|
||||
|
||||
newStatus := store.TeamTaskStatusPending
|
||||
if updated != nil {
|
||||
newStatus = updated.Status
|
||||
}
|
||||
|
||||
// Broadcast event
|
||||
if m.msgBus != nil {
|
||||
m.msgBus.Broadcast(bus.Event{
|
||||
Name: protocol.EventTeamTaskApproved,
|
||||
Payload: protocol.TeamTaskEventPayload{
|
||||
TeamID: task.TeamID.String(),
|
||||
TaskID: taskID.String(),
|
||||
Subject: task.Subject,
|
||||
Status: newStatus,
|
||||
UserID: client.UserID(),
|
||||
Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Inject message to lead agent via mailbox
|
||||
team, err := m.teamStore.GetTeam(ctx, task.TeamID)
|
||||
if err == nil {
|
||||
msg := fmt.Sprintf("Task '%s' (id=%s) has been approved by the user (status: %s).", task.Subject, task.ID, newStatus)
|
||||
_ = m.teamStore.SendMessage(ctx, &store.TeamMessageData{
|
||||
TeamID: task.TeamID,
|
||||
FromAgentID: team.LeadAgentID,
|
||||
ToAgentID: &team.LeadAgentID,
|
||||
Content: msg,
|
||||
MessageType: store.TeamMessageTypeChat,
|
||||
TaskID: &taskID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- Task Reject ---
|
||||
|
||||
type teamsTaskRejectParams struct {
|
||||
TaskID string `json:"task_id"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
func (m *TeamsMethods) handleTaskReject(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
||||
locale := store.LocaleFromContext(ctx)
|
||||
if m.teamStore == nil {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgTeamsNotConfigured)))
|
||||
return
|
||||
}
|
||||
|
||||
var params teamsTaskRejectParams
|
||||
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
|
||||
return
|
||||
}
|
||||
if params.TaskID == "" {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "task_id")))
|
||||
return
|
||||
}
|
||||
|
||||
taskID, err := uuid.Parse(params.TaskID)
|
||||
if err != nil {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "task_id")))
|
||||
return
|
||||
}
|
||||
|
||||
reason := params.Reason
|
||||
if reason == "" {
|
||||
reason = "Rejected by user"
|
||||
}
|
||||
|
||||
// Fetch task to get team_id and subject for the lead message
|
||||
task, err := m.teamStore.GetTask(ctx, taskID)
|
||||
if err != nil {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// Reuse CancelTask (handles unblocking dependents, guards against already-completed)
|
||||
if err := m.teamStore.CancelTask(ctx, taskID, task.TeamID, reason); err != nil {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{"ok": true}))
|
||||
|
||||
// Broadcast event
|
||||
if m.msgBus != nil {
|
||||
m.msgBus.Broadcast(bus.Event{
|
||||
Name: protocol.EventTeamTaskRejected,
|
||||
Payload: protocol.TeamTaskEventPayload{
|
||||
TeamID: task.TeamID.String(),
|
||||
TaskID: taskID.String(),
|
||||
Subject: task.Subject,
|
||||
Status: "cancelled",
|
||||
Reason: reason,
|
||||
UserID: client.UserID(),
|
||||
Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Inject message to lead agent via mailbox
|
||||
team, err := m.teamStore.GetTeam(ctx, task.TeamID)
|
||||
if err == nil {
|
||||
leadMsg := fmt.Sprintf("Task '%s' (id=%s) was rejected by the user. Reason: %s", task.Subject, task.ID, reason)
|
||||
_ = m.teamStore.SendMessage(ctx, &store.TeamMessageData{
|
||||
TeamID: task.TeamID,
|
||||
FromAgentID: team.LeadAgentID,
|
||||
ToAgentID: &team.LeadAgentID,
|
||||
Content: leadMsg,
|
||||
MessageType: store.TeamMessageTypeChat,
|
||||
TaskID: &taskID,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package methods
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/gateway"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/i18n"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
||||
)
|
||||
|
||||
// RegisterWorkspace adds workspace RPC handlers to the method router.
|
||||
func (m *TeamsMethods) RegisterWorkspace(router *gateway.MethodRouter) {
|
||||
router.Register(protocol.MethodTeamsWorkspaceList, m.handleWorkspaceList)
|
||||
router.Register(protocol.MethodTeamsWorkspaceRead, m.handleWorkspaceRead)
|
||||
router.Register(protocol.MethodTeamsWorkspaceDelete, m.handleWorkspaceDelete)
|
||||
}
|
||||
|
||||
// --- Workspace List ---
|
||||
|
||||
type workspaceListParams struct {
|
||||
TeamID string `json:"team_id"`
|
||||
Channel string `json:"channel"`
|
||||
ChatID string `json:"chat_id"`
|
||||
}
|
||||
|
||||
func (m *TeamsMethods) handleWorkspaceList(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
||||
locale := store.LocaleFromContext(ctx)
|
||||
if m.teamStore == nil {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgTeamsNotConfigured)))
|
||||
return
|
||||
}
|
||||
|
||||
var params workspaceListParams
|
||||
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
|
||||
return
|
||||
}
|
||||
if params.TeamID == "" {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "team_id")))
|
||||
return
|
||||
}
|
||||
|
||||
teamID, err := uuid.Parse(params.TeamID)
|
||||
if err != nil {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid team_id"))
|
||||
return
|
||||
}
|
||||
|
||||
files, err := m.teamStore.ListWorkspaceFiles(ctx, teamID, params.Channel, params.ChatID)
|
||||
if err != nil {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// Check disk existence for each file.
|
||||
type fileWithStatus struct {
|
||||
store.TeamWorkspaceFileData
|
||||
Missing bool `json:"missing,omitempty"`
|
||||
}
|
||||
var result []fileWithStatus
|
||||
for _, f := range files {
|
||||
fws := fileWithStatus{TeamWorkspaceFileData: f}
|
||||
if _, statErr := os.Stat(f.FilePath); os.IsNotExist(statErr) {
|
||||
fws.Missing = true
|
||||
}
|
||||
result = append(result, fws)
|
||||
}
|
||||
|
||||
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
|
||||
"files": result,
|
||||
"count": len(result),
|
||||
}))
|
||||
}
|
||||
|
||||
// --- Workspace Read ---
|
||||
|
||||
type workspaceReadParams struct {
|
||||
TeamID string `json:"team_id"`
|
||||
Channel string `json:"channel"`
|
||||
ChatID string `json:"chat_id"`
|
||||
FileName string `json:"file_name"`
|
||||
}
|
||||
|
||||
func (m *TeamsMethods) handleWorkspaceRead(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
||||
locale := store.LocaleFromContext(ctx)
|
||||
if m.teamStore == nil {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgTeamsNotConfigured)))
|
||||
return
|
||||
}
|
||||
|
||||
var params workspaceReadParams
|
||||
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
|
||||
return
|
||||
}
|
||||
if params.TeamID == "" || params.FileName == "" {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "team_id, file_name")))
|
||||
return
|
||||
}
|
||||
|
||||
teamID, err := uuid.Parse(params.TeamID)
|
||||
if err != nil {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid team_id"))
|
||||
return
|
||||
}
|
||||
|
||||
file, err := m.teamStore.GetWorkspaceFile(ctx, teamID, params.Channel, params.ChatID, params.FileName)
|
||||
if err != nil {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// Read content from disk.
|
||||
var content string
|
||||
data, readErr := os.ReadFile(file.FilePath)
|
||||
if readErr == nil {
|
||||
content = string(data)
|
||||
if len(content) > 500000 {
|
||||
content = content[:500000] + "\n\n[...truncated]"
|
||||
}
|
||||
}
|
||||
|
||||
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
|
||||
"file": file,
|
||||
"content": content,
|
||||
}))
|
||||
}
|
||||
|
||||
// --- Workspace Delete ---
|
||||
|
||||
type workspaceDeleteParams struct {
|
||||
TeamID string `json:"team_id"`
|
||||
Channel string `json:"channel"`
|
||||
ChatID string `json:"chat_id"`
|
||||
FileName string `json:"file_name"`
|
||||
}
|
||||
|
||||
func (m *TeamsMethods) handleWorkspaceDelete(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
||||
locale := store.LocaleFromContext(ctx)
|
||||
if m.teamStore == nil {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgTeamsNotConfigured)))
|
||||
return
|
||||
}
|
||||
|
||||
var params workspaceDeleteParams
|
||||
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
|
||||
return
|
||||
}
|
||||
if params.TeamID == "" || params.FileName == "" {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "team_id, file_name")))
|
||||
return
|
||||
}
|
||||
|
||||
teamID, err := uuid.Parse(params.TeamID)
|
||||
if err != nil {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid team_id"))
|
||||
return
|
||||
}
|
||||
|
||||
filePath, err := m.teamStore.DeleteWorkspaceFile(ctx, teamID, params.Channel, params.ChatID, params.FileName)
|
||||
if err != nil {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// Clean up disk file.
|
||||
_ = os.Remove(filePath)
|
||||
|
||||
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
|
||||
"deleted": params.FileName,
|
||||
}))
|
||||
}
|
||||
@@ -294,15 +294,21 @@ func bridgeContextMiddleware(gatewayToken string, next http.Handler) http.Handle
|
||||
peerKind := r.Header.Get("X-Peer-Kind")
|
||||
|
||||
if agentIDStr != "" || userID != "" {
|
||||
// Verify HMAC signature over all context fields when gateway token is configured
|
||||
if gatewayToken != "" {
|
||||
sig := r.Header.Get("X-Bridge-Sig")
|
||||
if !providers.VerifyBridgeContext(gatewayToken, agentIDStr, userID, channel, chatID, peerKind, sig) {
|
||||
slog.Warn("security.mcp_bridge: invalid bridge context signature",
|
||||
"agent_id", agentIDStr, "user_id", userID)
|
||||
http.Error(w, `{"error":"invalid bridge context signature"}`, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
// Reject context headers when no gateway token — prevents unauthenticated impersonation.
|
||||
if gatewayToken == "" {
|
||||
slog.Warn("security.mcp_bridge: no gateway token, ignoring context headers",
|
||||
"agent_id", agentIDStr, "user_id", userID)
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify HMAC signature over all context fields.
|
||||
sig := r.Header.Get("X-Bridge-Sig")
|
||||
if !providers.VerifyBridgeContext(gatewayToken, agentIDStr, userID, channel, chatID, peerKind, sig) {
|
||||
slog.Warn("security.mcp_bridge: invalid bridge context signature",
|
||||
"agent_id", agentIDStr, "user_id", userID)
|
||||
http.Error(w, `{"error":"invalid bridge context signature"}`, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if agentIDStr != "" {
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
|
||||
// BridgeToolNames is the subset of GoClaw tools exposed via the MCP bridge.
|
||||
// Excluded: spawn (agent loop), create_forum_topic (channels),
|
||||
// handoff/delegate_search/evaluate_loop/team_* (require database stores).
|
||||
// handoff/delegate_search/evaluate_loop (require agent loop context).
|
||||
var BridgeToolNames = map[string]bool{
|
||||
// Filesystem
|
||||
"read_file": true,
|
||||
@@ -47,6 +47,11 @@ var BridgeToolNames = map[string]bool{
|
||||
"session_status": true,
|
||||
"sessions_history": true,
|
||||
"sessions_send": true,
|
||||
// Team tools (context from X-Agent-ID/X-Channel/X-Chat-ID headers)
|
||||
"team_tasks": true,
|
||||
"team_message": true,
|
||||
"workspace_write": true,
|
||||
"workspace_read": true,
|
||||
}
|
||||
|
||||
// NewBridgeServer creates a StreamableHTTPServer that exposes GoClaw tools as MCP tools.
|
||||
|
||||
@@ -156,6 +156,9 @@ func isAdminMethod(method string) bool {
|
||||
protocol.MethodTeamsGet,
|
||||
protocol.MethodTeamsDelete,
|
||||
protocol.MethodTeamsTaskList,
|
||||
protocol.MethodTeamsTaskGet,
|
||||
protocol.MethodTeamsTaskComments,
|
||||
protocol.MethodTeamsTaskEvents,
|
||||
}
|
||||
return slices.Contains(adminMethods, method)
|
||||
}
|
||||
@@ -177,6 +180,11 @@ func isWriteMethod(method string) bool {
|
||||
"approvals.",
|
||||
"exec.approval.",
|
||||
protocol.MethodSend,
|
||||
protocol.MethodTeamsTaskApprove,
|
||||
protocol.MethodTeamsTaskReject,
|
||||
protocol.MethodTeamsTaskComment,
|
||||
protocol.MethodTeamsTaskCreate,
|
||||
protocol.MethodTeamsTaskAssign,
|
||||
}
|
||||
for _, prefix := range writePrefixes {
|
||||
if strings.HasPrefix(method, prefix) {
|
||||
|
||||
@@ -241,6 +241,38 @@ func buildStreamJSONInput(text string, images []ImageContent) *bytes.Reader {
|
||||
return bytes.NewReader(data)
|
||||
}
|
||||
|
||||
// ResetCLISession deletes the Claude CLI session file and CLAUDE.md for a given session key.
|
||||
// Called on /reset to ensure the CLI starts fresh instead of --resume-ing poisoned history.
|
||||
// Safe to call even if CLI provider is not in use (no-op if files don't exist).
|
||||
func ResetCLISession(baseWorkDir, sessionKey string) {
|
||||
if baseWorkDir == "" {
|
||||
baseWorkDir = defaultCLIWorkDir()
|
||||
}
|
||||
safe := sanitizePathSegment(sessionKey)
|
||||
workDir := filepath.Join(baseWorkDir, safe)
|
||||
sessionID := deriveSessionUUID(sessionKey)
|
||||
|
||||
// Delete CLI session .jsonl file from ~/.claude/projects/
|
||||
home, err := os.UserHomeDir()
|
||||
if err == nil {
|
||||
resolved, err := filepath.EvalSymlinks(workDir)
|
||||
if err != nil {
|
||||
resolved = workDir
|
||||
}
|
||||
encoded := strings.NewReplacer(string(filepath.Separator), "-", "_", "-", ".", "-", ":", "-").Replace(resolved)
|
||||
sessionFile := filepath.Join(home, ".claude", "projects", encoded, sessionID.String()+".jsonl")
|
||||
if err := os.Remove(sessionFile); err == nil {
|
||||
slog.Info("claude-cli: deleted session file on /reset", "path", sessionFile)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete CLAUDE.md from workdir so it regenerates fresh
|
||||
claudeMD := filepath.Join(workDir, "CLAUDE.md")
|
||||
if err := os.Remove(claudeMD); err == nil {
|
||||
slog.Info("claude-cli: deleted CLAUDE.md on /reset", "path", claudeMD)
|
||||
}
|
||||
}
|
||||
|
||||
// filterCLIEnv removes CLAUDE* env vars to prevent nested session conflicts.
|
||||
func filterCLIEnv(environ []string) []string {
|
||||
var filtered []string
|
||||
|
||||
@@ -134,7 +134,7 @@ var tablesWithUpdatedAt = map[string]bool{
|
||||
"agents": true, "llm_providers": true, "sessions": true,
|
||||
"channel_instances": true, "cron_jobs": true, "custom_tools": true,
|
||||
"skills": true, "mcp_servers": true, "agent_links": true,
|
||||
"agent_teams": true, "team_tasks": true, "builtin_tools": true,
|
||||
"agent_teams": true, "team_tasks": true, "builtin_tools": true, "team_workspace_files": true,
|
||||
"agent_context_files": true, "user_context_files": true,
|
||||
"user_agent_overrides": true, "config_secrets": true,
|
||||
"memory_documents": true, "memory_chunks": true, "embedding_cache": true,
|
||||
|
||||
@@ -29,10 +29,7 @@ func (s *PGSnapshotStore) UpsertSnapshots(ctx context.Context, snapshots []store
|
||||
return nil
|
||||
}
|
||||
for start := 0; start < len(snapshots); start += maxBatchRows {
|
||||
end := start + maxBatchRows
|
||||
if end > len(snapshots) {
|
||||
end = len(snapshots)
|
||||
}
|
||||
end := min(start+maxBatchRows, len(snapshots))
|
||||
if err := s.upsertBatch(ctx, snapshots[start:end]); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
+57
-10
@@ -69,7 +69,8 @@ func (s *PGTeamStore) DeleteTeam(ctx context.Context, teamID uuid.UUID) error {
|
||||
func (s *PGTeamStore) ListTeams(ctx context.Context) ([]store.TeamData, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT t.id, t.name, t.lead_agent_id, t.description, t.status, t.settings, t.created_by, t.created_at, t.updated_at,
|
||||
COALESCE(a.agent_key, '') AS lead_agent_key
|
||||
COALESCE(a.agent_key, '') AS lead_agent_key,
|
||||
COALESCE(a.display_name, '') AS lead_display_name
|
||||
FROM agent_teams t
|
||||
LEFT JOIN agents a ON a.id = t.lead_agent_id
|
||||
ORDER BY t.created_at`)
|
||||
@@ -85,7 +86,7 @@ func (s *PGTeamStore) ListTeams(ctx context.Context) ([]store.TeamData, error) {
|
||||
if err := rows.Scan(
|
||||
&d.ID, &d.Name, &d.LeadAgentID, &desc, &d.Status,
|
||||
&d.Settings, &d.CreatedBy, &d.CreatedAt, &d.UpdatedAt,
|
||||
&d.LeadAgentKey,
|
||||
&d.LeadAgentKey, &d.LeadDisplayName,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -148,12 +149,48 @@ func (s *PGTeamStore) ListMembers(ctx context.Context, teamID uuid.UUID) ([]stor
|
||||
return members, rows.Err()
|
||||
}
|
||||
|
||||
func (s *PGTeamStore) ListIdleMembers(ctx context.Context, teamID uuid.UUID) ([]store.TeamMemberData, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT m.team_id, m.agent_id, m.role, m.joined_at,
|
||||
COALESCE(a.agent_key, '') AS agent_key,
|
||||
COALESCE(a.display_name, '') AS display_name,
|
||||
COALESCE(a.frontmatter, '') AS frontmatter
|
||||
FROM agent_team_members m
|
||||
JOIN agents a ON a.id = m.agent_id
|
||||
WHERE m.team_id = $1 AND a.status = 'active' AND m.role != $2
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM team_tasks t
|
||||
WHERE t.owner_agent_id = m.agent_id AND t.team_id = $1 AND t.status = $3
|
||||
)
|
||||
ORDER BY m.joined_at`, teamID, store.TeamRoleLead, store.TeamTaskStatusInProgress)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var members []store.TeamMemberData
|
||||
for rows.Next() {
|
||||
var d store.TeamMemberData
|
||||
if err := rows.Scan(
|
||||
&d.TeamID, &d.AgentID, &d.Role, &d.JoinedAt,
|
||||
&d.AgentKey, &d.DisplayName, &d.Frontmatter,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
members = append(members, d)
|
||||
}
|
||||
return members, rows.Err()
|
||||
}
|
||||
|
||||
func (s *PGTeamStore) GetTeamForAgent(ctx context.Context, agentID uuid.UUID) (*store.TeamData, error) {
|
||||
row := s.db.QueryRowContext(ctx,
|
||||
`SELECT t.id, t.name, t.lead_agent_id, t.description, t.status, t.settings, t.created_by, t.created_at, t.updated_at
|
||||
FROM agent_teams t
|
||||
JOIN agent_team_members m ON m.team_id = t.id
|
||||
WHERE m.agent_id = $1 AND t.status = $2
|
||||
WHERE (
|
||||
t.lead_agent_id = $1
|
||||
OR EXISTS (SELECT 1 FROM agent_team_members m WHERE m.team_id = t.id AND m.agent_id = $1)
|
||||
) AND t.status = $2
|
||||
ORDER BY (t.lead_agent_id = $1) DESC
|
||||
LIMIT 1`, agentID, store.TeamStatusActive)
|
||||
|
||||
d, err := scanTeamRow(row)
|
||||
@@ -200,26 +237,33 @@ func (s *PGTeamStore) SetHandoffRoute(ctx context.Context, route *store.HandoffR
|
||||
}
|
||||
route.CreatedAt = time.Now()
|
||||
|
||||
var teamID *uuid.UUID
|
||||
if route.TeamID != uuid.Nil {
|
||||
teamID = &route.TeamID
|
||||
}
|
||||
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO handoff_routes (id, channel, chat_id, from_agent_key, to_agent_key, reason, created_by, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
`INSERT INTO handoff_routes (id, channel, chat_id, from_agent_key, to_agent_key, reason, created_by, created_at, team_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
ON CONFLICT (channel, chat_id)
|
||||
DO UPDATE SET to_agent_key = EXCLUDED.to_agent_key, from_agent_key = EXCLUDED.from_agent_key,
|
||||
reason = EXCLUDED.reason, created_by = EXCLUDED.created_by, created_at = EXCLUDED.created_at`,
|
||||
reason = EXCLUDED.reason, created_by = EXCLUDED.created_by, created_at = EXCLUDED.created_at,
|
||||
team_id = EXCLUDED.team_id`,
|
||||
route.ID, route.Channel, route.ChatID, route.FromAgentKey, route.ToAgentKey,
|
||||
route.Reason, route.CreatedBy, route.CreatedAt,
|
||||
route.Reason, route.CreatedBy, route.CreatedAt, teamID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PGTeamStore) GetHandoffRoute(ctx context.Context, channel, chatID string) (*store.HandoffRouteData, error) {
|
||||
var d store.HandoffRouteData
|
||||
var teamID *uuid.UUID
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT id, channel, chat_id, from_agent_key, to_agent_key, reason, created_by, created_at
|
||||
`SELECT id, channel, chat_id, from_agent_key, to_agent_key, reason, created_by, created_at, team_id
|
||||
FROM handoff_routes WHERE channel = $1 AND chat_id = $2`,
|
||||
channel, chatID).Scan(
|
||||
&d.ID, &d.Channel, &d.ChatID, &d.FromAgentKey, &d.ToAgentKey,
|
||||
&d.Reason, &d.CreatedBy, &d.CreatedAt,
|
||||
&d.Reason, &d.CreatedBy, &d.CreatedAt, &teamID,
|
||||
)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
@@ -227,6 +271,9 @@ func (s *PGTeamStore) GetHandoffRoute(ctx context.Context, channel, chatID strin
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if teamID != nil {
|
||||
d.TeamID = *teamID
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,9 @@ package pg
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -12,6 +14,54 @@ import (
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
// taskLockDuration is how long a claimed task stays locked before stale recovery resets it.
|
||||
const taskLockDuration = 30 * time.Minute
|
||||
|
||||
// taskSelectCols is the shared SELECT column list for task queries (must match scanTaskRowsJoined).
|
||||
const taskSelectCols = `t.id, t.team_id, t.subject, t.description, t.status, t.owner_agent_id, t.blocked_by, t.priority, t.result, t.user_id, t.channel,
|
||||
t.task_type, t.task_number, COALESCE(t.identifier,''), t.created_by_agent_id, COALESCE(t.assignee_user_id,''), t.parent_id,
|
||||
COALESCE(t.chat_id,''), t.locked_at, t.lock_expires_at, COALESCE(t.progress_percent,0), COALESCE(t.progress_step,''),
|
||||
t.followup_at, COALESCE(t.followup_count,0), COALESCE(t.followup_max,0), COALESCE(t.followup_message,''), COALESCE(t.followup_channel,''), COALESCE(t.followup_chat_id,''),
|
||||
t.created_at, t.updated_at,
|
||||
COALESCE(a.agent_key, '') AS owner_agent_key,
|
||||
COALESCE(ca.agent_key, '') AS created_by_agent_key`
|
||||
|
||||
// taskJoinClause is the shared JOIN clause for task queries.
|
||||
const taskJoinClause = `FROM team_tasks t
|
||||
LEFT JOIN agents a ON a.id = t.owner_agent_id
|
||||
LEFT JOIN agents ca ON ca.id = t.created_by_agent_id`
|
||||
|
||||
// maxListTasksRows caps ListTasks results to prevent unbounded queries.
|
||||
const maxListTasksRows = 200
|
||||
|
||||
// ============================================================
|
||||
// Scopes
|
||||
// ============================================================
|
||||
|
||||
func (s *PGTeamStore) ListTaskScopes(ctx context.Context, teamID uuid.UUID) ([]store.ScopeEntry, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT DISTINCT channel, chat_id FROM team_tasks
|
||||
WHERE team_id = $1 AND channel IS NOT NULL AND channel != ''
|
||||
UNION
|
||||
SELECT DISTINCT channel, chat_id FROM team_workspace_files
|
||||
WHERE team_id = $1 AND archived_at IS NULL AND channel != ''
|
||||
ORDER BY channel, chat_id`, teamID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var scopes []store.ScopeEntry
|
||||
for rows.Next() {
|
||||
var s store.ScopeEntry
|
||||
if err := rows.Scan(&s.Channel, &s.ChatID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
scopes = append(scopes, s)
|
||||
}
|
||||
return scopes, rows.Err()
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Tasks
|
||||
// ============================================================
|
||||
@@ -24,48 +74,119 @@ func (s *PGTeamStore) CreateTask(ctx context.Context, task *store.TeamTaskData)
|
||||
task.CreatedAt = now
|
||||
task.UpdatedAt = now
|
||||
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO team_tasks (id, team_id, subject, description, status, owner_agent_id, blocked_by, priority, result, user_id, channel, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)`,
|
||||
if task.TaskType == "" {
|
||||
task.TaskType = "general"
|
||||
}
|
||||
|
||||
// Wrap entire operation in a transaction for atomicity.
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Lock team row to serialize task_number generation (prevents races).
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`SELECT 1 FROM agent_teams WHERE id = $1 FOR UPDATE`, task.TeamID,
|
||||
); err != nil {
|
||||
return fmt.Errorf("lock team: %w", err)
|
||||
}
|
||||
|
||||
var taskNumber int
|
||||
err = tx.QueryRowContext(ctx,
|
||||
`SELECT COALESCE(MAX(task_number), 0) + 1 FROM team_tasks WHERE team_id = $1`,
|
||||
task.TeamID,
|
||||
).Scan(&taskNumber)
|
||||
if err != nil {
|
||||
return fmt.Errorf("compute task_number: %w", err)
|
||||
}
|
||||
task.TaskNumber = taskNumber
|
||||
|
||||
// Generate identifier: UPPER(team_name[:3]) + "-" + task_number.
|
||||
prefix := "TSK"
|
||||
var teamName string
|
||||
if err := tx.QueryRowContext(ctx,
|
||||
`SELECT name FROM agent_teams WHERE id = $1`, task.TeamID,
|
||||
).Scan(&teamName); err == nil && teamName != "" {
|
||||
r := []rune(teamName)
|
||||
if len(r) > 3 {
|
||||
r = r[:3]
|
||||
}
|
||||
prefix = strings.ToUpper(string(r))
|
||||
}
|
||||
task.Identifier = fmt.Sprintf("%s-%d", prefix, taskNumber)
|
||||
|
||||
// INSERT with all fields in one statement.
|
||||
_, err = tx.ExecContext(ctx,
|
||||
`INSERT INTO team_tasks (id, team_id, subject, description, status, owner_agent_id, blocked_by, priority, result, user_id, channel,
|
||||
task_type, task_number, identifier, created_by_agent_id, parent_id, chat_id, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)`,
|
||||
task.ID, task.TeamID, task.Subject, task.Description,
|
||||
task.Status, task.OwnerAgentID, pq.Array(task.BlockedBy),
|
||||
task.Priority, task.Result,
|
||||
sql.NullString{String: task.UserID, Valid: task.UserID != ""},
|
||||
sql.NullString{String: task.Channel, Valid: task.Channel != ""},
|
||||
task.TaskType, taskNumber, task.Identifier,
|
||||
task.CreatedByAgentID, task.ParentID,
|
||||
sql.NullString{String: task.ChatID, Valid: task.ChatID != ""},
|
||||
now, now,
|
||||
)
|
||||
return err
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// allowedTaskUpdateCols is the whitelist of columns that UpdateTask accepts.
|
||||
var allowedTaskUpdateCols = map[string]bool{
|
||||
"subject": true,
|
||||
"description": true,
|
||||
"priority": true,
|
||||
"assignee_user_id": true,
|
||||
"metadata": true,
|
||||
"updated_at": true,
|
||||
}
|
||||
|
||||
func (s *PGTeamStore) UpdateTask(ctx context.Context, taskID uuid.UUID, updates map[string]any) error {
|
||||
if len(updates) == 0 {
|
||||
return nil
|
||||
}
|
||||
// Validate columns against whitelist.
|
||||
for col := range updates {
|
||||
if !allowedTaskUpdateCols[col] {
|
||||
return fmt.Errorf("column %q is not allowed in task updates", col)
|
||||
}
|
||||
}
|
||||
updates["updated_at"] = time.Now()
|
||||
return execMapUpdate(ctx, s.db, "team_tasks", taskID, updates)
|
||||
}
|
||||
|
||||
func (s *PGTeamStore) ListTasks(ctx context.Context, teamID uuid.UUID, orderBy string, statusFilter string, userID string) ([]store.TeamTaskData, error) {
|
||||
func (s *PGTeamStore) ListTasks(ctx context.Context, teamID uuid.UUID, orderBy string, statusFilter string, userID string, channel string, chatID string) ([]store.TeamTaskData, error) {
|
||||
orderClause := "t.priority DESC, t.created_at"
|
||||
if orderBy == "newest" {
|
||||
orderClause = "t.created_at DESC"
|
||||
}
|
||||
|
||||
statusWhere := "AND t.status IN ('pending', 'pending_approval', 'in_progress', 'blocked')" // default: active only
|
||||
statusWhere := "AND t.status NOT IN ('completed','cancelled')" // default: active only
|
||||
switch statusFilter {
|
||||
case store.TeamTaskFilterAll:
|
||||
statusWhere = ""
|
||||
case store.TeamTaskFilterInReview:
|
||||
statusWhere = "AND t.status = 'in_review'"
|
||||
case store.TeamTaskFilterCompleted:
|
||||
statusWhere = "AND t.status = 'completed'"
|
||||
statusWhere = "AND t.status IN ('completed','cancelled')"
|
||||
}
|
||||
|
||||
// Scope filter: always bind $4/$5 but only enforce when non-empty.
|
||||
scopeWhere := "AND ($4 = '' OR COALESCE(t.channel,'') = $4) AND ($5 = '' OR COALESCE(t.chat_id,'') = $5)"
|
||||
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT t.id, t.team_id, t.subject, t.description, t.status, t.owner_agent_id, t.blocked_by, t.priority, t.result, t.user_id, t.channel, t.created_at, t.updated_at,
|
||||
COALESCE(a.agent_key, '') AS owner_agent_key
|
||||
FROM team_tasks t
|
||||
LEFT JOIN agents a ON a.id = t.owner_agent_id
|
||||
WHERE t.team_id = $1 AND ($2 = '' OR t.user_id = $2) `+statusWhere+`
|
||||
ORDER BY `+orderClause, teamID, userID)
|
||||
`SELECT `+taskSelectCols+`
|
||||
`+taskJoinClause+`
|
||||
WHERE t.team_id = $1 AND ($2 = '' OR t.user_id = $2) `+statusWhere+` `+scopeWhere+`
|
||||
ORDER BY `+orderClause+`
|
||||
LIMIT $3`, teamID, userID, maxListTasksRows, channel, chatID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -75,10 +196,8 @@ func (s *PGTeamStore) ListTasks(ctx context.Context, teamID uuid.UUID, orderBy s
|
||||
|
||||
func (s *PGTeamStore) GetTask(ctx context.Context, taskID uuid.UUID) (*store.TeamTaskData, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT t.id, t.team_id, t.subject, t.description, t.status, t.owner_agent_id, t.blocked_by, t.priority, t.result, t.user_id, t.channel, t.created_at, t.updated_at,
|
||||
COALESCE(a.agent_key, '') AS owner_agent_key
|
||||
FROM team_tasks t
|
||||
LEFT JOIN agents a ON a.id = t.owner_agent_id
|
||||
`SELECT `+taskSelectCols+`
|
||||
`+taskJoinClause+`
|
||||
WHERE t.id = $1`, taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -89,7 +208,7 @@ func (s *PGTeamStore) GetTask(ctx context.Context, taskID uuid.UUID) (*store.Tea
|
||||
return nil, err
|
||||
}
|
||||
if len(tasks) == 0 {
|
||||
return nil, fmt.Errorf("task not found")
|
||||
return nil, store.ErrTaskNotFound
|
||||
}
|
||||
return &tasks[0], nil
|
||||
}
|
||||
@@ -99,10 +218,8 @@ func (s *PGTeamStore) SearchTasks(ctx context.Context, teamID uuid.UUID, query s
|
||||
limit = 20
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT t.id, t.team_id, t.subject, t.description, t.status, t.owner_agent_id, t.blocked_by, t.priority, t.result, t.user_id, t.channel, t.created_at, t.updated_at,
|
||||
COALESCE(a.agent_key, '') AS owner_agent_key
|
||||
FROM team_tasks t
|
||||
LEFT JOIN agents a ON a.id = t.owner_agent_id
|
||||
`SELECT `+taskSelectCols+`
|
||||
`+taskJoinClause+`
|
||||
WHERE t.team_id = $1 AND t.tsv @@ plainto_tsquery('simple', $2) AND ($4 = '' OR t.user_id = $4)
|
||||
ORDER BY ts_rank(t.tsv, plainto_tsquery('simple', $2)) DESC
|
||||
LIMIT $3`, teamID, query, limit, userID)
|
||||
@@ -114,10 +231,12 @@ func (s *PGTeamStore) SearchTasks(ctx context.Context, teamID uuid.UUID, query s
|
||||
}
|
||||
|
||||
func (s *PGTeamStore) ClaimTask(ctx context.Context, taskID, agentID, teamID uuid.UUID) error {
|
||||
now := time.Now()
|
||||
lockExpires := now.Add(taskLockDuration)
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`UPDATE team_tasks SET status = $1, owner_agent_id = $2, updated_at = $3
|
||||
WHERE id = $4 AND status = $5 AND owner_agent_id IS NULL AND team_id = $6`,
|
||||
store.TeamTaskStatusInProgress, agentID, time.Now(),
|
||||
`UPDATE team_tasks SET status = $1, owner_agent_id = $2, locked_at = $3, lock_expires_at = $4, updated_at = $3
|
||||
WHERE id = $5 AND status = $6 AND owner_agent_id IS NULL AND team_id = $7`,
|
||||
store.TeamTaskStatusInProgress, agentID, now, lockExpires,
|
||||
taskID, store.TeamTaskStatusPending, teamID,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -133,6 +252,28 @@ func (s *PGTeamStore) ClaimTask(ctx context.Context, taskID, agentID, teamID uui
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PGTeamStore) AssignTask(ctx context.Context, taskID, agentID, teamID uuid.UUID) error {
|
||||
now := time.Now()
|
||||
lockExpires := now.Add(taskLockDuration)
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`UPDATE team_tasks SET status = $1, owner_agent_id = $2, locked_at = $3, lock_expires_at = $4, updated_at = $3
|
||||
WHERE id = $5 AND team_id = $6 AND status = $7`,
|
||||
store.TeamTaskStatusInProgress, agentID, now, lockExpires,
|
||||
taskID, teamID, store.TeamTaskStatusPending,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return fmt.Errorf("task not available for assignment (not pending or wrong team)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PGTeamStore) CompleteTask(ctx context.Context, taskID, teamID uuid.UUID, result string) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
@@ -140,9 +281,10 @@ func (s *PGTeamStore) CompleteTask(ctx context.Context, taskID, teamID uuid.UUID
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Mark task as completed (must be in_progress — use ClaimTask first)
|
||||
res, err := tx.ExecContext(ctx,
|
||||
`UPDATE team_tasks SET status = $1, result = $2, updated_at = $3
|
||||
`UPDATE team_tasks SET status = $1, result = $2, locked_at = NULL, lock_expires_at = NULL,
|
||||
followup_at = NULL, followup_count = 0, followup_message = NULL, followup_channel = NULL, followup_chat_id = NULL,
|
||||
updated_at = $3
|
||||
WHERE id = $4 AND status = $5 AND team_id = $6`,
|
||||
store.TeamTaskStatusCompleted, result, time.Now(),
|
||||
taskID, store.TeamTaskStatusInProgress, teamID,
|
||||
@@ -158,47 +300,12 @@ func (s *PGTeamStore) CompleteTask(ctx context.Context, taskID, teamID uuid.UUID
|
||||
return fmt.Errorf("task not in progress or not found")
|
||||
}
|
||||
|
||||
// Unblock dependent tasks: remove this taskID from their blocked_by arrays.
|
||||
// Tasks whose blocked_by becomes empty transition from blocked→pending so they can be claimed.
|
||||
_, err = tx.ExecContext(ctx,
|
||||
`UPDATE team_tasks SET
|
||||
blocked_by = array_remove(blocked_by, $1),
|
||||
status = CASE WHEN status = 'blocked' AND blocked_by = ARRAY[$1]::uuid[] THEN 'pending' ELSE status END,
|
||||
updated_at = $2
|
||||
WHERE $1 = ANY(blocked_by)`,
|
||||
taskID, time.Now(),
|
||||
)
|
||||
if err != nil {
|
||||
if err := unblockDependentTasks(ctx, tx, taskID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *PGTeamStore) ApproveTask(ctx context.Context, taskID, teamID uuid.UUID) error {
|
||||
// Transition pending_approval → pending or blocked (if blockers exist).
|
||||
// Single atomic UPDATE: check blocked_by array to decide target status.
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`UPDATE team_tasks SET
|
||||
status = CASE WHEN array_length(blocked_by, 1) > 0 THEN $1 ELSE $2 END,
|
||||
updated_at = $3
|
||||
WHERE id = $4 AND status = $5 AND team_id = $6`,
|
||||
store.TeamTaskStatusBlocked, store.TeamTaskStatusPending, time.Now(),
|
||||
taskID, store.TeamTaskStatusPendingApproval, teamID,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return fmt.Errorf("task not found, not pending approval, or wrong team")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PGTeamStore) CancelTask(ctx context.Context, taskID, teamID uuid.UUID, reason string) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
@@ -206,12 +313,13 @@ func (s *PGTeamStore) CancelTask(ctx context.Context, taskID, teamID uuid.UUID,
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Cancel the task (guard: only non-completed tasks)
|
||||
now := time.Now()
|
||||
res, err := tx.ExecContext(ctx,
|
||||
`UPDATE team_tasks SET status = $1, result = $2, updated_at = $3
|
||||
WHERE id = $4 AND status != $5 AND team_id = $6`,
|
||||
store.TeamTaskStatusCompleted, "CANCELLED: "+reason, now,
|
||||
`UPDATE team_tasks SET status = $1, result = $2, locked_at = NULL, lock_expires_at = NULL,
|
||||
followup_at = NULL, followup_count = 0, followup_message = NULL, followup_channel = NULL, followup_chat_id = NULL,
|
||||
updated_at = $3
|
||||
WHERE id = $4 AND status NOT IN ($5, $1) AND team_id = $6`,
|
||||
store.TeamTaskStatusCancelled, reason, now,
|
||||
taskID, store.TeamTaskStatusCompleted, teamID,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -222,22 +330,12 @@ func (s *PGTeamStore) CancelTask(ctx context.Context, taskID, teamID uuid.UUID,
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return fmt.Errorf("task not found, already completed, or wrong team")
|
||||
return fmt.Errorf("task not found, already completed/cancelled, or wrong team")
|
||||
}
|
||||
|
||||
// Unblock dependent tasks (same logic as CompleteTask)
|
||||
_, err = tx.ExecContext(ctx,
|
||||
`UPDATE team_tasks SET
|
||||
blocked_by = array_remove(blocked_by, $1),
|
||||
status = CASE WHEN status = 'blocked' AND blocked_by = ARRAY[$1]::uuid[] THEN 'pending' ELSE status END,
|
||||
updated_at = $2
|
||||
WHERE $1 = ANY(blocked_by)`,
|
||||
taskID, now,
|
||||
)
|
||||
if err != nil {
|
||||
if err := unblockDependentTasks(ctx, tx, taskID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
@@ -250,7 +348,9 @@ func (s *PGTeamStore) FailTask(ctx context.Context, taskID, teamID uuid.UUID, er
|
||||
|
||||
now := time.Now()
|
||||
res, err := tx.ExecContext(ctx,
|
||||
`UPDATE team_tasks SET status = $1, result = $2, updated_at = $3
|
||||
`UPDATE team_tasks SET status = $1, result = $2, locked_at = NULL, lock_expires_at = NULL,
|
||||
followup_at = NULL, followup_count = 0, followup_message = NULL, followup_channel = NULL, followup_chat_id = NULL,
|
||||
updated_at = $3
|
||||
WHERE id = $4 AND status = $5 AND team_id = $6`,
|
||||
store.TeamTaskStatusFailed, "FAILED: "+errMsg, now,
|
||||
taskID, store.TeamTaskStatusInProgress, teamID,
|
||||
@@ -266,19 +366,9 @@ func (s *PGTeamStore) FailTask(ctx context.Context, taskID, teamID uuid.UUID, er
|
||||
return fmt.Errorf("task not in progress or not found")
|
||||
}
|
||||
|
||||
// Unblock dependent tasks (same logic as CompleteTask/CancelTask)
|
||||
_, err = tx.ExecContext(ctx,
|
||||
`UPDATE team_tasks SET
|
||||
blocked_by = array_remove(blocked_by, $1),
|
||||
status = CASE WHEN status = 'blocked' AND blocked_by = ARRAY[$1]::uuid[] THEN 'pending' ELSE status END,
|
||||
updated_at = $2
|
||||
WHERE $1 = ANY(blocked_by)`,
|
||||
taskID, now,
|
||||
)
|
||||
if err != nil {
|
||||
if err := unblockDependentTasks(ctx, tx, taskID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
@@ -287,14 +377,22 @@ func scanTaskRowsJoined(rows *sql.Rows) ([]store.TeamTaskData, error) {
|
||||
for rows.Next() {
|
||||
var d store.TeamTaskData
|
||||
var desc, result, userID, channel sql.NullString
|
||||
var ownerID *uuid.UUID
|
||||
var ownerID, createdByAgentID, parentID *uuid.UUID
|
||||
var blockedBy []uuid.UUID
|
||||
var assigneeUserID, chatID, progressStep, identifier string
|
||||
var lockedAt, lockExpiresAt, followupAt *time.Time
|
||||
var followupCount, followupMax int
|
||||
var followupMessage, followupChannel, followupChatID string
|
||||
if err := rows.Scan(
|
||||
&d.ID, &d.TeamID, &d.Subject, &desc, &d.Status,
|
||||
&ownerID, pq.Array(&blockedBy), &d.Priority, &result,
|
||||
&userID, &channel,
|
||||
&d.TaskType, &d.TaskNumber, &identifier, &createdByAgentID, &assigneeUserID, &parentID,
|
||||
&chatID, &lockedAt, &lockExpiresAt, &d.ProgressPercent, &progressStep,
|
||||
&followupAt, &followupCount, &followupMax, &followupMessage, &followupChannel, &followupChatID,
|
||||
&d.CreatedAt, &d.UpdatedAt,
|
||||
&d.OwnerAgentKey,
|
||||
&d.CreatedByAgentKey,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -312,7 +410,463 @@ func scanTaskRowsJoined(rows *sql.Rows) ([]store.TeamTaskData, error) {
|
||||
}
|
||||
d.OwnerAgentID = ownerID
|
||||
d.BlockedBy = blockedBy
|
||||
d.Identifier = identifier
|
||||
d.CreatedByAgentID = createdByAgentID
|
||||
d.AssigneeUserID = assigneeUserID
|
||||
d.ParentID = parentID
|
||||
d.ChatID = chatID
|
||||
d.LockedAt = lockedAt
|
||||
d.LockExpiresAt = lockExpiresAt
|
||||
d.ProgressStep = progressStep
|
||||
d.FollowupAt = followupAt
|
||||
d.FollowupCount = followupCount
|
||||
d.FollowupMax = followupMax
|
||||
d.FollowupMessage = followupMessage
|
||||
d.FollowupChannel = followupChannel
|
||||
d.FollowupChatID = followupChatID
|
||||
tasks = append(tasks, d)
|
||||
}
|
||||
return tasks, rows.Err()
|
||||
}
|
||||
|
||||
// unblockDependentTasks removes taskID from blocked_by arrays and transitions blocked→pending
|
||||
// when all blockers are resolved. Must be called within a transaction.
|
||||
func unblockDependentTasks(ctx context.Context, tx *sql.Tx, taskID uuid.UUID) error {
|
||||
_, err := tx.ExecContext(ctx,
|
||||
`UPDATE team_tasks SET
|
||||
blocked_by = array_remove(blocked_by, $1),
|
||||
status = CASE WHEN status = 'blocked' AND array_length(array_remove(blocked_by, $1), 1) IS NULL THEN 'pending' ELSE status END,
|
||||
updated_at = $2
|
||||
WHERE $1 = ANY(blocked_by)`,
|
||||
taskID, time.Now(),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Review workflow
|
||||
// ============================================================
|
||||
|
||||
func (s *PGTeamStore) ReviewTask(ctx context.Context, taskID, teamID uuid.UUID) error {
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`UPDATE team_tasks SET status = $1, updated_at = $2
|
||||
WHERE id = $3 AND status = $4 AND team_id = $5`,
|
||||
store.TeamTaskStatusInReview, time.Now(),
|
||||
taskID, store.TeamTaskStatusInProgress, teamID,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return fmt.Errorf("task not in progress or not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PGTeamStore) ApproveTask(ctx context.Context, taskID, teamID uuid.UUID, comment string) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
now := time.Now()
|
||||
res, err := tx.ExecContext(ctx,
|
||||
`UPDATE team_tasks SET status = $1, locked_at = NULL, lock_expires_at = NULL,
|
||||
followup_at = NULL, followup_count = 0, followup_message = NULL, followup_channel = NULL, followup_chat_id = NULL,
|
||||
updated_at = $2
|
||||
WHERE id = $3 AND status = $4 AND team_id = $5`,
|
||||
store.TeamTaskStatusCompleted, now,
|
||||
taskID, store.TeamTaskStatusInReview, teamID,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return fmt.Errorf("task not in review or not found")
|
||||
}
|
||||
|
||||
if err := unblockDependentTasks(ctx, tx, taskID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *PGTeamStore) RejectTask(ctx context.Context, taskID, teamID uuid.UUID, reason string) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
now := time.Now()
|
||||
res, err := tx.ExecContext(ctx,
|
||||
`UPDATE team_tasks SET status = $1, result = $2, locked_at = NULL, lock_expires_at = NULL,
|
||||
followup_at = NULL, followup_count = 0, followup_message = NULL, followup_channel = NULL, followup_chat_id = NULL,
|
||||
updated_at = $3
|
||||
WHERE id = $4 AND status = $5 AND team_id = $6`,
|
||||
store.TeamTaskStatusCancelled, reason, now,
|
||||
taskID, store.TeamTaskStatusInReview, teamID,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return fmt.Errorf("task not in review or not found")
|
||||
}
|
||||
|
||||
if err := unblockDependentTasks(ctx, tx, taskID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Task comments
|
||||
// ============================================================
|
||||
|
||||
func (s *PGTeamStore) AddTaskComment(ctx context.Context, comment *store.TeamTaskCommentData) error {
|
||||
if comment.ID == uuid.Nil {
|
||||
comment.ID = store.GenNewID()
|
||||
}
|
||||
comment.CreatedAt = time.Now()
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO team_task_comments (id, task_id, agent_id, user_id, content, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
comment.ID, comment.TaskID, comment.AgentID,
|
||||
sql.NullString{String: comment.UserID, Valid: comment.UserID != ""},
|
||||
comment.Content, comment.CreatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PGTeamStore) ListTaskComments(ctx context.Context, taskID uuid.UUID) ([]store.TeamTaskCommentData, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT c.id, c.task_id, c.agent_id, c.user_id, c.content, c.created_at,
|
||||
COALESCE(a.agent_key, '') AS agent_key
|
||||
FROM team_task_comments c
|
||||
LEFT JOIN agents a ON a.id = c.agent_id
|
||||
WHERE c.task_id = $1
|
||||
ORDER BY c.created_at ASC`, taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var comments []store.TeamTaskCommentData
|
||||
for rows.Next() {
|
||||
var c store.TeamTaskCommentData
|
||||
var agentID *uuid.UUID
|
||||
var userID sql.NullString
|
||||
if err := rows.Scan(&c.ID, &c.TaskID, &agentID, &userID, &c.Content, &c.CreatedAt, &c.AgentKey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.AgentID = agentID
|
||||
if userID.Valid {
|
||||
c.UserID = userID.String
|
||||
}
|
||||
comments = append(comments, c)
|
||||
}
|
||||
return comments, rows.Err()
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Audit events
|
||||
// ============================================================
|
||||
|
||||
func (s *PGTeamStore) RecordTaskEvent(ctx context.Context, event *store.TeamTaskEventData) error {
|
||||
if event.ID == uuid.Nil {
|
||||
event.ID = store.GenNewID()
|
||||
}
|
||||
event.CreatedAt = time.Now()
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO team_task_events (id, task_id, event_type, actor_type, actor_id, data, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
event.ID, event.TaskID, event.EventType, event.ActorType, event.ActorID, event.Data, event.CreatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PGTeamStore) ListTaskEvents(ctx context.Context, taskID uuid.UUID) ([]store.TeamTaskEventData, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, task_id, event_type, actor_type, actor_id, data, created_at
|
||||
FROM team_task_events
|
||||
WHERE task_id = $1
|
||||
ORDER BY created_at ASC`, taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var events []store.TeamTaskEventData
|
||||
for rows.Next() {
|
||||
var e store.TeamTaskEventData
|
||||
var data json.RawMessage
|
||||
if err := rows.Scan(&e.ID, &e.TaskID, &e.EventType, &e.ActorType, &e.ActorID, &data, &e.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.Data = data
|
||||
events = append(events, e)
|
||||
}
|
||||
return events, rows.Err()
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Attachments
|
||||
// ============================================================
|
||||
|
||||
func (s *PGTeamStore) AttachFileToTask(ctx context.Context, att *store.TeamTaskAttachmentData) error {
|
||||
if att.ID == uuid.Nil {
|
||||
att.ID = store.GenNewID()
|
||||
}
|
||||
att.CreatedAt = time.Now()
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO team_task_attachments (id, task_id, file_id, added_by, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (task_id, file_id) DO NOTHING`,
|
||||
att.ID, att.TaskID, att.FileID, att.AddedBy, att.CreatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PGTeamStore) ListTaskAttachments(ctx context.Context, taskID uuid.UUID) ([]store.TeamTaskAttachmentData, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT a.id, a.task_id, a.file_id, a.added_by, a.created_at,
|
||||
COALESCE(f.file_name, '') AS file_name
|
||||
FROM team_task_attachments a
|
||||
LEFT JOIN team_workspace_files f ON f.id = a.file_id
|
||||
WHERE a.task_id = $1
|
||||
ORDER BY a.created_at ASC`, taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var atts []store.TeamTaskAttachmentData
|
||||
for rows.Next() {
|
||||
var a store.TeamTaskAttachmentData
|
||||
var addedBy *uuid.UUID
|
||||
if err := rows.Scan(&a.ID, &a.TaskID, &a.FileID, &addedBy, &a.CreatedAt, &a.FileName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.AddedBy = addedBy
|
||||
atts = append(atts, a)
|
||||
}
|
||||
return atts, rows.Err()
|
||||
}
|
||||
|
||||
func (s *PGTeamStore) DetachFileFromTask(ctx context.Context, taskID, fileID uuid.UUID) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`DELETE FROM team_task_attachments WHERE task_id = $1 AND file_id = $2`,
|
||||
taskID, fileID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Progress
|
||||
// ============================================================
|
||||
|
||||
func (s *PGTeamStore) UpdateTaskProgress(ctx context.Context, taskID, teamID uuid.UUID, percent int, step string) error {
|
||||
if percent < 0 || percent > 100 {
|
||||
return fmt.Errorf("progress percent must be 0-100, got %d", percent)
|
||||
}
|
||||
// Also renews lock_expires_at as a heartbeat.
|
||||
now := time.Now()
|
||||
lockExpires := now.Add(taskLockDuration)
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`UPDATE team_tasks SET progress_percent = $1, progress_step = $2, lock_expires_at = $3, updated_at = $4
|
||||
WHERE id = $5 AND status = $6 AND team_id = $7`,
|
||||
percent, step, lockExpires, now,
|
||||
taskID, store.TeamTaskStatusInProgress, teamID,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return fmt.Errorf("task not in progress or not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Stale recovery
|
||||
// ============================================================
|
||||
|
||||
func (s *PGTeamStore) RecoverStaleTasks(ctx context.Context, teamID uuid.UUID) (int, error) {
|
||||
now := time.Now()
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`UPDATE team_tasks SET status = $1, owner_agent_id = NULL, locked_at = NULL, lock_expires_at = NULL,
|
||||
followup_at = NULL, followup_count = 0, followup_message = NULL, followup_channel = NULL, followup_chat_id = NULL,
|
||||
updated_at = $2
|
||||
WHERE team_id = $3 AND status = $4 AND lock_expires_at IS NOT NULL AND lock_expires_at < $2`,
|
||||
store.TeamTaskStatusPending, now,
|
||||
teamID, store.TeamTaskStatusInProgress,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return int(n), nil
|
||||
}
|
||||
|
||||
func (s *PGTeamStore) ForceRecoverAllTasks(ctx context.Context, teamID uuid.UUID) (int, error) {
|
||||
now := time.Now()
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`UPDATE team_tasks SET status = $1, owner_agent_id = NULL, locked_at = NULL, lock_expires_at = NULL,
|
||||
followup_at = NULL, followup_count = 0, followup_message = NULL, followup_channel = NULL, followup_chat_id = NULL,
|
||||
updated_at = $2
|
||||
WHERE team_id = $3 AND status = $4`,
|
||||
store.TeamTaskStatusPending, now,
|
||||
teamID, store.TeamTaskStatusInProgress,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return int(n), nil
|
||||
}
|
||||
|
||||
func (s *PGTeamStore) ListRecoverableTasks(ctx context.Context, teamID uuid.UUID) ([]store.TeamTaskData, error) {
|
||||
now := time.Now()
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT `+taskSelectCols+`
|
||||
`+taskJoinClause+`
|
||||
WHERE t.team_id = $1
|
||||
AND (
|
||||
t.status = $2
|
||||
OR (t.status = $3 AND t.lock_expires_at IS NOT NULL AND t.lock_expires_at < $4)
|
||||
)
|
||||
ORDER BY t.priority DESC, t.created_at
|
||||
LIMIT $5`,
|
||||
teamID, store.TeamTaskStatusPending, store.TeamTaskStatusInProgress, now, maxListTasksRows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanTaskRowsJoined(rows)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Follow-up reminders
|
||||
// ============================================================
|
||||
|
||||
func (s *PGTeamStore) SetTaskFollowup(ctx context.Context, taskID, teamID uuid.UUID, followupAt time.Time, max int, message, channel, chatID string) error {
|
||||
now := time.Now()
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`UPDATE team_tasks SET followup_at = $1, followup_max = $2, followup_message = $3, followup_channel = $4, followup_chat_id = $5, updated_at = $6
|
||||
WHERE id = $7 AND team_id = $8 AND status = $9`,
|
||||
followupAt, max, message, channel, chatID, now,
|
||||
taskID, teamID, store.TeamTaskStatusInProgress,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return fmt.Errorf("task not in progress or not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PGTeamStore) ClearTaskFollowup(ctx context.Context, taskID uuid.UUID) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`UPDATE team_tasks SET followup_at = NULL, followup_count = 0, followup_message = NULL, followup_channel = NULL, followup_chat_id = NULL, updated_at = $1
|
||||
WHERE id = $2`,
|
||||
time.Now(), taskID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PGTeamStore) ListFollowupDueTasks(ctx context.Context, teamID uuid.UUID) ([]store.TeamTaskData, error) {
|
||||
now := time.Now()
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT `+taskSelectCols+`
|
||||
`+taskJoinClause+`
|
||||
WHERE t.team_id = $1
|
||||
AND t.followup_at IS NOT NULL
|
||||
AND t.followup_at <= $2
|
||||
AND t.status = $3
|
||||
AND (t.followup_max = 0 OR t.followup_count < t.followup_max)
|
||||
ORDER BY t.followup_at
|
||||
LIMIT 50`,
|
||||
teamID, now, store.TeamTaskStatusInProgress,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanTaskRowsJoined(rows)
|
||||
}
|
||||
|
||||
func (s *PGTeamStore) IncrementFollowupCount(ctx context.Context, taskID uuid.UUID, nextAt *time.Time) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`UPDATE team_tasks SET followup_count = followup_count + 1, followup_at = $1, updated_at = $2
|
||||
WHERE id = $3`,
|
||||
nextAt, time.Now(), taskID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PGTeamStore) ClearFollowupByScope(ctx context.Context, channel, chatID string) (int, error) {
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`UPDATE team_tasks
|
||||
SET followup_at = NULL, followup_count = 0, followup_message = NULL,
|
||||
followup_channel = NULL, followup_chat_id = NULL, updated_at = NOW()
|
||||
WHERE followup_channel = $1 AND followup_chat_id = $2
|
||||
AND followup_at IS NOT NULL AND status = $3`,
|
||||
channel, chatID, store.TeamTaskStatusInProgress,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
return int(n), err
|
||||
}
|
||||
|
||||
func (s *PGTeamStore) SetFollowupForActiveTasks(ctx context.Context, teamID uuid.UUID, channel, chatID string, followupAt time.Time, max int, message string) (int, error) {
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`UPDATE team_tasks
|
||||
SET followup_at = $4, followup_max = $5, followup_message = $6,
|
||||
followup_channel = $2, followup_chat_id = $3, updated_at = NOW()
|
||||
WHERE team_id = $1
|
||||
AND status = $7
|
||||
AND followup_at IS NULL
|
||||
AND (
|
||||
(COALESCE(channel,'') = $2 AND COALESCE(chat_id,'') = $3)
|
||||
OR followup_channel = $2
|
||||
OR (COALESCE(channel,'') IN ('', 'system', 'delegate') AND COALESCE(chat_id,'') = '')
|
||||
)`,
|
||||
teamID, channel, chatID, followupAt, max, message, store.TeamTaskStatusInProgress,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
return int(n), err
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
package pg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/lib/pq"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// Workspace files
|
||||
// ============================================================
|
||||
|
||||
func (s *PGTeamStore) UpsertWorkspaceFile(ctx context.Context, file *store.TeamWorkspaceFileData, diskWriteFn func(isNew bool) error) (bool, error) {
|
||||
if file.ID == uuid.Nil {
|
||||
file.ID = store.GenNewID()
|
||||
}
|
||||
now := time.Now()
|
||||
file.CreatedAt = now
|
||||
file.UpdatedAt = now
|
||||
if file.Tags == nil {
|
||||
file.Tags = []string{}
|
||||
}
|
||||
if len(file.Metadata) == 0 {
|
||||
file.Metadata = json.RawMessage(`{}`)
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Advisory lock on hash of team_id:chatID:file_name to prevent concurrent writes.
|
||||
lockKey := file.TeamID.String() + ":" + file.ChatID + ":" + file.FileName
|
||||
var locked bool
|
||||
if err := tx.QueryRowContext(ctx,
|
||||
`SELECT pg_try_advisory_xact_lock(hashtext($1))`, lockKey,
|
||||
).Scan(&locked); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !locked {
|
||||
return false, store.ErrFileLocked
|
||||
}
|
||||
|
||||
// Check if file already exists (scope: team_id + chat_id).
|
||||
var existingID uuid.UUID
|
||||
err = tx.QueryRowContext(ctx,
|
||||
`SELECT id FROM team_workspace_files WHERE team_id = $1 AND chat_id = $2 AND file_name = $3`,
|
||||
file.TeamID, file.ChatID, file.FileName,
|
||||
).Scan(&existingID)
|
||||
|
||||
isNew := err == sql.ErrNoRows
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// Disk I/O under advisory lock — prevents concurrent writes to same file.
|
||||
if diskWriteFn != nil {
|
||||
if err := diskWriteFn(isNew); err != nil {
|
||||
return false, fmt.Errorf("disk write failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if isNew {
|
||||
_, err = tx.ExecContext(ctx,
|
||||
`INSERT INTO team_workspace_files (id, team_id, channel, chat_id, file_name, mime_type, file_path, size_bytes, uploaded_by, task_id, pinned, tags, metadata, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)`,
|
||||
file.ID, file.TeamID, file.Channel, file.ChatID, file.FileName,
|
||||
sql.NullString{String: file.MimeType, Valid: file.MimeType != ""},
|
||||
file.FilePath, file.SizeBytes, file.UploadedBy, file.TaskID,
|
||||
file.Pinned, pq.Array(file.Tags), file.Metadata, now, now,
|
||||
)
|
||||
} else {
|
||||
file.ID = existingID
|
||||
_, err = tx.ExecContext(ctx,
|
||||
`UPDATE team_workspace_files SET file_path = $1, size_bytes = $2, mime_type = $3, uploaded_by = $4, task_id = $5, metadata = $6, updated_at = $7
|
||||
WHERE id = $8`,
|
||||
file.FilePath, file.SizeBytes,
|
||||
sql.NullString{String: file.MimeType, Valid: file.MimeType != ""},
|
||||
file.UploadedBy, file.TaskID, file.Metadata, now, existingID,
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return isNew, tx.Commit()
|
||||
}
|
||||
|
||||
func (s *PGTeamStore) GetWorkspaceFile(ctx context.Context, teamID uuid.UUID, _, chatID, fileName string) (*store.TeamWorkspaceFileData, error) {
|
||||
row := s.db.QueryRowContext(ctx,
|
||||
`SELECT f.id, f.team_id, f.channel, f.chat_id, f.file_name, f.mime_type, f.file_path, f.size_bytes,
|
||||
f.uploaded_by, f.task_id, f.pinned, f.tags, f.metadata, f.archived_at, f.created_at, f.updated_at,
|
||||
COALESCE(a.agent_key, '') AS uploaded_by_key
|
||||
FROM team_workspace_files f
|
||||
LEFT JOIN agents a ON a.id = f.uploaded_by
|
||||
WHERE f.team_id = $1 AND f.chat_id = $2 AND f.file_name = $3`,
|
||||
teamID, chatID, fileName,
|
||||
)
|
||||
f, err := scanWorkspaceFile(row)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("workspace file %q not found", fileName)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &f, nil
|
||||
}
|
||||
|
||||
func (s *PGTeamStore) ListWorkspaceFiles(ctx context.Context, teamID uuid.UUID, _, chatID string) ([]store.TeamWorkspaceFileData, error) {
|
||||
// When chatID is empty the caller wants all files for the team (used by
|
||||
// the Workspace UI tab). When non-empty, filter to that user scope.
|
||||
var (
|
||||
rows *sql.Rows
|
||||
err error
|
||||
)
|
||||
if chatID == "" {
|
||||
rows, err = s.db.QueryContext(ctx,
|
||||
`SELECT f.id, f.team_id, f.channel, f.chat_id, f.file_name, f.mime_type, f.file_path, f.size_bytes,
|
||||
f.uploaded_by, f.task_id, f.pinned, f.tags, f.metadata, f.archived_at, f.created_at, f.updated_at,
|
||||
COALESCE(a.agent_key, '') AS uploaded_by_key
|
||||
FROM team_workspace_files f
|
||||
LEFT JOIN agents a ON a.id = f.uploaded_by
|
||||
WHERE f.team_id = $1 AND f.archived_at IS NULL
|
||||
ORDER BY f.pinned DESC, f.updated_at DESC`,
|
||||
teamID,
|
||||
)
|
||||
} else {
|
||||
rows, err = s.db.QueryContext(ctx,
|
||||
`SELECT f.id, f.team_id, f.channel, f.chat_id, f.file_name, f.mime_type, f.file_path, f.size_bytes,
|
||||
f.uploaded_by, f.task_id, f.pinned, f.tags, f.metadata, f.archived_at, f.created_at, f.updated_at,
|
||||
COALESCE(a.agent_key, '') AS uploaded_by_key
|
||||
FROM team_workspace_files f
|
||||
LEFT JOIN agents a ON a.id = f.uploaded_by
|
||||
WHERE f.team_id = $1 AND f.chat_id = $2 AND f.archived_at IS NULL
|
||||
ORDER BY f.pinned DESC, f.updated_at DESC`,
|
||||
teamID, chatID,
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanWorkspaceFileRows(rows)
|
||||
}
|
||||
|
||||
func (s *PGTeamStore) DeleteWorkspaceFile(ctx context.Context, teamID uuid.UUID, _, chatID, fileName string) (string, error) {
|
||||
var filePath string
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`DELETE FROM team_workspace_files WHERE team_id = $1 AND chat_id = $2 AND file_name = $3 RETURNING file_path`,
|
||||
teamID, chatID, fileName,
|
||||
).Scan(&filePath)
|
||||
if err == sql.ErrNoRows {
|
||||
return "", fmt.Errorf("workspace file %q not found", fileName)
|
||||
}
|
||||
return filePath, err
|
||||
}
|
||||
|
||||
func (s *PGTeamStore) CountWorkspaceFiles(ctx context.Context, teamID uuid.UUID, _, chatID string) (int, error) {
|
||||
var count int
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM team_workspace_files WHERE team_id = $1 AND chat_id = $2 AND archived_at IS NULL`,
|
||||
teamID, chatID,
|
||||
).Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (s *PGTeamStore) PinWorkspaceFile(ctx context.Context, teamID uuid.UUID, _, chatID, fileName string, pinned bool) error {
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`UPDATE team_workspace_files SET pinned = $1, updated_at = $2 WHERE team_id = $3 AND chat_id = $4 AND file_name = $5`,
|
||||
pinned, time.Now(), teamID, chatID, fileName,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("workspace file %q not found", fileName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PGTeamStore) TagWorkspaceFile(ctx context.Context, teamID uuid.UUID, _, chatID, fileName string, tags []string) error {
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`UPDATE team_workspace_files SET tags = $1, updated_at = $2 WHERE team_id = $3 AND chat_id = $4 AND file_name = $5`,
|
||||
pq.Array(tags), time.Now(), teamID, chatID, fileName,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("workspace file %q not found", fileName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PGTeamStore) ListDeliverableFiles(ctx context.Context, teamID uuid.UUID, _, chatID string) ([]store.TeamWorkspaceFileData, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT f.id, f.team_id, f.channel, f.chat_id, f.file_name, f.mime_type, f.file_path, f.size_bytes,
|
||||
f.uploaded_by, f.task_id, f.pinned, f.tags, f.metadata, f.archived_at, f.created_at, f.updated_at,
|
||||
COALESCE(a.agent_key, '') AS uploaded_by_key
|
||||
FROM team_workspace_files f
|
||||
LEFT JOIN agents a ON a.id = f.uploaded_by
|
||||
WHERE f.team_id = $1 AND f.chat_id = $2 AND 'deliverable' = ANY(f.tags) AND f.archived_at IS NULL
|
||||
ORDER BY f.updated_at DESC`,
|
||||
teamID, chatID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanWorkspaceFileRows(rows)
|
||||
}
|
||||
|
||||
func (s *PGTeamStore) ArchiveWorkspaceFilesByTask(ctx context.Context, taskID uuid.UUID) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`UPDATE team_workspace_files SET archived_at = $1, updated_at = $1 WHERE task_id = $2 AND NOT pinned AND archived_at IS NULL`,
|
||||
time.Now(), taskID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PGTeamStore) ListOrphanWorkspaceFiles(ctx context.Context, teamID uuid.UUID, olderThan time.Time) ([]store.TeamWorkspaceFileData, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT f.id, f.team_id, f.channel, f.chat_id, f.file_name, f.mime_type, f.file_path, f.size_bytes,
|
||||
f.uploaded_by, f.task_id, f.pinned, f.tags, f.metadata, f.archived_at, f.created_at, f.updated_at,
|
||||
COALESCE(a.agent_key, '') AS uploaded_by_key
|
||||
FROM team_workspace_files f
|
||||
LEFT JOIN agents a ON a.id = f.uploaded_by
|
||||
WHERE f.team_id = $1 AND f.task_id IS NULL AND f.created_at < $2 AND NOT f.pinned AND f.archived_at IS NULL
|
||||
ORDER BY f.created_at`,
|
||||
teamID, olderThan,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanWorkspaceFileRows(rows)
|
||||
}
|
||||
|
||||
func (s *PGTeamStore) CopyFilesToTeam(ctx context.Context, fileIDs []uuid.UUID, targetTeamID uuid.UUID, _, targetChatID, dataDir string) error {
|
||||
if len(fileIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
targetDir := filepath.Join(dataDir, "teams", targetTeamID.String(), targetChatID)
|
||||
if err := os.MkdirAll(targetDir, 0750); err != nil {
|
||||
return fmt.Errorf("failed to create target workspace dir: %w", err)
|
||||
}
|
||||
|
||||
for _, fid := range fileIDs {
|
||||
var src store.TeamWorkspaceFileData
|
||||
var mimeType sql.NullString
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT id, team_id, channel, chat_id, file_name, mime_type, file_path, size_bytes, uploaded_by, pinned, tags
|
||||
FROM team_workspace_files WHERE id = $1`, fid,
|
||||
).Scan(&src.ID, &src.TeamID, &src.Channel, &src.ChatID, &src.FileName, &mimeType, &src.FilePath, &src.SizeBytes, &src.UploadedBy, &src.Pinned, pq.Array(&src.Tags))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if mimeType.Valid {
|
||||
src.MimeType = mimeType.String
|
||||
}
|
||||
|
||||
// C1: Guard against path traversal from corrupted DB records.
|
||||
if strings.ContainsAny(src.FileName, "/\\") || strings.Contains(src.FileName, "..") || src.FileName == "" {
|
||||
continue
|
||||
}
|
||||
destPath := filepath.Join(targetDir, src.FileName)
|
||||
if !strings.HasPrefix(filepath.Clean(destPath), filepath.Clean(targetDir)+string(os.PathSeparator)) {
|
||||
continue
|
||||
}
|
||||
if err := copyFile(src.FilePath, destPath); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Insert DB record for target team.
|
||||
newID := store.GenNewID()
|
||||
now := time.Now()
|
||||
_, _ = s.db.ExecContext(ctx,
|
||||
`INSERT INTO team_workspace_files (id, team_id, channel, chat_id, file_name, mime_type, file_path, size_bytes, uploaded_by, pinned, tags, created_at, updated_at)
|
||||
VALUES ($1, $2, '', $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
ON CONFLICT (team_id, chat_id, file_name) DO UPDATE SET
|
||||
file_path = EXCLUDED.file_path, size_bytes = EXCLUDED.size_bytes, updated_at = EXCLUDED.updated_at`,
|
||||
newID, targetTeamID, targetChatID, src.FileName,
|
||||
sql.NullString{String: src.MimeType, Valid: src.MimeType != ""},
|
||||
destPath, src.SizeBytes, src.UploadedBy, false, pq.Array(src.Tags), now, now,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PGTeamStore) GetWorkspaceTotalSize(ctx context.Context, teamID uuid.UUID) (int64, error) {
|
||||
var total int64
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT COALESCE(SUM(size_bytes), 0) FROM team_workspace_files WHERE team_id = $1`,
|
||||
teamID,
|
||||
).Scan(&total)
|
||||
return total, err
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Workspace file versions
|
||||
// ============================================================
|
||||
|
||||
func (s *PGTeamStore) CreateFileVersion(ctx context.Context, v *store.TeamWorkspaceFileVersionData) error {
|
||||
if v.ID == uuid.Nil {
|
||||
v.ID = store.GenNewID()
|
||||
}
|
||||
v.CreatedAt = time.Now()
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO team_workspace_file_versions (id, file_id, version, file_path, size_bytes, uploaded_by, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
v.ID, v.FileID, v.Version, v.FilePath, v.SizeBytes, v.UploadedBy, v.CreatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PGTeamStore) ListFileVersions(ctx context.Context, fileID uuid.UUID) ([]store.TeamWorkspaceFileVersionData, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT v.id, v.file_id, v.version, v.file_path, v.size_bytes, v.uploaded_by, v.created_at,
|
||||
COALESCE(a.agent_key, '') AS uploaded_by_key
|
||||
FROM team_workspace_file_versions v
|
||||
LEFT JOIN agents a ON a.id = v.uploaded_by
|
||||
WHERE v.file_id = $1
|
||||
ORDER BY v.version DESC`, fileID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var versions []store.TeamWorkspaceFileVersionData
|
||||
for rows.Next() {
|
||||
var v store.TeamWorkspaceFileVersionData
|
||||
if err := rows.Scan(&v.ID, &v.FileID, &v.Version, &v.FilePath, &v.SizeBytes, &v.UploadedBy, &v.CreatedAt, &v.UploadedByKey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
versions = append(versions, v)
|
||||
}
|
||||
return versions, rows.Err()
|
||||
}
|
||||
|
||||
func (s *PGTeamStore) GetFileVersion(ctx context.Context, fileID uuid.UUID, version int) (*store.TeamWorkspaceFileVersionData, error) {
|
||||
var v store.TeamWorkspaceFileVersionData
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT v.id, v.file_id, v.version, v.file_path, v.size_bytes, v.uploaded_by, v.created_at,
|
||||
COALESCE(a.agent_key, '') AS uploaded_by_key
|
||||
FROM team_workspace_file_versions v
|
||||
LEFT JOIN agents a ON a.id = v.uploaded_by
|
||||
WHERE v.file_id = $1 AND v.version = $2`, fileID, version,
|
||||
).Scan(&v.ID, &v.FileID, &v.Version, &v.FilePath, &v.SizeBytes, &v.UploadedBy, &v.CreatedAt, &v.UploadedByKey)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("version %d not found", version)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &v, nil
|
||||
}
|
||||
|
||||
func (s *PGTeamStore) PruneOldVersions(ctx context.Context, fileID uuid.UUID, keepN int) ([]string, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`DELETE FROM team_workspace_file_versions
|
||||
WHERE id IN (
|
||||
SELECT id FROM team_workspace_file_versions
|
||||
WHERE file_id = $1
|
||||
ORDER BY version DESC
|
||||
OFFSET $2
|
||||
) RETURNING file_path`, fileID, keepN,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var paths []string
|
||||
for rows.Next() {
|
||||
var p string
|
||||
if err := rows.Scan(&p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
paths = append(paths, p)
|
||||
}
|
||||
return paths, rows.Err()
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Workspace comments
|
||||
// ============================================================
|
||||
|
||||
func (s *PGTeamStore) AddFileComment(ctx context.Context, c *store.TeamWorkspaceCommentData) error {
|
||||
if c.ID == uuid.Nil {
|
||||
c.ID = store.GenNewID()
|
||||
}
|
||||
c.CreatedAt = time.Now()
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO team_workspace_comments (id, file_id, agent_id, content, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
c.ID, c.FileID, c.AgentID, c.Content, c.CreatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PGTeamStore) ListFileComments(ctx context.Context, fileID uuid.UUID) ([]store.TeamWorkspaceCommentData, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT c.id, c.file_id, c.agent_id, c.content, c.created_at,
|
||||
COALESCE(a.agent_key, '') AS agent_key
|
||||
FROM team_workspace_comments c
|
||||
LEFT JOIN agents a ON a.id = c.agent_id
|
||||
WHERE c.file_id = $1
|
||||
ORDER BY c.created_at`, fileID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var comments []store.TeamWorkspaceCommentData
|
||||
for rows.Next() {
|
||||
var c store.TeamWorkspaceCommentData
|
||||
if err := rows.Scan(&c.ID, &c.FileID, &c.AgentID, &c.Content, &c.CreatedAt, &c.AgentKey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
comments = append(comments, c)
|
||||
}
|
||||
return comments, rows.Err()
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Helpers
|
||||
// ============================================================
|
||||
|
||||
// scanner is satisfied by both *sql.Row and *sql.Rows.
|
||||
type scanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
// scanWorkspaceFile scans a single workspace file row into a struct.
|
||||
func scanWorkspaceFile(s scanner) (store.TeamWorkspaceFileData, error) {
|
||||
var f store.TeamWorkspaceFileData
|
||||
var mimeType sql.NullString
|
||||
var taskID *uuid.UUID
|
||||
var archivedAt *time.Time
|
||||
if err := s.Scan(
|
||||
&f.ID, &f.TeamID, &f.Channel, &f.ChatID, &f.FileName, &mimeType, &f.FilePath, &f.SizeBytes,
|
||||
&f.UploadedBy, &taskID, &f.Pinned, pq.Array(&f.Tags), &f.Metadata, &archivedAt, &f.CreatedAt, &f.UpdatedAt,
|
||||
&f.UploadedByKey,
|
||||
); err != nil {
|
||||
return f, err
|
||||
}
|
||||
if mimeType.Valid {
|
||||
f.MimeType = mimeType.String
|
||||
}
|
||||
f.TaskID = taskID
|
||||
f.ArchivedAt = archivedAt
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func scanWorkspaceFileRows(rows *sql.Rows) ([]store.TeamWorkspaceFileData, error) {
|
||||
var files []store.TeamWorkspaceFileData
|
||||
for rows.Next() {
|
||||
f, err := scanWorkspaceFile(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
files = append(files, f)
|
||||
}
|
||||
return files, rows.Err()
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
sf, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer sf.Close()
|
||||
|
||||
df, err := os.OpenFile(dst, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0640)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer df.Close()
|
||||
|
||||
_, err = io.Copy(df, sf)
|
||||
return err
|
||||
}
|
||||
+219
-14
@@ -3,11 +3,18 @@ package store
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ErrFileLocked is returned when a workspace file is being written by another agent.
|
||||
var ErrFileLocked = errors.New("file is being written by another agent, try again shortly")
|
||||
|
||||
// ErrTaskNotFound is returned when a task does not exist.
|
||||
var ErrTaskNotFound = errors.New("task not found")
|
||||
|
||||
// Team status constants.
|
||||
const (
|
||||
TeamStatusActive = "active"
|
||||
@@ -23,17 +30,19 @@ const (
|
||||
|
||||
// Team task status constants.
|
||||
const (
|
||||
TeamTaskStatusPending = "pending"
|
||||
TeamTaskStatusPendingApproval = "pending_approval"
|
||||
TeamTaskStatusInProgress = "in_progress"
|
||||
TeamTaskStatusCompleted = "completed"
|
||||
TeamTaskStatusBlocked = "blocked"
|
||||
TeamTaskStatusFailed = "failed"
|
||||
TeamTaskStatusPending = "pending"
|
||||
TeamTaskStatusInProgress = "in_progress"
|
||||
TeamTaskStatusCompleted = "completed"
|
||||
TeamTaskStatusBlocked = "blocked"
|
||||
TeamTaskStatusFailed = "failed"
|
||||
TeamTaskStatusInReview = "in_review"
|
||||
TeamTaskStatusCancelled = "cancelled"
|
||||
)
|
||||
|
||||
// Team task list filter constants (for ListTasks statusFilter parameter).
|
||||
const (
|
||||
TeamTaskFilterActive = "" // default: pending + pending_approval + in_progress + blocked
|
||||
TeamTaskFilterActive = "" // default: pending + in_progress + blocked
|
||||
TeamTaskFilterInReview = "in_review" // only in_review tasks
|
||||
TeamTaskFilterCompleted = "completed" // only completed tasks
|
||||
TeamTaskFilterAll = "all" // all statuses
|
||||
)
|
||||
@@ -55,7 +64,8 @@ type TeamData struct {
|
||||
CreatedBy string `json:"created_by"`
|
||||
|
||||
// Joined fields (populated by queries that JOIN agents table)
|
||||
LeadAgentKey string `json:"lead_agent_key,omitempty"`
|
||||
LeadAgentKey string `json:"lead_agent_key,omitempty"`
|
||||
LeadDisplayName string `json:"lead_display_name,omitempty"`
|
||||
}
|
||||
|
||||
// TeamMemberData represents a team member.
|
||||
@@ -86,8 +96,66 @@ type TeamTaskData struct {
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
Channel string `json:"channel,omitempty"`
|
||||
|
||||
// V2 fields
|
||||
TaskType string `json:"task_type"`
|
||||
TaskNumber int `json:"task_number,omitempty"`
|
||||
Identifier string `json:"identifier,omitempty"`
|
||||
CreatedByAgentID *uuid.UUID `json:"created_by_agent_id,omitempty"`
|
||||
AssigneeUserID string `json:"assignee_user_id,omitempty"`
|
||||
ParentID *uuid.UUID `json:"parent_id,omitempty"`
|
||||
ChatID string `json:"chat_id,omitempty"`
|
||||
LockedAt *time.Time `json:"locked_at,omitempty"`
|
||||
LockExpiresAt *time.Time `json:"lock_expires_at,omitempty"`
|
||||
ProgressPercent int `json:"progress_percent,omitempty"`
|
||||
ProgressStep string `json:"progress_step,omitempty"`
|
||||
|
||||
// Follow-up reminder fields
|
||||
FollowupAt *time.Time `json:"followup_at,omitempty"`
|
||||
FollowupCount int `json:"followup_count,omitempty"`
|
||||
FollowupMax int `json:"followup_max,omitempty"`
|
||||
FollowupMessage string `json:"followup_message,omitempty"`
|
||||
FollowupChannel string `json:"followup_channel,omitempty"`
|
||||
FollowupChatID string `json:"followup_chat_id,omitempty"`
|
||||
|
||||
// Joined fields
|
||||
OwnerAgentKey string `json:"owner_agent_key,omitempty"`
|
||||
OwnerAgentKey string `json:"owner_agent_key,omitempty"`
|
||||
CreatedByAgentKey string `json:"created_by_agent_key,omitempty"`
|
||||
}
|
||||
|
||||
// TeamTaskCommentData represents a comment on a team task.
|
||||
type TeamTaskCommentData struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
TaskID uuid.UUID `json:"task_id"`
|
||||
AgentID *uuid.UUID `json:"agent_id,omitempty"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
Content string `json:"content"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
// Joined
|
||||
AgentKey string `json:"agent_key,omitempty"`
|
||||
}
|
||||
|
||||
// TeamTaskEventData represents an audit event on a team task.
|
||||
type TeamTaskEventData struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
TaskID uuid.UUID `json:"task_id"`
|
||||
EventType string `json:"event_type"`
|
||||
ActorType string `json:"actor_type"` // "agent" | "human"
|
||||
ActorID string `json:"actor_id"`
|
||||
Data json.RawMessage `json:"data,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// TeamTaskAttachmentData represents a workspace file attached to a team task.
|
||||
type TeamTaskAttachmentData struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
TaskID uuid.UUID `json:"task_id"`
|
||||
FileID uuid.UUID `json:"file_id"`
|
||||
AddedBy *uuid.UUID `json:"added_by,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
// Joined
|
||||
FileName string `json:"file_name,omitempty"`
|
||||
}
|
||||
|
||||
// DelegationHistoryData represents a persisted delegation record.
|
||||
@@ -128,6 +196,7 @@ type DelegationHistoryListOpts struct {
|
||||
// HandoffRouteData represents an active routing override for agent handoff.
|
||||
type HandoffRouteData struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
TeamID uuid.UUID `json:"team_id,omitempty"`
|
||||
Channel string `json:"channel"`
|
||||
ChatID string `json:"chat_id"`
|
||||
FromAgentKey string `json:"from_agent_key"`
|
||||
@@ -156,6 +225,61 @@ type TeamMessageData struct {
|
||||
ToAgentKey string `json:"to_agent_key,omitempty"`
|
||||
}
|
||||
|
||||
// TeamWorkspaceFileData represents a file in the team's shared workspace.
|
||||
type TeamWorkspaceFileData struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
TeamID uuid.UUID `json:"team_id"`
|
||||
Channel string `json:"channel"`
|
||||
ChatID string `json:"chat_id"`
|
||||
FileName string `json:"file_name"`
|
||||
MimeType string `json:"mime_type,omitempty"`
|
||||
FilePath string `json:"-"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
UploadedBy uuid.UUID `json:"uploaded_by"`
|
||||
TaskID *uuid.UUID `json:"task_id,omitempty"`
|
||||
Pinned bool `json:"pinned"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Metadata json.RawMessage `json:"metadata,omitempty"`
|
||||
ArchivedAt *time.Time `json:"archived_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
// Joined
|
||||
UploadedByKey string `json:"uploaded_by_key,omitempty"`
|
||||
}
|
||||
|
||||
// TeamWorkspaceFileVersionData represents a version of a workspace file.
|
||||
type TeamWorkspaceFileVersionData struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
FileID uuid.UUID `json:"file_id"`
|
||||
Version int `json:"version"`
|
||||
FilePath string `json:"-"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
UploadedBy uuid.UUID `json:"uploaded_by"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
// Joined
|
||||
UploadedByKey string `json:"uploaded_by_key,omitempty"`
|
||||
}
|
||||
|
||||
// TeamWorkspaceCommentData represents a comment on a workspace file.
|
||||
type TeamWorkspaceCommentData struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
FileID uuid.UUID `json:"file_id"`
|
||||
AgentID uuid.UUID `json:"agent_id"`
|
||||
Content string `json:"content"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
// Joined
|
||||
AgentKey string `json:"agent_key,omitempty"`
|
||||
}
|
||||
|
||||
// ScopeEntry represents a unique channel+chatID scope across tasks and workspace.
|
||||
type ScopeEntry struct {
|
||||
Channel string `json:"channel"`
|
||||
ChatID string `json:"chat_id"`
|
||||
}
|
||||
|
||||
// TeamStore manages agent teams, tasks, and messages.
|
||||
type TeamStore interface {
|
||||
// Team CRUD
|
||||
@@ -169,6 +293,8 @@ type TeamStore interface {
|
||||
AddMember(ctx context.Context, teamID, agentID uuid.UUID, role string) error
|
||||
RemoveMember(ctx context.Context, teamID, agentID uuid.UUID) error
|
||||
ListMembers(ctx context.Context, teamID uuid.UUID) ([]TeamMemberData, error)
|
||||
// ListIdleMembers returns team members (non-lead) that have no in_progress tasks.
|
||||
ListIdleMembers(ctx context.Context, teamID uuid.UUID) ([]TeamMemberData, error)
|
||||
|
||||
// GetTeamForAgent returns the team that the given agent belongs to.
|
||||
// Returns nil, nil if the agent is not in any team.
|
||||
@@ -178,13 +304,17 @@ type TeamStore interface {
|
||||
// Used by team settings UI to populate user select boxes.
|
||||
KnownUserIDs(ctx context.Context, teamID uuid.UUID, limit int) ([]string, error)
|
||||
|
||||
// Scopes (channel+chatID discovery across tasks and workspace)
|
||||
ListTaskScopes(ctx context.Context, teamID uuid.UUID) ([]ScopeEntry, error)
|
||||
|
||||
// Tasks (shared task list)
|
||||
CreateTask(ctx context.Context, task *TeamTaskData) error
|
||||
UpdateTask(ctx context.Context, taskID uuid.UUID, updates map[string]any) error
|
||||
// ListTasks returns tasks for a team. orderBy: "priority" or "newest".
|
||||
// statusFilter: "" = non-completed (default), "completed", "all".
|
||||
// userID: if non-empty, filter to tasks created by this user.
|
||||
ListTasks(ctx context.Context, teamID uuid.UUID, orderBy string, statusFilter string, userID string) ([]TeamTaskData, error)
|
||||
// channel+chatID: if either is non-empty, filter to that exact scope.
|
||||
ListTasks(ctx context.Context, teamID uuid.UUID, orderBy string, statusFilter string, userID string, channel string, chatID string) ([]TeamTaskData, error)
|
||||
// GetTask returns a single task by ID with joined agent info.
|
||||
GetTask(ctx context.Context, taskID uuid.UUID) (*TeamTaskData, error)
|
||||
// SearchTasks performs FTS search over task subject+description.
|
||||
@@ -196,14 +326,14 @@ type TeamStore interface {
|
||||
// teamID is validated in the WHERE clause to prevent cross-team task claiming.
|
||||
ClaimTask(ctx context.Context, taskID, agentID, teamID uuid.UUID) error
|
||||
|
||||
// AssignTask admin-assigns a pending task to a specific agent.
|
||||
// teamID is validated in the WHERE clause to prevent cross-team assignment.
|
||||
AssignTask(ctx context.Context, taskID, agentID, teamID uuid.UUID) error
|
||||
|
||||
// CompleteTask marks a task as completed and unblocks dependent tasks.
|
||||
// teamID is validated in the WHERE clause to prevent cross-team task completion.
|
||||
CompleteTask(ctx context.Context, taskID, teamID uuid.UUID, result string) error
|
||||
|
||||
// ApproveTask atomically transitions a task from pending_approval to pending.
|
||||
// Returns error if the task is not in pending_approval status or not found.
|
||||
ApproveTask(ctx context.Context, taskID, teamID uuid.UUID) error
|
||||
|
||||
// CancelTask marks a non-completed task as cancelled (status=completed, result="CANCELLED: ..."),
|
||||
// unblocks dependent tasks, and transitions blocked→pending when all blockers are resolved.
|
||||
// Returns error if the task is already completed or not found.
|
||||
@@ -213,6 +343,48 @@ type TeamStore interface {
|
||||
// Unblocks dependent tasks so they aren't stuck.
|
||||
FailTask(ctx context.Context, taskID, teamID uuid.UUID, errMsg string) error
|
||||
|
||||
// Review workflow
|
||||
ReviewTask(ctx context.Context, taskID, teamID uuid.UUID) error
|
||||
ApproveTask(ctx context.Context, taskID, teamID uuid.UUID, comment string) error
|
||||
RejectTask(ctx context.Context, taskID, teamID uuid.UUID, reason string) error
|
||||
|
||||
// Task comments
|
||||
AddTaskComment(ctx context.Context, comment *TeamTaskCommentData) error
|
||||
ListTaskComments(ctx context.Context, taskID uuid.UUID) ([]TeamTaskCommentData, error)
|
||||
|
||||
// Audit events
|
||||
RecordTaskEvent(ctx context.Context, event *TeamTaskEventData) error
|
||||
ListTaskEvents(ctx context.Context, taskID uuid.UUID) ([]TeamTaskEventData, error)
|
||||
|
||||
// Attachments
|
||||
AttachFileToTask(ctx context.Context, att *TeamTaskAttachmentData) error
|
||||
ListTaskAttachments(ctx context.Context, taskID uuid.UUID) ([]TeamTaskAttachmentData, error)
|
||||
DetachFileFromTask(ctx context.Context, taskID, fileID uuid.UUID) error
|
||||
|
||||
// Follow-up reminders
|
||||
SetTaskFollowup(ctx context.Context, taskID, teamID uuid.UUID, followupAt time.Time, max int, message, channel, chatID string) error
|
||||
ClearTaskFollowup(ctx context.Context, taskID uuid.UUID) error
|
||||
ListFollowupDueTasks(ctx context.Context, teamID uuid.UUID) ([]TeamTaskData, error)
|
||||
IncrementFollowupCount(ctx context.Context, taskID uuid.UUID, nextAt *time.Time) error
|
||||
|
||||
// Auto follow-up guardrails (system-level, no LLM dependency)
|
||||
// ClearFollowupByScope clears followup on all in_progress tasks for a given channel+chatID scope.
|
||||
ClearFollowupByScope(ctx context.Context, channel, chatID string) (int, error)
|
||||
// SetFollowupForActiveTasks sets followup on in_progress tasks that don't already have one.
|
||||
// Matches tasks scoped to the given channel+chatID, or unscoped tasks in the same team.
|
||||
SetFollowupForActiveTasks(ctx context.Context, teamID uuid.UUID, channel, chatID string, followupAt time.Time, max int, message string) (int, error)
|
||||
|
||||
// Progress
|
||||
UpdateTaskProgress(ctx context.Context, taskID, teamID uuid.UUID, percent int, step string) error
|
||||
|
||||
// Stale recovery
|
||||
RecoverStaleTasks(ctx context.Context, teamID uuid.UUID) (int, error)
|
||||
// ForceRecoverAllTasks resets ALL in_progress tasks back to pending (ignoring lock expiry).
|
||||
// Used on startup when no agents are running.
|
||||
ForceRecoverAllTasks(ctx context.Context, teamID uuid.UUID) (int, error)
|
||||
// ListRecoverableTasks returns all pending tasks (including stale in_progress with expired locks).
|
||||
ListRecoverableTasks(ctx context.Context, teamID uuid.UUID) ([]TeamTaskData, error)
|
||||
|
||||
// Delegation history
|
||||
SaveDelegationHistory(ctx context.Context, record *DelegationHistoryData) error
|
||||
ListDelegationHistory(ctx context.Context, opts DelegationHistoryListOpts) ([]DelegationHistoryData, int, error)
|
||||
@@ -229,4 +401,37 @@ type TeamStore interface {
|
||||
MarkRead(ctx context.Context, messageID uuid.UUID) error
|
||||
// ListMessages returns paginated team messages ordered by created_at DESC.
|
||||
ListMessages(ctx context.Context, teamID uuid.UUID, limit, offset int) ([]TeamMessageData, int, error)
|
||||
|
||||
// Workspace files
|
||||
// UpsertWorkspaceFile acquires an advisory lock, calls diskWriteFn (if non-nil)
|
||||
// to perform disk I/O under that lock, then upserts DB metadata — all within one tx.
|
||||
// diskWriteFn receives isNew (true if this is a new file, false if update).
|
||||
UpsertWorkspaceFile(ctx context.Context, file *TeamWorkspaceFileData, diskWriteFn func(isNew bool) error) (isNew bool, err error)
|
||||
GetWorkspaceFile(ctx context.Context, teamID uuid.UUID, channel, chatID, fileName string) (*TeamWorkspaceFileData, error)
|
||||
// ListWorkspaceFiles returns non-archived files for a team.
|
||||
// When both channel and chatID are empty, all files for the team are returned
|
||||
// regardless of scope (used by the Workspace UI tab).
|
||||
// When either is non-empty, results are filtered to that exact channel+chatID scope.
|
||||
ListWorkspaceFiles(ctx context.Context, teamID uuid.UUID, channel, chatID string) ([]TeamWorkspaceFileData, error)
|
||||
DeleteWorkspaceFile(ctx context.Context, teamID uuid.UUID, channel, chatID, fileName string) (filePath string, err error)
|
||||
CountWorkspaceFiles(ctx context.Context, teamID uuid.UUID, channel, chatID string) (int, error)
|
||||
PinWorkspaceFile(ctx context.Context, teamID uuid.UUID, channel, chatID, fileName string, pinned bool) error
|
||||
TagWorkspaceFile(ctx context.Context, teamID uuid.UUID, channel, chatID, fileName string, tags []string) error
|
||||
ListDeliverableFiles(ctx context.Context, teamID uuid.UUID, channel, chatID string) ([]TeamWorkspaceFileData, error)
|
||||
ArchiveWorkspaceFilesByTask(ctx context.Context, taskID uuid.UUID) error
|
||||
ListOrphanWorkspaceFiles(ctx context.Context, teamID uuid.UUID, olderThan time.Time) ([]TeamWorkspaceFileData, error)
|
||||
CopyFilesToTeam(ctx context.Context, fileIDs []uuid.UUID, targetTeamID uuid.UUID, targetChannel, targetChatID, dataDir string) error
|
||||
|
||||
// Workspace versioning
|
||||
CreateFileVersion(ctx context.Context, version *TeamWorkspaceFileVersionData) error
|
||||
ListFileVersions(ctx context.Context, fileID uuid.UUID) ([]TeamWorkspaceFileVersionData, error)
|
||||
GetFileVersion(ctx context.Context, fileID uuid.UUID, version int) (*TeamWorkspaceFileVersionData, error)
|
||||
PruneOldVersions(ctx context.Context, fileID uuid.UUID, keepN int) ([]string, error) // returns pruned file paths
|
||||
|
||||
// Workspace comments
|
||||
AddFileComment(ctx context.Context, comment *TeamWorkspaceCommentData) error
|
||||
ListFileComments(ctx context.Context, fileID uuid.UUID) ([]TeamWorkspaceCommentData, error)
|
||||
|
||||
// Workspace quota
|
||||
GetWorkspaceTotalSize(ctx context.Context, teamID uuid.UUID) (int64, error)
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/tools"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultRecoveryInterval = 5 * time.Minute
|
||||
dispatchCooldown = 10 * time.Minute
|
||||
leadNotifyCooldown = 30 * time.Minute
|
||||
followupCooldown = 5 * time.Minute
|
||||
defaultFollowupInterval = 30 * time.Minute
|
||||
)
|
||||
|
||||
// isTeamV2 delegates to tools.IsTeamV2 for version checking.
|
||||
var isTeamV2 = tools.IsTeamV2
|
||||
|
||||
// TaskTicker periodically recovers stale tasks and re-dispatches pending work.
|
||||
type TaskTicker struct {
|
||||
teams store.TeamStore
|
||||
agents store.AgentStore
|
||||
msgBus *bus.MessageBus
|
||||
interval time.Duration
|
||||
|
||||
stopCh chan struct{}
|
||||
wg sync.WaitGroup
|
||||
|
||||
mu sync.Mutex
|
||||
lastDispatched map[uuid.UUID]time.Time // taskID → last dispatch time
|
||||
lastLeadNotified map[uuid.UUID]time.Time // teamID → last lead notify time
|
||||
lastFollowupSent map[uuid.UUID]time.Time // taskID → last followup sent time
|
||||
}
|
||||
|
||||
func NewTaskTicker(teams store.TeamStore, agents store.AgentStore, msgBus *bus.MessageBus, intervalSec int) *TaskTicker {
|
||||
interval := defaultRecoveryInterval
|
||||
if intervalSec > 0 {
|
||||
interval = time.Duration(intervalSec) * time.Second
|
||||
}
|
||||
return &TaskTicker{
|
||||
teams: teams,
|
||||
agents: agents,
|
||||
msgBus: msgBus,
|
||||
interval: interval,
|
||||
stopCh: make(chan struct{}),
|
||||
lastDispatched: make(map[uuid.UUID]time.Time),
|
||||
lastLeadNotified: make(map[uuid.UUID]time.Time),
|
||||
lastFollowupSent: make(map[uuid.UUID]time.Time),
|
||||
}
|
||||
}
|
||||
|
||||
// Start launches the background recovery loop.
|
||||
func (t *TaskTicker) Start() {
|
||||
t.wg.Add(1)
|
||||
go t.loop()
|
||||
slog.Info("task ticker started", "interval", t.interval)
|
||||
}
|
||||
|
||||
// Stop signals the ticker to stop and waits for completion.
|
||||
func (t *TaskTicker) Stop() {
|
||||
close(t.stopCh)
|
||||
t.wg.Wait()
|
||||
slog.Info("task ticker stopped")
|
||||
}
|
||||
|
||||
func (t *TaskTicker) loop() {
|
||||
defer t.wg.Done()
|
||||
|
||||
// On startup: force-recover ALL in_progress tasks (lock may not be expired yet,
|
||||
// but no agent is running after a restart).
|
||||
t.recoverAll(true)
|
||||
|
||||
ticker := time.NewTicker(t.interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-t.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
// Periodic: only recover tasks with expired locks.
|
||||
t.recoverAll(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *TaskTicker) recoverAll(forceRecover bool) {
|
||||
ctx := context.Background()
|
||||
|
||||
teams, err := t.teams.ListTeams(ctx)
|
||||
if err != nil {
|
||||
slog.Warn("task_ticker: list teams", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, team := range teams {
|
||||
if team.Status != store.TeamStatusActive {
|
||||
continue
|
||||
}
|
||||
// Skip v1 teams — ticker features (locking, followup, recovery) are v2 only.
|
||||
if !isTeamV2(&team) {
|
||||
continue
|
||||
}
|
||||
// Process followups BEFORE recovery: recovery resets in_progress→pending,
|
||||
// which would make followup tasks invisible to ListFollowupDueTasks
|
||||
// (it only queries status='in_progress').
|
||||
t.processFollowups(ctx, team)
|
||||
t.recoverTeam(ctx, team, forceRecover)
|
||||
}
|
||||
|
||||
// Prune old cooldown entries to prevent memory leak.
|
||||
t.pruneCooldowns()
|
||||
}
|
||||
|
||||
func (t *TaskTicker) recoverTeam(ctx context.Context, team store.TeamData, forceRecover bool) {
|
||||
// Step 1: Reset in_progress tasks back to pending.
|
||||
// On startup (forceRecover=true): reset ALL in_progress — no agent is running after restart.
|
||||
// On periodic tick: only reset tasks with expired locks.
|
||||
var recovered int
|
||||
var err error
|
||||
if forceRecover {
|
||||
recovered, err = t.teams.ForceRecoverAllTasks(ctx, team.ID)
|
||||
} else {
|
||||
recovered, err = t.teams.RecoverStaleTasks(ctx, team.ID)
|
||||
}
|
||||
if err != nil {
|
||||
slog.Warn("task_ticker: recover tasks", "team_id", team.ID, "force", forceRecover, "error", err)
|
||||
return
|
||||
}
|
||||
if recovered > 0 {
|
||||
slog.Info("task_ticker: recovered tasks", "team_id", team.ID, "count", recovered, "force", forceRecover)
|
||||
}
|
||||
|
||||
// Step 2: List all recoverable tasks (pending + stale in_progress with expired locks).
|
||||
tasks, err := t.teams.ListRecoverableTasks(ctx, team.ID)
|
||||
if err != nil {
|
||||
slog.Warn("task_ticker: list recoverable", "team_id", team.ID, "error", err)
|
||||
return
|
||||
}
|
||||
if len(tasks) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
var unassigned []store.TeamTaskData
|
||||
|
||||
for i := range tasks {
|
||||
task := &tasks[i]
|
||||
if task.OwnerAgentID != nil {
|
||||
// Pending task with an assigned owner — re-dispatch it.
|
||||
t.mu.Lock()
|
||||
last, exists := t.lastDispatched[task.ID]
|
||||
t.mu.Unlock()
|
||||
if exists && now.Sub(last) < dispatchCooldown {
|
||||
continue
|
||||
}
|
||||
|
||||
t.dispatchTask(ctx, task, team.ID)
|
||||
|
||||
t.mu.Lock()
|
||||
t.lastDispatched[task.ID] = now
|
||||
t.mu.Unlock()
|
||||
} else {
|
||||
unassigned = append(unassigned, *task)
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Unassigned tasks — only notify lead if there are idle agents available.
|
||||
if len(unassigned) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
idleMembers, err := t.teams.ListIdleMembers(ctx, team.ID)
|
||||
if err != nil {
|
||||
slog.Warn("task_ticker: list idle members", "team_id", team.ID, "error", err)
|
||||
return
|
||||
}
|
||||
if len(idleMembers) == 0 {
|
||||
// All agents busy — skip, will retry next tick.
|
||||
return
|
||||
}
|
||||
|
||||
t.mu.Lock()
|
||||
last, exists := t.lastLeadNotified[team.ID]
|
||||
t.mu.Unlock()
|
||||
if !exists || now.Sub(last) >= leadNotifyCooldown {
|
||||
t.notifyLead(ctx, team, unassigned, idleMembers)
|
||||
t.mu.Lock()
|
||||
t.lastLeadNotified[team.ID] = now
|
||||
t.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// processFollowups sends follow-up reminders for tasks awaiting user reply.
|
||||
// Called at the end of each recoverAll cycle.
|
||||
func (t *TaskTicker) processFollowups(ctx context.Context, team store.TeamData) {
|
||||
tasks, err := t.teams.ListFollowupDueTasks(ctx, team.ID)
|
||||
if err != nil {
|
||||
slog.Warn("task_ticker: list followup tasks", "team_id", team.ID, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
interval := followupInterval(team)
|
||||
|
||||
for i := range tasks {
|
||||
task := &tasks[i]
|
||||
|
||||
// Cooldown: don't send more often than followupCooldown.
|
||||
t.mu.Lock()
|
||||
lastSent, exists := t.lastFollowupSent[task.ID]
|
||||
t.mu.Unlock()
|
||||
if exists && now.Sub(lastSent) < followupCooldown {
|
||||
continue
|
||||
}
|
||||
|
||||
if task.FollowupChannel == "" || task.FollowupChatID == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Format reminder message.
|
||||
countLabel := fmt.Sprintf("%d", task.FollowupCount+1)
|
||||
if task.FollowupMax > 0 {
|
||||
countLabel = fmt.Sprintf("%d/%d", task.FollowupCount+1, task.FollowupMax)
|
||||
}
|
||||
content := fmt.Sprintf("Reminder (%s): %s", countLabel, task.FollowupMessage)
|
||||
|
||||
if !t.msgBus.TryPublishOutbound(bus.OutboundMessage{
|
||||
Channel: task.FollowupChannel,
|
||||
ChatID: task.FollowupChatID,
|
||||
Content: content,
|
||||
}) {
|
||||
slog.Warn("task_ticker: outbound buffer full, skipping followup", "task_id", task.ID)
|
||||
continue
|
||||
}
|
||||
|
||||
// Compute next followup_at.
|
||||
newCount := task.FollowupCount + 1
|
||||
var nextAt *time.Time
|
||||
if task.FollowupMax == 0 || newCount < task.FollowupMax {
|
||||
next := now.Add(interval)
|
||||
nextAt = &next
|
||||
}
|
||||
// nextAt = nil when max reached → stops future reminders.
|
||||
|
||||
if err := t.teams.IncrementFollowupCount(ctx, task.ID, nextAt); err != nil {
|
||||
slog.Warn("task_ticker: increment followup count", "task_id", task.ID, "error", err)
|
||||
}
|
||||
|
||||
t.mu.Lock()
|
||||
t.lastFollowupSent[task.ID] = now
|
||||
t.mu.Unlock()
|
||||
|
||||
slog.Info("task_ticker: sent followup reminder",
|
||||
"task_id", task.ID,
|
||||
"task_number", task.TaskNumber,
|
||||
"count", newCount,
|
||||
"channel", task.FollowupChannel,
|
||||
"team_id", team.ID,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *TaskTicker) dispatchTask(ctx context.Context, task *store.TeamTaskData, teamID uuid.UUID) {
|
||||
ag, err := t.agents.GetByID(ctx, *task.OwnerAgentID)
|
||||
if err != nil {
|
||||
slog.Warn("task_ticker: resolve agent", "agent_id", task.OwnerAgentID, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
content := fmt.Sprintf("[Assigned task #%d]: %s", task.TaskNumber, task.Subject)
|
||||
if task.Description != "" {
|
||||
content += "\n\n" + task.Description
|
||||
}
|
||||
|
||||
if !t.msgBus.TryPublishInbound(bus.InboundMessage{
|
||||
Channel: "system",
|
||||
SenderID: "teammate:dashboard",
|
||||
ChatID: teamID.String(),
|
||||
Content: content,
|
||||
UserID: task.UserID,
|
||||
AgentID: ag.AgentKey,
|
||||
Metadata: map[string]string{
|
||||
"origin_channel": "dashboard",
|
||||
"origin_peer_kind": "direct",
|
||||
"from_agent": "dashboard",
|
||||
"to_agent": ag.AgentKey,
|
||||
"team_task_id": task.ID.String(),
|
||||
"team_id": teamID.String(),
|
||||
},
|
||||
}) {
|
||||
slog.Warn("task_ticker: inbound buffer full, skipping dispatch", "task_id", task.ID)
|
||||
return
|
||||
}
|
||||
slog.Info("task_ticker: re-dispatched task",
|
||||
"task_id", task.ID,
|
||||
"task_number", task.TaskNumber,
|
||||
"agent_key", ag.AgentKey,
|
||||
"team_id", teamID,
|
||||
)
|
||||
}
|
||||
|
||||
func (t *TaskTicker) notifyLead(ctx context.Context, team store.TeamData, tasks []store.TeamTaskData, idleMembers []store.TeamMemberData) {
|
||||
ag, err := t.agents.GetByID(ctx, team.LeadAgentID)
|
||||
if err != nil {
|
||||
slog.Warn("task_ticker: resolve lead agent", "agent_id", team.LeadAgentID, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
var content strings.Builder
|
||||
content.WriteString(fmt.Sprintf("[System] %d unassigned task(s) need attention. %d agent(s) available:", len(tasks), len(idleMembers)))
|
||||
content.WriteString("\n\nAvailable agents:")
|
||||
for _, m := range idleMembers {
|
||||
name := m.DisplayName
|
||||
if name == "" {
|
||||
name = m.AgentKey
|
||||
}
|
||||
content.WriteString(fmt.Sprintf("\n- %s (%s)", name, m.AgentKey))
|
||||
}
|
||||
content.WriteString("\n\nUnassigned tasks:")
|
||||
for _, task := range tasks {
|
||||
content.WriteString(fmt.Sprintf("\n- #%d %s (created %s ago)", task.TaskNumber, task.Subject, time.Since(task.CreatedAt).Truncate(time.Minute)))
|
||||
}
|
||||
content.WriteString("\n\nPlease review and assign these tasks to the appropriate available agents using the team_tasks tool.")
|
||||
|
||||
if !t.msgBus.TryPublishInbound(bus.InboundMessage{
|
||||
Channel: "system",
|
||||
SenderID: "teammate:system",
|
||||
ChatID: team.ID.String(),
|
||||
Content: content.String(),
|
||||
UserID: team.CreatedBy,
|
||||
AgentID: ag.AgentKey,
|
||||
Metadata: map[string]string{
|
||||
"origin_channel": "system",
|
||||
"origin_peer_kind": "direct",
|
||||
"from_agent": "system",
|
||||
"to_agent": ag.AgentKey,
|
||||
"team_id": team.ID.String(),
|
||||
},
|
||||
}) {
|
||||
slog.Warn("task_ticker: inbound buffer full, skipping lead notify", "team_id", team.ID)
|
||||
return
|
||||
}
|
||||
slog.Info("task_ticker: notified lead about unassigned tasks",
|
||||
"team_id", team.ID,
|
||||
"lead_agent_key", ag.AgentKey,
|
||||
"count", len(tasks),
|
||||
)
|
||||
}
|
||||
|
||||
// followupInterval parses the team's followup_interval_minutes setting.
|
||||
func followupInterval(team store.TeamData) time.Duration {
|
||||
if team.Settings != nil {
|
||||
var settings map[string]any
|
||||
if json.Unmarshal(team.Settings, &settings) == nil {
|
||||
if v, ok := settings["followup_interval_minutes"].(float64); ok && v > 0 {
|
||||
return time.Duration(int(v)) * time.Minute
|
||||
}
|
||||
}
|
||||
}
|
||||
return defaultFollowupInterval
|
||||
}
|
||||
|
||||
func (t *TaskTicker) pruneCooldowns() {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
for id, ts := range t.lastDispatched {
|
||||
if now.Sub(ts) > 2*dispatchCooldown {
|
||||
delete(t.lastDispatched, id)
|
||||
}
|
||||
}
|
||||
for id, ts := range t.lastLeadNotified {
|
||||
if now.Sub(ts) > 2*leadNotifyCooldown {
|
||||
delete(t.lastLeadNotified, id)
|
||||
}
|
||||
}
|
||||
for id, ts := range t.lastFollowupSent {
|
||||
if now.Sub(ts) > 2*followupCooldown {
|
||||
delete(t.lastFollowupSent, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,13 @@ const (
|
||||
ctxSessionKey toolContextKey = "tool_session_key" // origin session key for announce routing
|
||||
)
|
||||
|
||||
// Well-known channel names used for routing and access control.
|
||||
const (
|
||||
ChannelSystem = "system"
|
||||
ChannelDashboard = "dashboard"
|
||||
ChannelDelegate = "delegate"
|
||||
)
|
||||
|
||||
func WithToolChannel(ctx context.Context, channel string) context.Context {
|
||||
return context.WithValue(ctx, ctxChannel, channel)
|
||||
}
|
||||
@@ -189,6 +196,31 @@ func MemoryConfigFromCtx(ctx context.Context) *config.MemoryConfig {
|
||||
return v
|
||||
}
|
||||
|
||||
// --- Workspace scope propagation (delegation origin) ---
|
||||
|
||||
const (
|
||||
ctxWsChannel toolContextKey = "tool_workspace_channel"
|
||||
ctxWsChatID toolContextKey = "tool_workspace_chat_id"
|
||||
)
|
||||
|
||||
func WithWorkspaceChannel(ctx context.Context, channel string) context.Context {
|
||||
return context.WithValue(ctx, ctxWsChannel, channel)
|
||||
}
|
||||
|
||||
func WorkspaceChannelFromCtx(ctx context.Context) string {
|
||||
v, _ := ctx.Value(ctxWsChannel).(string)
|
||||
return v
|
||||
}
|
||||
|
||||
func WithWorkspaceChatID(ctx context.Context, chatID string) context.Context {
|
||||
return context.WithValue(ctx, ctxWsChatID, chatID)
|
||||
}
|
||||
|
||||
func WorkspaceChatIDFromCtx(ctx context.Context) string {
|
||||
v, _ := ctx.Value(ctxWsChatID).(string)
|
||||
return v
|
||||
}
|
||||
|
||||
// --- Per-agent sandbox config override ---
|
||||
|
||||
const ctxSandboxCfg toolContextKey = "tool_sandbox_config"
|
||||
|
||||
@@ -113,6 +113,10 @@ type DelegateRunRequest struct {
|
||||
TeamID string
|
||||
TeamTaskID string
|
||||
ParentAgentID string
|
||||
|
||||
// Workspace scope propagation (set by delegation, read by workspace tools)
|
||||
WorkspaceChannel string
|
||||
WorkspaceChatID string
|
||||
}
|
||||
|
||||
// DelegateRunResult is the result from AgentRunFunc.
|
||||
|
||||
@@ -31,6 +31,7 @@ func (dm *DelegateManager) DelegateAsync(ctx context.Context, opts DelegateOpts)
|
||||
}
|
||||
|
||||
dm.injectDependencyResults(ctx, &opts)
|
||||
dm.injectWorkspaceContext(ctx, task, &opts)
|
||||
message := buildDelegateMessage(opts)
|
||||
dm.emitDelegationEvent(protocol.EventDelegationStarted, task)
|
||||
slog.Info("delegation started (async)", "id", task.ID, "target", opts.TargetAgentKey)
|
||||
|
||||
@@ -37,16 +37,21 @@ func checkUserPermission(settings json.RawMessage, userID string) error {
|
||||
// teamAccessSettings defines access control rules stored in agent_teams.settings JSONB.
|
||||
// Empty/nil lists mean "no restriction". Deny lists take precedence over allow lists.
|
||||
type teamAccessSettings struct {
|
||||
Version *int `json:"version,omitempty"`
|
||||
AllowUserIDs []string `json:"allow_user_ids"`
|
||||
DenyUserIDs []string `json:"deny_user_ids"`
|
||||
AllowChannels []string `json:"allow_channels"`
|
||||
DenyChannels []string `json:"deny_channels"`
|
||||
ProgressNotifications *bool `json:"progress_notifications,omitempty"`
|
||||
FollowupIntervalMins *int `json:"followup_interval_minutes,omitempty"`
|
||||
FollowupMaxReminders *int `json:"followup_max_reminders,omitempty"`
|
||||
EscalationMode string `json:"escalation_mode,omitempty"`
|
||||
EscalationActions []string `json:"escalation_actions,omitempty"`
|
||||
}
|
||||
|
||||
// checkTeamAccess validates whether a user/channel combination is authorized
|
||||
// for team operations. Returns nil if access is allowed.
|
||||
// System channels ("delegate", "system") always pass.
|
||||
// System channels (ChannelDelegate, ChannelSystem) always pass.
|
||||
// Empty settings = open access (no restrictions).
|
||||
func checkTeamAccess(settings json.RawMessage, userID, channel string) error {
|
||||
if len(settings) == 0 || string(settings) == "{}" {
|
||||
@@ -58,7 +63,7 @@ func checkTeamAccess(settings json.RawMessage, userID, channel string) error {
|
||||
}
|
||||
|
||||
// System/internal access always allowed
|
||||
if channel == "delegate" || channel == "system" {
|
||||
if channel == ChannelDelegate || channel == ChannelSystem {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -50,11 +50,11 @@ func (dm *DelegateManager) prepareDelegation(ctx context.Context, opts DelegateO
|
||||
team, _ = dm.teamStore.GetTeamForAgent(ctx, sourceAgentID)
|
||||
}
|
||||
|
||||
// Auto-create team task when team_task_id is omitted.
|
||||
// Auto-create team task when team_task_id is omitted (v2 teams only).
|
||||
// This eliminates the two-step create→spawn dance that caused LLM hallucination
|
||||
// (LLM would call create+spawn in parallel, hallucinating the task_id).
|
||||
// Only the team lead can create tasks — members must ask the lead.
|
||||
if team != nil && opts.TeamTaskID == uuid.Nil {
|
||||
if team != nil && opts.TeamTaskID == uuid.Nil && IsTeamV2(team) {
|
||||
if sourceAgentID != team.LeadAgentID {
|
||||
return nil, nil, fmt.Errorf("only the team lead can create team tasks — ask your lead to assign this task")
|
||||
}
|
||||
@@ -66,12 +66,15 @@ func (dm *DelegateManager) prepareDelegation(ctx context.Context, opts DelegateO
|
||||
}
|
||||
}
|
||||
taskData := &store.TeamTaskData{
|
||||
TeamID: team.ID,
|
||||
Subject: subject,
|
||||
Description: opts.Task,
|
||||
Status: store.TeamTaskStatusPending,
|
||||
UserID: store.UserIDFromContext(ctx),
|
||||
Channel: ToolChannelFromCtx(ctx),
|
||||
TeamID: team.ID,
|
||||
Subject: subject,
|
||||
Description: opts.Task,
|
||||
Status: store.TeamTaskStatusPending,
|
||||
UserID: store.UserIDFromContext(ctx),
|
||||
Channel: ToolChannelFromCtx(ctx),
|
||||
TaskType: "delegation",
|
||||
CreatedByAgentID: &sourceAgentID,
|
||||
ChatID: ToolChatIDFromCtx(ctx),
|
||||
}
|
||||
if err := dm.teamStore.CreateTask(ctx, taskData); err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to auto-create team task: %w", err)
|
||||
@@ -95,7 +98,7 @@ func (dm *DelegateManager) prepareDelegation(ctx context.Context, opts DelegateO
|
||||
// Delegate/system channels skip this check — they operate cross-context by design.
|
||||
currentUserID := store.UserIDFromContext(ctx)
|
||||
channel := ToolChannelFromCtx(ctx)
|
||||
if channel != "delegate" && channel != "system" &&
|
||||
if channel != ChannelDelegate && channel != ChannelSystem &&
|
||||
teamTask.UserID != "" && currentUserID != "" && teamTask.UserID != currentUserID {
|
||||
return nil, nil, fmt.Errorf(
|
||||
"team_task_id %s belongs to a different context. Omit team_task_id to auto-create a new task.",
|
||||
@@ -103,7 +106,7 @@ func (dm *DelegateManager) prepareDelegation(ctx context.Context, opts DelegateO
|
||||
}
|
||||
|
||||
// Guard: reject completed/cancelled tasks — enforce "one task per delegation".
|
||||
if teamTask.Status == store.TeamTaskStatusCompleted || teamTask.Status == "cancelled" {
|
||||
if teamTask.Status == store.TeamTaskStatusCompleted || teamTask.Status == store.TeamTaskStatusCancelled {
|
||||
ownerLabel := "another agent"
|
||||
if teamTask.OwnerAgentKey != "" {
|
||||
ownerLabel = teamTask.OwnerAgentKey
|
||||
@@ -133,11 +136,13 @@ func (dm *DelegateManager) prepareDelegation(ctx context.Context, opts DelegateO
|
||||
})
|
||||
}
|
||||
|
||||
// Claim task early so status moves to in_progress immediately.
|
||||
// Claim task early so status moves to in_progress immediately (v2 only).
|
||||
// This prevents the pending reminder from re-triggering spawns for
|
||||
// tasks that are already running. The ClaimTask in autoCompleteTeamTask()
|
||||
// will harmlessly fail (WHERE status='pending' won't match).
|
||||
_ = dm.teamStore.ClaimTask(ctx, opts.TeamTaskID, targetAgent.ID, teamTask.TeamID)
|
||||
if team != nil && IsTeamV2(team) {
|
||||
_ = dm.teamStore.ClaimTask(ctx, opts.TeamTaskID, targetAgent.ID, teamTask.TeamID)
|
||||
}
|
||||
}
|
||||
|
||||
linkCount := dm.ActiveCountForLink(sourceAgentID, targetAgent.ID)
|
||||
@@ -239,6 +244,45 @@ func (dm *DelegateManager) injectDependencyResults(ctx context.Context, opts *De
|
||||
}
|
||||
}
|
||||
|
||||
// injectWorkspaceContext lists workspace files and prepends metadata to opts.Context.
|
||||
// Uses task.UserID as workspace scope (stable across WS reconnects).
|
||||
func (dm *DelegateManager) injectWorkspaceContext(ctx context.Context, task *DelegationTask, opts *DelegateOpts) {
|
||||
if dm.teamStore == nil || task.TeamID == uuid.Nil {
|
||||
return
|
||||
}
|
||||
channel := ""
|
||||
chatID := task.UserID
|
||||
if chatID == "" {
|
||||
chatID = store.UserIDFromContext(ctx)
|
||||
}
|
||||
|
||||
files, err := dm.teamStore.ListWorkspaceFiles(ctx, task.TeamID, channel, chatID)
|
||||
if err != nil || len(files) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var lines []string
|
||||
for _, f := range files {
|
||||
tag := ""
|
||||
if f.Pinned {
|
||||
tag = " [pinned]"
|
||||
}
|
||||
for _, t := range f.Tags {
|
||||
tag += " [" + t + "]"
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("- %s (%s, %d bytes, by %s)%s",
|
||||
f.FileName, f.MimeType, f.SizeBytes, f.UploadedByKey, tag))
|
||||
}
|
||||
wsCtx := "--- Team workspace files (use workspace_read to access) ---\n" +
|
||||
strings.Join(lines, "\n")
|
||||
|
||||
if opts.Context != "" {
|
||||
opts.Context = wsCtx + "\n\n" + opts.Context
|
||||
} else {
|
||||
opts.Context = wsCtx
|
||||
}
|
||||
}
|
||||
|
||||
// sendProgressNotification sends a grouped "still working" message listing all
|
||||
// active delegations from the same source agent. Uses progressSent to dedup —
|
||||
// concurrent tickers only send one notification per cycle, then release for next tick.
|
||||
@@ -248,7 +292,7 @@ func (dm *DelegateManager) sendProgressNotification(task *DelegationTask) {
|
||||
}
|
||||
// Skip internal/delegate channels — only notify on real user-facing channels.
|
||||
if dm.msgBus == nil || task.OriginChannel == "" || task.OriginChatID == "" ||
|
||||
task.OriginChannel == "delegate" || task.OriginChannel == "system" {
|
||||
task.OriginChannel == ChannelDelegate || task.OriginChannel == ChannelSystem {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -337,7 +381,7 @@ func (dm *DelegateManager) buildRunRequest(task *DelegationTask, message string)
|
||||
SessionKey: task.SessionKey,
|
||||
Message: message,
|
||||
UserID: task.UserID,
|
||||
Channel: "delegate",
|
||||
Channel: ChannelDelegate,
|
||||
ChatID: task.OriginChatID,
|
||||
PeerKind: task.OriginPeerKind,
|
||||
RunID: fmt.Sprintf("delegate-%s", task.ID),
|
||||
@@ -355,6 +399,11 @@ func (dm *DelegateManager) buildRunRequest(task *DelegationTask, message string)
|
||||
ParentAgentID: task.SourceAgentKey,
|
||||
}
|
||||
|
||||
// Propagate workspace scope to delegate so workspace tools write to the
|
||||
// origin user's workspace, not the "delegate" channel. Scope = userID.
|
||||
req.WorkspaceChannel = ""
|
||||
req.WorkspaceChatID = task.UserID
|
||||
|
||||
// Propagate parent's recent image media to delegate for vision context.
|
||||
if dm.mediaLoader != nil && dm.sessionStore != nil && task.OriginSessionKey != "" {
|
||||
req.Media = dm.resolveParentMedia(task.OriginSessionKey)
|
||||
|
||||
@@ -188,7 +188,7 @@ func (dm *DelegateManager) flushCompletedSessions() {
|
||||
}
|
||||
}
|
||||
|
||||
// autoCompleteTeamTask attempts to claim+complete the associated team task.
|
||||
// autoCompleteTeamTask attempts to claim+complete the associated team task (v2 only).
|
||||
// Called after a delegation finishes successfully. Errors are logged but not fatal.
|
||||
// On success, flushes all tracked delegate sessions (task done = context no longer needed).
|
||||
// Also persists a team message record for audit trail / visualization.
|
||||
@@ -196,6 +196,14 @@ func (dm *DelegateManager) autoCompleteTeamTask(task *DelegationTask, resultCont
|
||||
if dm.teamStore == nil || task.TeamTaskID == uuid.Nil {
|
||||
return
|
||||
}
|
||||
// Use a bounded context so this work doesn't run indefinitely on shutdown.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Only auto-complete for v2 teams.
|
||||
if team, _ := dm.teamStore.GetTeam(ctx, task.TeamID); team == nil || !IsTeamV2(team) {
|
||||
return
|
||||
}
|
||||
|
||||
// Use actual deliverables (tool outputs) as task result when available,
|
||||
// falling back to the LLM's summary response.
|
||||
@@ -207,13 +215,22 @@ func (dm *DelegateManager) autoCompleteTeamTask(task *DelegationTask, resultCont
|
||||
}
|
||||
}
|
||||
|
||||
_ = dm.teamStore.ClaimTask(context.Background(), task.TeamTaskID, task.TargetAgentID, task.TeamID)
|
||||
if err := dm.teamStore.CompleteTask(context.Background(), task.TeamTaskID, task.TeamID, taskResult); err != nil {
|
||||
_ = dm.teamStore.ClaimTask(ctx, task.TeamTaskID, task.TargetAgentID, task.TeamID)
|
||||
if err := dm.teamStore.CompleteTask(ctx, task.TeamTaskID, task.TeamID, taskResult); err != nil {
|
||||
slog.Warn("delegate: failed to auto-complete team task",
|
||||
"task_id", task.TeamTaskID, "delegation_id", task.ID, "error", err)
|
||||
} else {
|
||||
slog.Info("delegate: auto-completed team task",
|
||||
"task_id", task.TeamTaskID, "delegation_id", task.ID)
|
||||
// Record audit event.
|
||||
_ = dm.teamStore.RecordTaskEvent(ctx, &store.TeamTaskEventData{
|
||||
TaskID: task.TeamTaskID,
|
||||
EventType: "completed",
|
||||
ActorType: "agent",
|
||||
ActorID: task.TargetAgentID.String(),
|
||||
})
|
||||
// Archive workspace files linked to this completed task (pinned files are immune).
|
||||
_ = dm.teamStore.ArchiveWorkspaceFilesByTask(ctx, task.TeamTaskID)
|
||||
// Task done — flush delegate sessions
|
||||
dm.flushCompletedSessions()
|
||||
|
||||
@@ -224,7 +241,7 @@ func (dm *DelegateManager) autoCompleteTeamTask(task *DelegationTask, resultCont
|
||||
summary = summary[:500] + "..."
|
||||
}
|
||||
taskID := task.TeamTaskID
|
||||
_ = dm.teamStore.SendMessage(context.Background(), &store.TeamMessageData{
|
||||
_ = dm.teamStore.SendMessage(ctx, &store.TeamMessageData{
|
||||
TeamID: task.TeamID,
|
||||
FromAgentID: task.TargetAgentID,
|
||||
ToAgentID: &task.SourceAgentID,
|
||||
@@ -236,25 +253,40 @@ func (dm *DelegateManager) autoCompleteTeamTask(task *DelegationTask, resultCont
|
||||
}
|
||||
}
|
||||
|
||||
// autoFailTeamTask marks the associated team task as failed.
|
||||
// autoFailTeamTask marks the associated team task as failed (v2 only).
|
||||
// Called after a delegation fails. Errors are logged but not fatal.
|
||||
func (dm *DelegateManager) autoFailTeamTask(task *DelegationTask, errMsg string) {
|
||||
if dm.teamStore == nil || task.TeamTaskID == uuid.Nil {
|
||||
return
|
||||
}
|
||||
// Use a bounded context so this work doesn't run indefinitely on shutdown.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Only auto-fail for v2 teams.
|
||||
if team, _ := dm.teamStore.GetTeam(ctx, task.TeamID); team == nil || !IsTeamV2(team) {
|
||||
return
|
||||
}
|
||||
|
||||
// Truncate error message for storage
|
||||
if len(errMsg) > 2000 {
|
||||
errMsg = errMsg[:2000] + "..."
|
||||
}
|
||||
|
||||
_ = dm.teamStore.ClaimTask(context.Background(), task.TeamTaskID, task.TargetAgentID, task.TeamID)
|
||||
if err := dm.teamStore.FailTask(context.Background(), task.TeamTaskID, task.TeamID, errMsg); err != nil {
|
||||
_ = dm.teamStore.ClaimTask(ctx, task.TeamTaskID, task.TargetAgentID, task.TeamID)
|
||||
if err := dm.teamStore.FailTask(ctx, task.TeamTaskID, task.TeamID, errMsg); err != nil {
|
||||
slog.Warn("delegate: failed to auto-fail team task",
|
||||
"task_id", task.TeamTaskID, "delegation_id", task.ID, "error", err)
|
||||
} else {
|
||||
slog.Info("delegate: auto-failed team task",
|
||||
"task_id", task.TeamTaskID, "delegation_id", task.ID)
|
||||
// Record audit event.
|
||||
_ = dm.teamStore.RecordTaskEvent(ctx, &store.TeamTaskEventData{
|
||||
TaskID: task.TeamTaskID,
|
||||
EventType: "failed",
|
||||
ActorType: "agent",
|
||||
ActorID: task.TargetAgentID.String(),
|
||||
})
|
||||
|
||||
// Persist delegation failure as team message for audit trail
|
||||
if task.TeamID != uuid.Nil {
|
||||
@@ -263,7 +295,7 @@ func (dm *DelegateManager) autoFailTeamTask(task *DelegationTask, errMsg string)
|
||||
summary = summary[:500] + "..."
|
||||
}
|
||||
taskID := task.TeamTaskID
|
||||
_ = dm.teamStore.SendMessage(context.Background(), &store.TeamMessageData{
|
||||
_ = dm.teamStore.SendMessage(ctx, &store.TeamMessageData{
|
||||
TeamID: task.TeamID,
|
||||
FromAgentID: task.TargetAgentID,
|
||||
ToAgentID: &task.SourceAgentID,
|
||||
|
||||
@@ -28,6 +28,7 @@ func (dm *DelegateManager) Delegate(ctx context.Context, opts DelegateOpts) (*De
|
||||
}()
|
||||
|
||||
dm.injectDependencyResults(ctx, &opts)
|
||||
dm.injectWorkspaceContext(ctx, task, &opts)
|
||||
message := buildDelegateMessage(opts)
|
||||
dm.emitDelegationEvent(protocol.EventDelegationStarted, task)
|
||||
slog.Info("delegation started", "id", task.ID, "target", opts.TargetAgentKey, "mode", "sync")
|
||||
|
||||
@@ -20,6 +20,7 @@ type HandoffTool struct {
|
||||
teamStore store.TeamStore
|
||||
sessionStore store.SessionStore
|
||||
msgBus *bus.MessageBus
|
||||
dataDir string
|
||||
}
|
||||
|
||||
func NewHandoffTool(
|
||||
@@ -27,12 +28,14 @@ func NewHandoffTool(
|
||||
teamStore store.TeamStore,
|
||||
sessionStore store.SessionStore,
|
||||
msgBus *bus.MessageBus,
|
||||
dataDir string,
|
||||
) *HandoffTool {
|
||||
return &HandoffTool{
|
||||
delegateMgr: delegateMgr,
|
||||
teamStore: teamStore,
|
||||
sessionStore: sessionStore,
|
||||
msgBus: msgBus,
|
||||
dataDir: dataDir,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,6 +155,9 @@ func (t *HandoffTool) executeTransfer(ctx context.Context, args map[string]any)
|
||||
Reason: reason,
|
||||
CreatedBy: userID,
|
||||
}
|
||||
if sourceTeam, _ := t.teamStore.GetTeamForAgent(ctx, sourceAgentID); sourceTeam != nil {
|
||||
route.TeamID = sourceTeam.ID
|
||||
}
|
||||
if err := t.teamStore.SetHandoffRoute(ctx, route); err != nil {
|
||||
slog.Warn("handoff: failed to set route", "error", err)
|
||||
return ErrorResult("failed to set handoff route: " + err.Error())
|
||||
@@ -203,6 +209,25 @@ func (t *HandoffTool) executeTransfer(ctx context.Context, args map[string]any)
|
||||
})
|
||||
}
|
||||
|
||||
// Copy deliverable workspace files to target agent's team workspace.
|
||||
// Workspace scope uses userID (not raw chatID) for stable cross-session access.
|
||||
if t.teamStore != nil && t.dataDir != "" {
|
||||
sourceTeam, _ := t.teamStore.GetTeamForAgent(ctx, sourceAgentID)
|
||||
targetTeam, _ := t.teamStore.GetTeamForAgent(ctx, targetAgent.ID)
|
||||
if sourceTeam != nil && targetTeam != nil && sourceTeam.ID != targetTeam.ID {
|
||||
deliverables, _ := t.teamStore.ListDeliverableFiles(ctx, sourceTeam.ID, "", userID)
|
||||
if len(deliverables) > 0 {
|
||||
fileIDs := make([]uuid.UUID, len(deliverables))
|
||||
for i, f := range deliverables {
|
||||
fileIDs[i] = f.ID
|
||||
}
|
||||
_ = t.teamStore.CopyFilesToTeam(ctx, fileIDs, targetTeam.ID, "", userID, t.dataDir)
|
||||
slog.Info("handoff: copied deliverable files",
|
||||
"count", len(deliverables), "from_team", sourceTeam.ID, "to_team", targetTeam.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("handoff: conversation transferred",
|
||||
"from", sourceAgent.AgentKey, "to", targetAgentKey,
|
||||
"channel", channel, "chat_id", chatID, "reason", reason)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -19,9 +20,9 @@ type MediaProviderEntry struct {
|
||||
Provider string `json:"provider"` // name for registry.Get()
|
||||
Model string `json:"model"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Timeout int `json:"timeout"` // seconds, default 120
|
||||
MaxRetries int `json:"max_retries"` // default 2
|
||||
Params map[string]any `json:"params,omitempty"` // provider-specific config
|
||||
Timeout int `json:"timeout"` // seconds, default 120
|
||||
MaxRetries int `json:"max_retries"` // default 2
|
||||
Params map[string]any `json:"params,omitempty"` // provider-specific config
|
||||
}
|
||||
|
||||
// mediaProviderChain is the settings JSON structure for media tools.
|
||||
@@ -178,9 +179,7 @@ func ExecuteWithChain(
|
||||
// Clone params to avoid mutating the original entry config.
|
||||
resolvedType := ResolveProviderType(p)
|
||||
callParams := make(map[string]any, len(entry.Params)+1)
|
||||
for k, v := range entry.Params {
|
||||
callParams[k] = v
|
||||
}
|
||||
maps.Copy(callParams, entry.Params)
|
||||
callParams["_provider_type"] = resolvedType
|
||||
|
||||
// Retry loop for this provider
|
||||
|
||||
@@ -19,7 +19,7 @@ var toolGroups = map[string][]string{
|
||||
"automation": {"cron"},
|
||||
"messaging": {"message", "create_forum_topic"},
|
||||
"delegation": {"handoff", "delegate_search", "evaluate_loop"},
|
||||
"team": {"team_tasks", "team_message"},
|
||||
"team": {"team_tasks", "team_message", "workspace_write", "workspace_read"},
|
||||
// Composite group: all goclaw native tools (excludes MCP/custom plugins).
|
||||
"goclaw": {
|
||||
"read_file", "write_file", "list_files", "edit", "exec",
|
||||
@@ -31,7 +31,7 @@ var toolGroups = map[string][]string{
|
||||
"create_image", "create_video",
|
||||
"skill_search", "mcp_tool_search", "tts",
|
||||
"handoff", "delegate_search", "evaluate_loop",
|
||||
"team_tasks", "team_message",
|
||||
"team_tasks", "team_message", "workspace_write", "workspace_read",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -153,6 +153,8 @@ var SubagentDenyAlways = []string{
|
||||
"memory_search",
|
||||
"memory_get",
|
||||
"sessions_send",
|
||||
"workspace_write",
|
||||
"workspace_read",
|
||||
}
|
||||
|
||||
// SubagentDenyLeaf is the additional deny list for subagents at max depth.
|
||||
|
||||
@@ -4,6 +4,10 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
@@ -23,7 +27,7 @@ func NewTeamMessageTool(manager *TeamToolManager) *TeamMessageTool {
|
||||
func (t *TeamMessageTool) Name() string { return "team_message" }
|
||||
|
||||
func (t *TeamMessageTool) Description() string {
|
||||
return "Send and receive messages within your team. Actions: send (direct message to a teammate), broadcast (message all teammates), read (check unread messages). See TEAM.md for your teammates."
|
||||
return "Send and receive messages within your team. Actions: send, broadcast, read. See TEAM.md for your teammates."
|
||||
}
|
||||
|
||||
func (t *TeamMessageTool) Parameters() map[string]any {
|
||||
@@ -42,6 +46,13 @@ func (t *TeamMessageTool) Parameters() map[string]any {
|
||||
"type": "string",
|
||||
"description": "Message content (required for action=send and action=broadcast)",
|
||||
},
|
||||
"media": map[string]any{
|
||||
"type": "array",
|
||||
"description": "Optional file paths to attach as media (for action=send)",
|
||||
"items": map[string]any{
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": []string{"action"},
|
||||
}
|
||||
@@ -98,6 +109,18 @@ func (t *TeamMessageTool) executeSend(ctx context.Context, args map[string]any)
|
||||
return ErrorResult(fmt.Sprintf("agent %q is not a member of your team", toKey))
|
||||
}
|
||||
|
||||
// Parse optional media paths
|
||||
var mediaFiles []bus.MediaFile
|
||||
if rawMedia, ok := args["media"]; ok {
|
||||
if mediaArr, ok := rawMedia.([]any); ok {
|
||||
for _, item := range mediaArr {
|
||||
if path, ok := item.(string); ok && path != "" {
|
||||
mediaFiles = append(mediaFiles, bus.MediaFile{Path: path})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Persist to DB
|
||||
msg := &store.TeamMessageData{
|
||||
TeamID: team.ID,
|
||||
@@ -110,9 +133,70 @@ func (t *TeamMessageTool) executeSend(ctx context.Context, args map[string]any)
|
||||
return ErrorResult("failed to send message: " + err.Error())
|
||||
}
|
||||
|
||||
// Auto-create a "message" task so team messages appear in the Tasks tab.
|
||||
subject := text
|
||||
if len(subject) > 100 {
|
||||
subject = subject[:100] + "..."
|
||||
}
|
||||
now := time.Now()
|
||||
lockExpires := now.Add(30 * time.Minute)
|
||||
taskData := &store.TeamTaskData{
|
||||
TeamID: team.ID,
|
||||
Subject: subject,
|
||||
Description: text,
|
||||
Status: store.TeamTaskStatusInProgress,
|
||||
TaskType: "message",
|
||||
CreatedByAgentID: &agentID,
|
||||
OwnerAgentID: &toAgentID,
|
||||
UserID: store.UserIDFromContext(ctx),
|
||||
Channel: ToolChannelFromCtx(ctx),
|
||||
ChatID: ToolChatIDFromCtx(ctx),
|
||||
LockedAt: &now,
|
||||
LockExpiresAt: &lockExpires,
|
||||
}
|
||||
var teamTaskID uuid.UUID
|
||||
slog.Info("team_message: creating auto-task",
|
||||
"team_id", team.ID,
|
||||
"from_agent", agentID,
|
||||
"to_agent", toAgentID,
|
||||
"status", taskData.Status,
|
||||
"task_type", taskData.TaskType,
|
||||
"user_id", taskData.UserID,
|
||||
"channel", taskData.Channel,
|
||||
"chat_id", taskData.ChatID,
|
||||
)
|
||||
if err := t.manager.teamStore.CreateTask(ctx, taskData); err != nil {
|
||||
slog.Warn("team_message: failed to auto-create task",
|
||||
"error", err,
|
||||
"team_id", team.ID,
|
||||
"from_agent", agentID,
|
||||
"to_agent", toAgentID,
|
||||
)
|
||||
} else {
|
||||
teamTaskID = taskData.ID
|
||||
_ = t.manager.teamStore.RecordTaskEvent(ctx, &store.TeamTaskEventData{
|
||||
TaskID: teamTaskID,
|
||||
EventType: "created",
|
||||
ActorType: "agent",
|
||||
ActorID: t.manager.agentKeyFromID(ctx, agentID),
|
||||
})
|
||||
t.manager.broadcastTeamEvent(protocol.EventTeamTaskCreated, protocol.TeamTaskEventPayload{
|
||||
TeamID: team.ID.String(),
|
||||
TaskID: teamTaskID.String(),
|
||||
Subject: subject,
|
||||
Status: store.TeamTaskStatusInProgress,
|
||||
OwnerAgentKey: toKey,
|
||||
OwnerDisplayName: t.manager.agentDisplayName(ctx, toKey),
|
||||
UserID: store.UserIDFromContext(ctx),
|
||||
Channel: ToolChannelFromCtx(ctx),
|
||||
ChatID: ToolChatIDFromCtx(ctx),
|
||||
Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
|
||||
})
|
||||
}
|
||||
|
||||
// Real-time delivery via message bus
|
||||
fromKey := t.manager.agentKeyFromID(ctx, agentID)
|
||||
t.publishTeammateMessage(fromKey, toKey, text, ctx)
|
||||
t.publishTeammateMessage(fromKey, toKey, text, mediaFiles, teamTaskID, team.ID, ctx)
|
||||
|
||||
preview := text
|
||||
if len(preview) > 100 {
|
||||
@@ -168,7 +252,7 @@ func (t *TeamMessageTool) executeBroadcast(ctx context.Context, args map[string]
|
||||
if m.AgentID == agentID {
|
||||
continue // don't send to self
|
||||
}
|
||||
t.publishTeammateMessage(fromKey, m.AgentKey, text, ctx)
|
||||
t.publishTeammateMessage(fromKey, m.AgentKey, text, nil, uuid.Nil, team.ID, ctx)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,7 +300,7 @@ func (t *TeamMessageTool) executeRead(ctx context.Context) *Result {
|
||||
|
||||
// publishTeammateMessage sends a real-time notification via the message bus.
|
||||
// Uses "teammate:{fromKey}" sender prefix so the consumer can route it.
|
||||
func (t *TeamMessageTool) publishTeammateMessage(fromKey, toKey, text string, ctx context.Context) {
|
||||
func (t *TeamMessageTool) publishTeammateMessage(fromKey, toKey, text string, media []bus.MediaFile, teamTaskID uuid.UUID, teamID uuid.UUID, ctx context.Context) {
|
||||
if t.manager.msgBus == nil {
|
||||
return
|
||||
}
|
||||
@@ -226,6 +310,15 @@ func (t *TeamMessageTool) publishTeammateMessage(fromKey, toKey, text string, ct
|
||||
originChannel := ToolChannelFromCtx(ctx)
|
||||
originPeerKind := ToolPeerKindFromCtx(ctx)
|
||||
|
||||
slog.Info("team_message: publishTeammateMessage",
|
||||
"from", fromKey, "to", toKey,
|
||||
"origin_channel", originChannel,
|
||||
"chat_id", chatID,
|
||||
"origin_peer_kind", originPeerKind,
|
||||
"user_id", userID,
|
||||
"team_task_id", teamTaskID,
|
||||
)
|
||||
|
||||
teamMeta := map[string]string{
|
||||
"origin_channel": originChannel,
|
||||
"origin_peer_kind": originPeerKind,
|
||||
@@ -235,11 +328,16 @@ func (t *TeamMessageTool) publishTeammateMessage(fromKey, toKey, text string, ct
|
||||
if localKey := ToolLocalKeyFromCtx(ctx); localKey != "" {
|
||||
teamMeta["origin_local_key"] = localKey
|
||||
}
|
||||
if teamTaskID != uuid.Nil {
|
||||
teamMeta["team_task_id"] = teamTaskID.String()
|
||||
teamMeta["team_id"] = teamID.String()
|
||||
}
|
||||
t.manager.msgBus.PublishInbound(bus.InboundMessage{
|
||||
Channel: "system",
|
||||
SenderID: fmt.Sprintf("teammate:%s", fromKey),
|
||||
ChatID: chatID,
|
||||
Content: fmt.Sprintf("[Team message from %s]: %s", fromKey, text),
|
||||
Media: media,
|
||||
UserID: userID,
|
||||
AgentID: toKey,
|
||||
Metadata: teamMeta,
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
)
|
||||
|
||||
// TeamTasksTool exposes the shared team task list to agents.
|
||||
// Actions: list, get, create, claim, complete, search.
|
||||
// Actions: list, get, create, claim, complete, cancel, search, review, comment, progress, attach, update.
|
||||
type TeamTasksTool struct {
|
||||
manager *TeamToolManager
|
||||
}
|
||||
@@ -25,7 +25,7 @@ func NewTeamTasksTool(manager *TeamToolManager) *TeamTasksTool {
|
||||
func (t *TeamTasksTool) Name() string { return "team_tasks" }
|
||||
|
||||
func (t *TeamTasksTool) Description() string {
|
||||
return "Manage the shared team task list. Actions: list (active tasks overview), get (full task detail with result), create, claim, complete, cancel, approve, reject, search. See TEAM.md for your team context."
|
||||
return "Manage the shared team task list (create, claim, complete, track progress). See TEAM.md for available actions and team context."
|
||||
}
|
||||
|
||||
func (t *TeamTasksTool) Parameters() map[string]any {
|
||||
@@ -34,57 +34,83 @@ func (t *TeamTasksTool) Parameters() map[string]any {
|
||||
"properties": map[string]any{
|
||||
"action": map[string]any{
|
||||
"type": "string",
|
||||
"description": "'list', 'get', 'create', 'claim', 'complete', 'cancel', 'approve', 'reject', or 'search'",
|
||||
"description": "'list', 'get', 'create', 'claim', 'complete', 'cancel', 'approve', 'reject', 'search', 'review', 'comment', 'progress', 'attach', 'update', 'await_reply', or 'clear_followup'",
|
||||
},
|
||||
"status": map[string]any{
|
||||
"task_id": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Filter for action=list: '' (active only, default), 'completed', 'all'",
|
||||
},
|
||||
"query": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Search query for action=search (searches subject and description)",
|
||||
"description": "Task ID (required for most actions except list, create, search)",
|
||||
},
|
||||
"subject": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Task subject (required for action=create)",
|
||||
"description": "Task subject (required for create, optional for update)",
|
||||
},
|
||||
"description": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Task description (optional, for action=create)",
|
||||
"description": "Task description (for create or update)",
|
||||
},
|
||||
"result": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Result summary (required for complete)",
|
||||
},
|
||||
"text": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Text content: comment text, cancel/reject reason, progress step, or followup reminder message",
|
||||
},
|
||||
"status": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Filter for list: '' (active, default), 'completed', 'all'",
|
||||
},
|
||||
"query": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Search query for action=search",
|
||||
},
|
||||
"priority": map[string]any{
|
||||
"type": "number",
|
||||
"description": "Task priority, higher = more important (optional, for action=create, default 0)",
|
||||
"description": "Priority, higher = more important (for create, default 0)",
|
||||
},
|
||||
"blocked_by": map[string]any{
|
||||
"type": "array",
|
||||
"items": map[string]any{"type": "string"},
|
||||
"description": "Task IDs that must complete before this task can be claimed (optional, for action=create)",
|
||||
},
|
||||
"task_id": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Task ID (required for action=get, claim, complete, cancel)",
|
||||
},
|
||||
"result": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Task result summary (required for action=complete)",
|
||||
},
|
||||
"reason": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Cancellation or rejection reason (optional for action=cancel or reject)",
|
||||
"description": "Task IDs that must complete first (for create)",
|
||||
},
|
||||
"require_approval": map[string]any{
|
||||
"type": "boolean",
|
||||
"description": "If true, task requires user approval before agents can claim it (optional for action=create, default false)",
|
||||
"description": "Require user approval before claim (for create, default false)",
|
||||
},
|
||||
"percent": map[string]any{
|
||||
"type": "number",
|
||||
"description": "Progress 0-100 (for progress)",
|
||||
},
|
||||
"file_id": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Workspace file ID (for attach)",
|
||||
},
|
||||
},
|
||||
"required": []string{"action"},
|
||||
}
|
||||
}
|
||||
|
||||
// v2Actions lists team_tasks actions that require team version >= 2.
|
||||
var v2Actions = map[string]bool{
|
||||
"approve": true, "reject": true, "review": true, "comment": true,
|
||||
"progress": true, "attach": true, "update": true,
|
||||
"await_reply": true, "clear_followup": true,
|
||||
}
|
||||
|
||||
func (t *TeamTasksTool) Execute(ctx context.Context, args map[string]any) *Result {
|
||||
action, _ := args["action"].(string)
|
||||
|
||||
// Gate v2-only actions: resolve team once and check version.
|
||||
if v2Actions[action] {
|
||||
team, _, err := t.manager.resolveTeam(ctx)
|
||||
if err != nil {
|
||||
return ErrorResult(err.Error())
|
||||
}
|
||||
if !IsTeamV2(team) {
|
||||
return ErrorResult(fmt.Sprintf("action '%s' requires team version 2 — upgrade in team settings", action))
|
||||
}
|
||||
}
|
||||
|
||||
switch action {
|
||||
case "list":
|
||||
return t.executeList(ctx, args)
|
||||
@@ -104,8 +130,22 @@ func (t *TeamTasksTool) Execute(ctx context.Context, args map[string]any) *Resul
|
||||
return t.executeReject(ctx, args)
|
||||
case "search":
|
||||
return t.executeSearch(ctx, args)
|
||||
case "review":
|
||||
return t.executeReview(ctx, args)
|
||||
case "comment":
|
||||
return t.executeComment(ctx, args)
|
||||
case "progress":
|
||||
return t.executeProgress(ctx, args)
|
||||
case "attach":
|
||||
return t.executeAttach(ctx, args)
|
||||
case "update":
|
||||
return t.executeUpdate(ctx, args)
|
||||
case "await_reply":
|
||||
return t.executeAwaitReply(ctx, args)
|
||||
case "clear_followup":
|
||||
return t.executeClearFollowup(ctx, args)
|
||||
default:
|
||||
return ErrorResult(fmt.Sprintf("unknown action: %s (use list, get, create, claim, complete, cancel, approve, reject, or search)", action))
|
||||
return ErrorResult(fmt.Sprintf("unknown action: %s (use list, get, create, claim, complete, cancel, search, review, comment, progress, attach, update, await_reply, or clear_followup)", action))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,11 +162,11 @@ func (t *TeamTasksTool) executeList(ctx context.Context, args map[string]any) *R
|
||||
// Delegate/system channels see all tasks; end users only see their own.
|
||||
filterUserID := ""
|
||||
channel := ToolChannelFromCtx(ctx)
|
||||
if channel != "delegate" && channel != "system" {
|
||||
if channel != ChannelDelegate && channel != ChannelSystem {
|
||||
filterUserID = store.UserIDFromContext(ctx)
|
||||
}
|
||||
|
||||
tasks, err := t.manager.teamStore.ListTasks(ctx, team.ID, "priority", statusFilter, filterUserID)
|
||||
tasks, err := t.manager.teamStore.ListTasks(ctx, team.ID, "priority", statusFilter, filterUserID, "", "")
|
||||
if err != nil {
|
||||
return ErrorResult("failed to list tasks: " + err.Error())
|
||||
}
|
||||
@@ -187,7 +227,25 @@ func (t *TeamTasksTool) executeGet(ctx context.Context, args map[string]any) *Re
|
||||
}
|
||||
}
|
||||
|
||||
out, _ := json.Marshal(task)
|
||||
// Load comments, events, and attachments for full detail view.
|
||||
comments, _ := t.manager.teamStore.ListTaskComments(ctx, taskID)
|
||||
events, _ := t.manager.teamStore.ListTaskEvents(ctx, taskID)
|
||||
attachments, _ := t.manager.teamStore.ListTaskAttachments(ctx, taskID)
|
||||
|
||||
resp := map[string]any{
|
||||
"task": task,
|
||||
}
|
||||
if len(comments) > 0 {
|
||||
resp["comments"] = comments
|
||||
}
|
||||
if len(events) > 0 {
|
||||
resp["events"] = events
|
||||
}
|
||||
if len(attachments) > 0 {
|
||||
resp["attachments"] = attachments
|
||||
}
|
||||
|
||||
out, _ := json.Marshal(resp)
|
||||
return SilentResult(string(out))
|
||||
}
|
||||
|
||||
@@ -205,7 +263,7 @@ func (t *TeamTasksTool) executeSearch(ctx context.Context, args map[string]any)
|
||||
// Delegate/system channels see all tasks; end users only see their own.
|
||||
filterUserID := ""
|
||||
channel := ToolChannelFromCtx(ctx)
|
||||
if channel != "delegate" && channel != "system" {
|
||||
if channel != ChannelDelegate && channel != ChannelSystem {
|
||||
filterUserID = store.UserIDFromContext(ctx)
|
||||
}
|
||||
|
||||
@@ -264,23 +322,39 @@ func (t *TeamTasksTool) executeCreate(ctx context.Context, args map[string]any)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate that all blocked_by tasks belong to the same team.
|
||||
for _, depID := range blockedBy {
|
||||
depTask, err := t.manager.teamStore.GetTask(ctx, depID)
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("blocked_by task %s not found: %v", depID, err))
|
||||
}
|
||||
if depTask.TeamID != team.ID {
|
||||
return ErrorResult(fmt.Sprintf("blocked_by task %s belongs to a different team", depID))
|
||||
}
|
||||
}
|
||||
|
||||
requireApproval, _ := args["require_approval"].(bool)
|
||||
status := store.TeamTaskStatusPending
|
||||
if requireApproval {
|
||||
status = store.TeamTaskStatusPendingApproval
|
||||
status = store.TeamTaskStatusInReview
|
||||
} else if len(blockedBy) > 0 {
|
||||
status = store.TeamTaskStatusBlocked
|
||||
}
|
||||
|
||||
chatID := ToolChatIDFromCtx(ctx)
|
||||
|
||||
task := &store.TeamTaskData{
|
||||
TeamID: team.ID,
|
||||
Subject: subject,
|
||||
Description: description,
|
||||
Status: status,
|
||||
BlockedBy: blockedBy,
|
||||
Priority: priority,
|
||||
UserID: store.UserIDFromContext(ctx),
|
||||
Channel: ToolChannelFromCtx(ctx),
|
||||
TeamID: team.ID,
|
||||
Subject: subject,
|
||||
Description: description,
|
||||
Status: status,
|
||||
BlockedBy: blockedBy,
|
||||
Priority: priority,
|
||||
UserID: store.UserIDFromContext(ctx),
|
||||
Channel: ToolChannelFromCtx(ctx),
|
||||
TaskType: "general",
|
||||
CreatedByAgentID: &agentID,
|
||||
ChatID: chatID,
|
||||
}
|
||||
|
||||
if err := t.manager.teamStore.CreateTask(ctx, task); err != nil {
|
||||
@@ -294,11 +368,11 @@ func (t *TeamTasksTool) executeCreate(ctx context.Context, args map[string]any)
|
||||
Status: status,
|
||||
UserID: store.UserIDFromContext(ctx),
|
||||
Channel: ToolChannelFromCtx(ctx),
|
||||
ChatID: ToolChatIDFromCtx(ctx),
|
||||
ChatID: chatID,
|
||||
Timestamp: task.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
|
||||
})
|
||||
|
||||
return NewResult(fmt.Sprintf("Task created: %s (id=%s, status=%s)", subject, task.ID, status))
|
||||
return NewResult(fmt.Sprintf("Task created: %s (id=%s, identifier=%s, status=%s)", subject, task.ID, task.Identifier, status))
|
||||
}
|
||||
|
||||
func (t *TeamTasksTool) executeClaim(ctx context.Context, args map[string]any) *Result {
|
||||
@@ -338,7 +412,7 @@ func (t *TeamTasksTool) executeClaim(ctx context.Context, args map[string]any) *
|
||||
|
||||
func (t *TeamTasksTool) executeComplete(ctx context.Context, args map[string]any) *Result {
|
||||
// Delegate agents cannot complete tasks — autoCompleteTeamTask handles it.
|
||||
if ToolChannelFromCtx(ctx) == "delegate" {
|
||||
if ToolChannelFromCtx(ctx) == ChannelDelegate {
|
||||
return ErrorResult("delegate agents cannot complete team tasks directly — results are auto-completed when delegation finishes")
|
||||
}
|
||||
|
||||
@@ -388,7 +462,7 @@ func (t *TeamTasksTool) executeComplete(ctx context.Context, args map[string]any
|
||||
|
||||
func (t *TeamTasksTool) executeCancel(ctx context.Context, args map[string]any) *Result {
|
||||
// Delegate agents cannot cancel tasks — only lead/user-facing agents can.
|
||||
if ToolChannelFromCtx(ctx) == "delegate" {
|
||||
if ToolChannelFromCtx(ctx) == ChannelDelegate {
|
||||
return ErrorResult("delegate agents cannot cancel team tasks directly")
|
||||
}
|
||||
|
||||
@@ -409,7 +483,7 @@ func (t *TeamTasksTool) executeCancel(ctx context.Context, args map[string]any)
|
||||
return ErrorResult("invalid task_id")
|
||||
}
|
||||
|
||||
reason, _ := args["reason"].(string)
|
||||
reason, _ := args["text"].(string)
|
||||
if reason == "" {
|
||||
reason = "Cancelled by agent"
|
||||
}
|
||||
@@ -424,10 +498,18 @@ func (t *TeamTasksTool) executeCancel(ctx context.Context, args map[string]any)
|
||||
t.manager.delegateMgr.CancelByTeamTaskID(taskID)
|
||||
}
|
||||
|
||||
// Record audit event.
|
||||
_ = t.manager.teamStore.RecordTaskEvent(ctx, &store.TeamTaskEventData{
|
||||
TaskID: taskID,
|
||||
EventType: "cancelled",
|
||||
ActorType: "agent",
|
||||
ActorID: agentID.String(),
|
||||
})
|
||||
|
||||
t.manager.broadcastTeamEvent(protocol.EventTeamTaskCancelled, protocol.TeamTaskEventPayload{
|
||||
TeamID: team.ID.String(),
|
||||
TaskID: taskIDStr,
|
||||
Status: "cancelled",
|
||||
Status: store.TeamTaskStatusCancelled,
|
||||
Reason: reason,
|
||||
UserID: store.UserIDFromContext(ctx),
|
||||
Channel: ToolChannelFromCtx(ctx),
|
||||
@@ -438,17 +520,80 @@ func (t *TeamTasksTool) executeCancel(ctx context.Context, args map[string]any)
|
||||
return NewResult(fmt.Sprintf("Task %s cancelled. Any running delegation has been stopped and dependent tasks unblocked.", taskIDStr))
|
||||
}
|
||||
|
||||
func (t *TeamTasksTool) executeReview(ctx context.Context, args map[string]any) *Result {
|
||||
team, agentID, err := t.manager.resolveTeam(ctx)
|
||||
if err != nil {
|
||||
return ErrorResult(err.Error())
|
||||
}
|
||||
|
||||
taskIDStr, _ := args["task_id"].(string)
|
||||
if taskIDStr == "" {
|
||||
return ErrorResult("task_id is required for review action")
|
||||
}
|
||||
taskID, err := uuid.Parse(taskIDStr)
|
||||
if err != nil {
|
||||
return ErrorResult("invalid task_id")
|
||||
}
|
||||
|
||||
// Verify the agent owns this task.
|
||||
task, err := t.manager.teamStore.GetTask(ctx, taskID)
|
||||
if err != nil {
|
||||
return ErrorResult("task not found: " + err.Error())
|
||||
}
|
||||
if task.TeamID != team.ID {
|
||||
return ErrorResult("task does not belong to your team")
|
||||
}
|
||||
if task.OwnerAgentID == nil || *task.OwnerAgentID != agentID {
|
||||
return ErrorResult("only the task owner can submit for review")
|
||||
}
|
||||
|
||||
if err := t.manager.teamStore.ReviewTask(ctx, taskID, team.ID); err != nil {
|
||||
return ErrorResult("failed to submit for review: " + err.Error())
|
||||
}
|
||||
|
||||
_ = t.manager.teamStore.RecordTaskEvent(ctx, &store.TeamTaskEventData{
|
||||
TaskID: taskID,
|
||||
EventType: "reviewed",
|
||||
ActorType: "agent",
|
||||
ActorID: agentID.String(),
|
||||
})
|
||||
|
||||
ownerKey := t.manager.agentKeyFromID(ctx, agentID)
|
||||
t.manager.broadcastTeamEvent(protocol.EventTeamTaskReviewed, protocol.TeamTaskEventPayload{
|
||||
TeamID: team.ID.String(),
|
||||
TaskID: taskIDStr,
|
||||
Status: store.TeamTaskStatusInReview,
|
||||
OwnerAgentKey: ownerKey,
|
||||
OwnerDisplayName: t.manager.agentDisplayName(ctx, ownerKey),
|
||||
UserID: store.UserIDFromContext(ctx),
|
||||
Channel: ToolChannelFromCtx(ctx),
|
||||
ChatID: ToolChatIDFromCtx(ctx),
|
||||
Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
|
||||
})
|
||||
|
||||
return NewResult(fmt.Sprintf("Task %s submitted for review.", taskIDStr))
|
||||
}
|
||||
|
||||
func (t *TeamTasksTool) executeApprove(ctx context.Context, args map[string]any) *Result {
|
||||
// Delegate agents cannot approve tasks — approval requires user authority.
|
||||
if ToolChannelFromCtx(ctx) == "delegate" {
|
||||
if ToolChannelFromCtx(ctx) == ChannelDelegate {
|
||||
return ErrorResult("delegate agents cannot approve team tasks")
|
||||
}
|
||||
|
||||
team, _, err := t.manager.resolveTeam(ctx)
|
||||
team, agentID, err := t.manager.resolveTeam(ctx)
|
||||
if err != nil {
|
||||
return ErrorResult(err.Error())
|
||||
}
|
||||
|
||||
// Only lead can approve tasks via tool (non-lead agents should not approve).
|
||||
// System/dashboard channels bypass this check (human UI approval).
|
||||
ch := ToolChannelFromCtx(ctx)
|
||||
if ch != ChannelSystem && ch != ChannelDashboard {
|
||||
if err := t.manager.requireLead(ctx, team, agentID); err != nil {
|
||||
return ErrorResult(err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
taskIDStr, _ := args["task_id"].(string)
|
||||
if taskIDStr == "" {
|
||||
return ErrorResult("task_id is required for approve action")
|
||||
@@ -467,8 +612,8 @@ func (t *TeamTasksTool) executeApprove(ctx context.Context, args map[string]any)
|
||||
return ErrorResult("task does not belong to your team")
|
||||
}
|
||||
|
||||
// Atomic transition: pending_approval -> pending (or blocked if blockers exist)
|
||||
if err := t.manager.teamStore.ApproveTask(ctx, taskID, team.ID); err != nil {
|
||||
// Atomic transition: in_review -> completed
|
||||
if err := t.manager.teamStore.ApproveTask(ctx, taskID, team.ID, ""); err != nil {
|
||||
return ErrorResult("failed to approve task: " + err.Error())
|
||||
}
|
||||
|
||||
@@ -506,15 +651,23 @@ func (t *TeamTasksTool) executeApprove(ctx context.Context, args map[string]any)
|
||||
|
||||
func (t *TeamTasksTool) executeReject(ctx context.Context, args map[string]any) *Result {
|
||||
// Delegate agents cannot reject tasks.
|
||||
if ToolChannelFromCtx(ctx) == "delegate" {
|
||||
if ToolChannelFromCtx(ctx) == ChannelDelegate {
|
||||
return ErrorResult("delegate agents cannot reject team tasks")
|
||||
}
|
||||
|
||||
team, _, err := t.manager.resolveTeam(ctx)
|
||||
team, agentID, err := t.manager.resolveTeam(ctx)
|
||||
if err != nil {
|
||||
return ErrorResult(err.Error())
|
||||
}
|
||||
|
||||
// Only lead can reject tasks via tool.
|
||||
ch := ToolChannelFromCtx(ctx)
|
||||
if ch != ChannelSystem && ch != ChannelDashboard {
|
||||
if err := t.manager.requireLead(ctx, team, agentID); err != nil {
|
||||
return ErrorResult(err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
taskIDStr, _ := args["task_id"].(string)
|
||||
if taskIDStr == "" {
|
||||
return ErrorResult("task_id is required for reject action")
|
||||
@@ -524,7 +677,7 @@ func (t *TeamTasksTool) executeReject(ctx context.Context, args map[string]any)
|
||||
return ErrorResult("invalid task_id")
|
||||
}
|
||||
|
||||
reason, _ := args["reason"].(string)
|
||||
reason, _ := args["text"].(string)
|
||||
if reason == "" {
|
||||
reason = "Rejected by user"
|
||||
}
|
||||
@@ -568,3 +721,292 @@ func (t *TeamTasksTool) executeReject(ctx context.Context, args map[string]any)
|
||||
|
||||
return NewResult(fmt.Sprintf("Task %s rejected. Dependent tasks have been unblocked.", taskIDStr))
|
||||
}
|
||||
|
||||
func (t *TeamTasksTool) executeComment(ctx context.Context, args map[string]any) *Result {
|
||||
team, agentID, err := t.manager.resolveTeam(ctx)
|
||||
if err != nil {
|
||||
return ErrorResult(err.Error())
|
||||
}
|
||||
|
||||
taskIDStr, _ := args["task_id"].(string)
|
||||
if taskIDStr == "" {
|
||||
return ErrorResult("task_id is required for comment action")
|
||||
}
|
||||
taskID, err := uuid.Parse(taskIDStr)
|
||||
if err != nil {
|
||||
return ErrorResult("invalid task_id")
|
||||
}
|
||||
|
||||
text, _ := args["text"].(string)
|
||||
if text == "" {
|
||||
return ErrorResult("text is required for comment action")
|
||||
}
|
||||
if len(text) > 10000 {
|
||||
return ErrorResult("comment text too long (max 10000 chars)")
|
||||
}
|
||||
|
||||
// Verify task belongs to team.
|
||||
task, err := t.manager.teamStore.GetTask(ctx, taskID)
|
||||
if err != nil {
|
||||
return ErrorResult("task not found: " + err.Error())
|
||||
}
|
||||
if task.TeamID != team.ID {
|
||||
return ErrorResult("task does not belong to your team")
|
||||
}
|
||||
|
||||
if err := t.manager.teamStore.AddTaskComment(ctx, &store.TeamTaskCommentData{
|
||||
TaskID: taskID,
|
||||
AgentID: &agentID,
|
||||
Content: text,
|
||||
}); err != nil {
|
||||
return ErrorResult("failed to add comment: " + err.Error())
|
||||
}
|
||||
|
||||
t.manager.broadcastTeamEvent(protocol.EventTeamTaskCommented, protocol.TeamTaskEventPayload{
|
||||
TeamID: team.ID.String(),
|
||||
TaskID: taskIDStr,
|
||||
UserID: store.UserIDFromContext(ctx),
|
||||
Channel: ToolChannelFromCtx(ctx),
|
||||
ChatID: ToolChatIDFromCtx(ctx),
|
||||
Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
|
||||
})
|
||||
|
||||
return NewResult(fmt.Sprintf("Comment added to task %s.", taskIDStr))
|
||||
}
|
||||
|
||||
func (t *TeamTasksTool) executeProgress(ctx context.Context, args map[string]any) *Result {
|
||||
team, agentID, err := t.manager.resolveTeam(ctx)
|
||||
if err != nil {
|
||||
return ErrorResult(err.Error())
|
||||
}
|
||||
|
||||
taskIDStr, _ := args["task_id"].(string)
|
||||
if taskIDStr == "" {
|
||||
return ErrorResult("task_id is required for progress action")
|
||||
}
|
||||
taskID, err := uuid.Parse(taskIDStr)
|
||||
if err != nil {
|
||||
return ErrorResult("invalid task_id")
|
||||
}
|
||||
|
||||
percent := 0
|
||||
if p, ok := args["percent"].(float64); ok {
|
||||
percent = int(p)
|
||||
}
|
||||
if percent < 0 || percent > 100 {
|
||||
return ErrorResult("percent must be 0-100")
|
||||
}
|
||||
step, _ := args["text"].(string)
|
||||
|
||||
// Verify ownership.
|
||||
task, err := t.manager.teamStore.GetTask(ctx, taskID)
|
||||
if err != nil {
|
||||
return ErrorResult("task not found: " + err.Error())
|
||||
}
|
||||
if task.TeamID != team.ID {
|
||||
return ErrorResult("task does not belong to your team")
|
||||
}
|
||||
if task.OwnerAgentID == nil || *task.OwnerAgentID != agentID {
|
||||
return ErrorResult("only the task owner can update progress")
|
||||
}
|
||||
|
||||
if err := t.manager.teamStore.UpdateTaskProgress(ctx, taskID, team.ID, percent, step); err != nil {
|
||||
return ErrorResult("failed to update progress: " + err.Error())
|
||||
}
|
||||
|
||||
t.manager.broadcastTeamEvent(protocol.EventTeamTaskProgress, protocol.TeamTaskEventPayload{
|
||||
TeamID: team.ID.String(),
|
||||
TaskID: taskIDStr,
|
||||
Status: store.TeamTaskStatusInProgress,
|
||||
UserID: store.UserIDFromContext(ctx),
|
||||
Channel: ToolChannelFromCtx(ctx),
|
||||
ChatID: ToolChatIDFromCtx(ctx),
|
||||
Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
|
||||
})
|
||||
|
||||
return SilentResult(fmt.Sprintf("Progress updated: %d%% %s", percent, step))
|
||||
}
|
||||
|
||||
func (t *TeamTasksTool) executeAttach(ctx context.Context, args map[string]any) *Result {
|
||||
team, agentID, err := t.manager.resolveTeam(ctx)
|
||||
if err != nil {
|
||||
return ErrorResult(err.Error())
|
||||
}
|
||||
|
||||
taskIDStr, _ := args["task_id"].(string)
|
||||
if taskIDStr == "" {
|
||||
return ErrorResult("task_id is required for attach action")
|
||||
}
|
||||
taskID, err := uuid.Parse(taskIDStr)
|
||||
if err != nil {
|
||||
return ErrorResult("invalid task_id")
|
||||
}
|
||||
|
||||
fileIDStr, _ := args["file_id"].(string)
|
||||
if fileIDStr == "" {
|
||||
return ErrorResult("file_id is required for attach action")
|
||||
}
|
||||
fileID, err := uuid.Parse(fileIDStr)
|
||||
if err != nil {
|
||||
return ErrorResult("invalid file_id")
|
||||
}
|
||||
|
||||
// Verify task belongs to team.
|
||||
task, err := t.manager.teamStore.GetTask(ctx, taskID)
|
||||
if err != nil {
|
||||
return ErrorResult("task not found: " + err.Error())
|
||||
}
|
||||
if task.TeamID != team.ID {
|
||||
return ErrorResult("task does not belong to your team")
|
||||
}
|
||||
|
||||
if err := t.manager.teamStore.AttachFileToTask(ctx, &store.TeamTaskAttachmentData{
|
||||
TaskID: taskID,
|
||||
FileID: fileID,
|
||||
AddedBy: &agentID,
|
||||
}); err != nil {
|
||||
return ErrorResult("failed to attach file: " + err.Error())
|
||||
}
|
||||
|
||||
return NewResult(fmt.Sprintf("File attached to task %s.", taskIDStr))
|
||||
}
|
||||
|
||||
func (t *TeamTasksTool) executeUpdate(ctx context.Context, args map[string]any) *Result {
|
||||
team, agentID, err := t.manager.resolveTeam(ctx)
|
||||
if err != nil {
|
||||
return ErrorResult(err.Error())
|
||||
}
|
||||
if err := t.manager.requireLead(ctx, team, agentID); err != nil {
|
||||
return ErrorResult(err.Error())
|
||||
}
|
||||
|
||||
taskIDStr, _ := args["task_id"].(string)
|
||||
if taskIDStr == "" {
|
||||
return ErrorResult("task_id is required for update action")
|
||||
}
|
||||
taskID, err := uuid.Parse(taskIDStr)
|
||||
if err != nil {
|
||||
return ErrorResult("invalid task_id")
|
||||
}
|
||||
|
||||
// Verify task belongs to this team (prevent cross-team update).
|
||||
task, err := t.manager.teamStore.GetTask(ctx, taskID)
|
||||
if err != nil {
|
||||
return ErrorResult("task not found: " + err.Error())
|
||||
}
|
||||
if task.TeamID != team.ID {
|
||||
return ErrorResult("task does not belong to your team")
|
||||
}
|
||||
|
||||
updates := map[string]any{}
|
||||
if desc, ok := args["description"].(string); ok {
|
||||
updates["description"] = desc
|
||||
}
|
||||
if subj, ok := args["subject"].(string); ok && subj != "" {
|
||||
updates["subject"] = subj
|
||||
}
|
||||
if len(updates) == 0 {
|
||||
return ErrorResult("no updates provided (set description or subject)")
|
||||
}
|
||||
|
||||
if err := t.manager.teamStore.UpdateTask(ctx, taskID, updates); err != nil {
|
||||
return ErrorResult("failed to update task: " + err.Error())
|
||||
}
|
||||
|
||||
return NewResult(fmt.Sprintf("Task %s updated.", taskIDStr))
|
||||
}
|
||||
|
||||
func (t *TeamTasksTool) executeAwaitReply(ctx context.Context, args map[string]any) *Result {
|
||||
team, agentID, err := t.manager.resolveTeam(ctx)
|
||||
if err != nil {
|
||||
return ErrorResult(err.Error())
|
||||
}
|
||||
|
||||
taskIDStr, _ := args["task_id"].(string)
|
||||
if taskIDStr == "" {
|
||||
return ErrorResult("task_id is required for await_reply action")
|
||||
}
|
||||
taskID, err := uuid.Parse(taskIDStr)
|
||||
if err != nil {
|
||||
return ErrorResult("invalid task_id")
|
||||
}
|
||||
|
||||
followupMessage, _ := args["text"].(string)
|
||||
if followupMessage == "" {
|
||||
return ErrorResult("text is required for await_reply action (the reminder message)")
|
||||
}
|
||||
|
||||
// Verify ownership.
|
||||
task, err := t.manager.teamStore.GetTask(ctx, taskID)
|
||||
if err != nil {
|
||||
return ErrorResult("task not found: " + err.Error())
|
||||
}
|
||||
if task.TeamID != team.ID {
|
||||
return ErrorResult("task does not belong to your team")
|
||||
}
|
||||
if task.OwnerAgentID == nil || *task.OwnerAgentID != agentID {
|
||||
return ErrorResult("only the task owner can set follow-up reminders")
|
||||
}
|
||||
|
||||
// Resolve delay and max from team settings.
|
||||
delayMinutes := t.manager.followupDelayMinutes(team)
|
||||
maxReminders := t.manager.followupMaxReminders(team)
|
||||
|
||||
// Resolve channel: prefer task's channel, fallback to context channel.
|
||||
channel := task.Channel
|
||||
chatID := task.ChatID
|
||||
ctxChannel := ToolChannelFromCtx(ctx)
|
||||
if channel == "" || channel == ChannelDelegate || channel == ChannelSystem || channel == ChannelDashboard {
|
||||
channel = ctxChannel
|
||||
chatID = ToolChatIDFromCtx(ctx)
|
||||
}
|
||||
if channel == "" || channel == ChannelDelegate || channel == ChannelSystem || channel == ChannelDashboard {
|
||||
return ErrorResult("cannot set follow-up: no valid channel found (task has no origin channel and context channel is internal)")
|
||||
}
|
||||
|
||||
followupAt := time.Now().Add(time.Duration(delayMinutes) * time.Minute)
|
||||
if err := t.manager.teamStore.SetTaskFollowup(ctx, taskID, team.ID, followupAt, maxReminders, followupMessage, channel, chatID); err != nil {
|
||||
return ErrorResult("failed to set follow-up: " + err.Error())
|
||||
}
|
||||
|
||||
maxDesc := "unlimited"
|
||||
if maxReminders > 0 {
|
||||
maxDesc = fmt.Sprintf("max %d", maxReminders)
|
||||
}
|
||||
return NewResult(fmt.Sprintf("Follow-up set for task %s. First reminder in %d minutes via %s (%s).", taskIDStr, delayMinutes, channel, maxDesc))
|
||||
}
|
||||
|
||||
func (t *TeamTasksTool) executeClearFollowup(ctx context.Context, args map[string]any) *Result {
|
||||
team, agentID, err := t.manager.resolveTeam(ctx)
|
||||
if err != nil {
|
||||
return ErrorResult(err.Error())
|
||||
}
|
||||
|
||||
taskIDStr, _ := args["task_id"].(string)
|
||||
if taskIDStr == "" {
|
||||
return ErrorResult("task_id is required for clear_followup action")
|
||||
}
|
||||
taskID, err := uuid.Parse(taskIDStr)
|
||||
if err != nil {
|
||||
return ErrorResult("invalid task_id")
|
||||
}
|
||||
|
||||
// Verify task belongs to team.
|
||||
task, err := t.manager.teamStore.GetTask(ctx, taskID)
|
||||
if err != nil {
|
||||
return ErrorResult("task not found: " + err.Error())
|
||||
}
|
||||
if task.TeamID != team.ID {
|
||||
return ErrorResult("task does not belong to your team")
|
||||
}
|
||||
// Allow owner or lead to clear.
|
||||
if task.OwnerAgentID == nil || (*task.OwnerAgentID != agentID && agentID != team.LeadAgentID) {
|
||||
return ErrorResult("only the task owner or team lead can clear follow-up reminders")
|
||||
}
|
||||
|
||||
if err := t.manager.teamStore.ClearTaskFollowup(ctx, taskID); err != nil {
|
||||
return ErrorResult("failed to clear follow-up: " + err.Error())
|
||||
}
|
||||
|
||||
return NewResult(fmt.Sprintf("Follow-up reminders cleared for task %s.", taskIDStr))
|
||||
}
|
||||
@@ -2,7 +2,9 @@ package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -10,6 +12,7 @@ import (
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
||||
)
|
||||
|
||||
const teamCacheTTL = 5 * time.Minute
|
||||
@@ -68,9 +71,11 @@ func (m *TeamToolManager) resolveTeam(ctx context.Context) (*store.TeamData, uui
|
||||
// Cache miss → DB
|
||||
team, err := m.teamStore.GetTeamForAgent(ctx, agentID)
|
||||
if err != nil {
|
||||
slog.Warn("workspace: resolveTeam DB error", "agent_id", agentID, "error", err)
|
||||
return nil, uuid.Nil, fmt.Errorf("failed to resolve team: %w", err)
|
||||
}
|
||||
if team == nil {
|
||||
slog.Warn("workspace: agent has no team", "agent_id", agentID)
|
||||
return nil, uuid.Nil, fmt.Errorf("this agent is not part of any team")
|
||||
}
|
||||
|
||||
@@ -91,7 +96,7 @@ func (m *TeamToolManager) resolveTeam(ctx context.Context) (*store.TeamData, uui
|
||||
// Delegate/system channels bypass this check (they act on behalf of the lead).
|
||||
func (m *TeamToolManager) requireLead(ctx context.Context, team *store.TeamData, agentID uuid.UUID) error {
|
||||
channel := ToolChannelFromCtx(ctx)
|
||||
if channel == "delegate" || channel == "system" {
|
||||
if channel == ChannelDelegate || channel == ChannelSystem {
|
||||
return nil
|
||||
}
|
||||
if agentID != team.LeadAgentID {
|
||||
@@ -104,7 +109,7 @@ func (m *TeamToolManager) requireLead(ctx context.Context, team *store.TeamData,
|
||||
// Called when team membership, settings, or links change.
|
||||
// Full clear is acceptable because team mutations are rare (admin-initiated).
|
||||
func (m *TeamToolManager) InvalidateTeam() {
|
||||
m.teamCache = sync.Map{}
|
||||
m.teamCache.Range(func(k, _ any) bool { m.teamCache.Delete(k); return true })
|
||||
}
|
||||
|
||||
// resolveAgentByKey looks up an agent by key and returns its UUID.
|
||||
@@ -136,6 +141,25 @@ func (m *TeamToolManager) broadcastTeamEvent(name string, payload any) {
|
||||
})
|
||||
}
|
||||
|
||||
// resolveTeamRole returns the calling agent's role in the team.
|
||||
// Unlike requireLead(), this does NOT bypass for delegate channel —
|
||||
// workspace RBAC must respect actual roles even during delegation.
|
||||
func (m *TeamToolManager) resolveTeamRole(ctx context.Context, team *store.TeamData, agentID uuid.UUID) (string, error) {
|
||||
if agentID == team.LeadAgentID {
|
||||
return store.TeamRoleLead, nil
|
||||
}
|
||||
members, err := m.teamStore.ListMembers(ctx, team.ID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to resolve team role: %w", err)
|
||||
}
|
||||
for _, member := range members {
|
||||
if member.AgentID == agentID {
|
||||
return member.Role, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("agent is not a member of this team")
|
||||
}
|
||||
|
||||
// agentDisplayName returns the display name for an agent key, falling back to empty string.
|
||||
func (m *TeamToolManager) agentDisplayName(ctx context.Context, key string) string {
|
||||
ag, err := m.agentStore.GetByKey(ctx, key)
|
||||
@@ -144,3 +168,176 @@ func (m *TeamToolManager) agentDisplayName(ctx context.Context, key string) stri
|
||||
}
|
||||
return ag.DisplayName
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Version helpers
|
||||
// ============================================================
|
||||
|
||||
// IsTeamV2 checks if team has version >= 2 in settings.
|
||||
// Returns false for nil team, nil/empty settings, or version < 2.
|
||||
func IsTeamV2(team *store.TeamData) bool {
|
||||
if team == nil || team.Settings == nil {
|
||||
return false
|
||||
}
|
||||
var s struct {
|
||||
Version int `json:"version"`
|
||||
}
|
||||
if json.Unmarshal(team.Settings, &s) != nil {
|
||||
return false
|
||||
}
|
||||
return s.Version >= 2
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Follow-up settings helpers
|
||||
// ============================================================
|
||||
|
||||
const (
|
||||
defaultFollowupDelayMinutes = 30
|
||||
defaultFollowupMaxReminders = 0 // 0 = unlimited
|
||||
)
|
||||
|
||||
// followupDelayMinutes returns the team's followup_interval_minutes setting, or the default.
|
||||
// Returns 0 for v1 teams (followup disabled).
|
||||
func (m *TeamToolManager) followupDelayMinutes(team *store.TeamData) int {
|
||||
if !IsTeamV2(team) {
|
||||
return 0
|
||||
}
|
||||
if team.Settings == nil {
|
||||
return defaultFollowupDelayMinutes
|
||||
}
|
||||
var settings map[string]any
|
||||
if json.Unmarshal(team.Settings, &settings) != nil {
|
||||
return defaultFollowupDelayMinutes
|
||||
}
|
||||
if v, ok := settings["followup_interval_minutes"].(float64); ok && v > 0 {
|
||||
return int(v)
|
||||
}
|
||||
return defaultFollowupDelayMinutes
|
||||
}
|
||||
|
||||
// followupMaxReminders returns the team's followup_max_reminders setting, or the default.
|
||||
// Returns 0 for v1 teams (followup disabled).
|
||||
func (m *TeamToolManager) followupMaxReminders(team *store.TeamData) int {
|
||||
if !IsTeamV2(team) {
|
||||
return 0
|
||||
}
|
||||
if team.Settings == nil {
|
||||
return defaultFollowupMaxReminders
|
||||
}
|
||||
var settings map[string]any
|
||||
if json.Unmarshal(team.Settings, &settings) != nil {
|
||||
return defaultFollowupMaxReminders
|
||||
}
|
||||
if v, ok := settings["followup_max_reminders"].(float64); ok && v >= 0 {
|
||||
return int(v)
|
||||
}
|
||||
return defaultFollowupMaxReminders
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Escalation policy
|
||||
// ============================================================
|
||||
|
||||
// EscalationResult indicates how an action should be handled.
|
||||
type EscalationResult int
|
||||
|
||||
const (
|
||||
EscalationNone EscalationResult = iota // no escalation configured
|
||||
EscalationAuto // LLM chooses (currently: always review)
|
||||
EscalationReview // create review task
|
||||
EscalationReject // reject outright
|
||||
)
|
||||
|
||||
// checkEscalation parses the team's escalation_mode and escalation_actions settings.
|
||||
// Returns EscalationNone for v1 teams.
|
||||
func (m *TeamToolManager) checkEscalation(team *store.TeamData, action string) EscalationResult {
|
||||
if !IsTeamV2(team) {
|
||||
return EscalationNone
|
||||
}
|
||||
if team.Settings == nil {
|
||||
return EscalationNone
|
||||
}
|
||||
var settings map[string]any
|
||||
if err := json.Unmarshal(team.Settings, &settings); err != nil {
|
||||
return EscalationNone
|
||||
}
|
||||
|
||||
mode, _ := settings["escalation_mode"].(string)
|
||||
if mode == "" {
|
||||
return EscalationNone
|
||||
}
|
||||
|
||||
// Check if action is in escalation_actions list.
|
||||
actionsRaw, _ := settings["escalation_actions"].([]any)
|
||||
if len(actionsRaw) > 0 {
|
||||
found := false
|
||||
for _, a := range actionsRaw {
|
||||
if s, ok := a.(string); ok && s == action {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return EscalationNone
|
||||
}
|
||||
}
|
||||
|
||||
switch mode {
|
||||
case "auto":
|
||||
return EscalationAuto
|
||||
case "review":
|
||||
return EscalationReview
|
||||
case "reject":
|
||||
return EscalationReject
|
||||
default:
|
||||
return EscalationNone
|
||||
}
|
||||
}
|
||||
|
||||
// createEscalationTask creates an escalation task and broadcasts the event.
|
||||
func (m *TeamToolManager) createEscalationTask(ctx context.Context, team *store.TeamData, agentID uuid.UUID, subject, description string) *Result {
|
||||
task := &store.TeamTaskData{
|
||||
TeamID: team.ID,
|
||||
Subject: subject,
|
||||
Description: description,
|
||||
Status: store.TeamTaskStatusPending,
|
||||
UserID: store.UserIDFromContext(ctx),
|
||||
Channel: ToolChannelFromCtx(ctx),
|
||||
TaskType: "escalation",
|
||||
CreatedByAgentID: &agentID,
|
||||
ChatID: ToolChatIDFromCtx(ctx),
|
||||
}
|
||||
if err := m.teamStore.CreateTask(ctx, task); err != nil {
|
||||
return ErrorResult("failed to create escalation task: " + err.Error())
|
||||
}
|
||||
|
||||
m.broadcastTeamEvent(protocol.EventTeamTaskCreated, protocol.TeamTaskEventPayload{
|
||||
TeamID: team.ID.String(),
|
||||
TaskID: task.ID.String(),
|
||||
Subject: subject,
|
||||
Status: store.TeamTaskStatusPending,
|
||||
UserID: store.UserIDFromContext(ctx),
|
||||
Channel: ToolChannelFromCtx(ctx),
|
||||
ChatID: ToolChatIDFromCtx(ctx),
|
||||
Timestamp: task.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
|
||||
})
|
||||
|
||||
// Notify channel if possible.
|
||||
m.notifyChannelReview(task)
|
||||
|
||||
return NewResult(fmt.Sprintf("Action requires approval. Escalation task created: %s (id=%s). A human must approve before this action can proceed.", subject, task.Identifier))
|
||||
}
|
||||
|
||||
// notifyChannelReview publishes an outbound message to the origin channel about a pending review.
|
||||
func (m *TeamToolManager) notifyChannelReview(task *store.TeamTaskData) {
|
||||
if m.msgBus == nil || task.Channel == "" || task.ChatID == "" {
|
||||
return
|
||||
}
|
||||
content := fmt.Sprintf("🔔 Escalation: \"%s\" requires human review (task %s).", task.Subject, task.Identifier)
|
||||
m.msgBus.PublishOutbound(bus.OutboundMessage{
|
||||
Channel: task.Channel,
|
||||
ChatID: task.ChatID,
|
||||
Content: content,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
// Workspace limits shared across read/write tools.
|
||||
const defaultQuotaMB = 500
|
||||
|
||||
// workspaceDir returns the disk directory for a team workspace scope.
|
||||
// Pattern: {dataDir}/teams/{teamID}/{chatID}/
|
||||
// chatID is the system-derived userID (stable across WS reconnects).
|
||||
// Creates directory with 0750 if not exists.
|
||||
func workspaceDir(dataDir string, teamID uuid.UUID, _, chatID string) (string, error) {
|
||||
if chatID == "" {
|
||||
chatID = "_default"
|
||||
}
|
||||
dir := filepath.Join(dataDir, "teams", teamID.String(), chatID)
|
||||
if err := os.MkdirAll(dir, 0750); err != nil {
|
||||
return "", fmt.Errorf("failed to create workspace dir: %w", err)
|
||||
}
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
// validFileName matches alphanumeric + "-_." only.
|
||||
var validFileName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,253}[a-zA-Z0-9._]$`)
|
||||
|
||||
// sanitizeFileName validates file name: max 255 chars, no path separators,
|
||||
// no null bytes, no "..", alphanumeric + "-_." only.
|
||||
func sanitizeFileName(name string) (string, error) {
|
||||
if name == "" {
|
||||
return "", fmt.Errorf("file_name is required")
|
||||
}
|
||||
if len(name) > 255 {
|
||||
return "", fmt.Errorf("file_name exceeds 255 characters")
|
||||
}
|
||||
if strings.Contains(name, "\x00") {
|
||||
return "", fmt.Errorf("file_name contains null bytes")
|
||||
}
|
||||
if strings.Contains(name, "..") {
|
||||
return "", fmt.Errorf("file_name contains path traversal")
|
||||
}
|
||||
if strings.ContainsAny(name, "/\\") {
|
||||
return "", fmt.Errorf("file_name contains path separators")
|
||||
}
|
||||
// Allow single-char names like "a" or "1"
|
||||
if len(name) == 1 {
|
||||
if (name[0] >= 'a' && name[0] <= 'z') || (name[0] >= 'A' && name[0] <= 'Z') || (name[0] >= '0' && name[0] <= '9') {
|
||||
return name, nil
|
||||
}
|
||||
return "", fmt.Errorf("file_name must be alphanumeric with hyphens, underscores, and dots only")
|
||||
}
|
||||
if !validFileName.MatchString(name) {
|
||||
return "", fmt.Errorf("file_name must be alphanumeric with hyphens, underscores, and dots only")
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
// blockedExtensions lists executable file types that are not allowed.
|
||||
var blockedExtensions = map[string]bool{
|
||||
".exe": true, ".sh": true, ".bat": true, ".cmd": true,
|
||||
".ps1": true, ".com": true, ".msi": true, ".scr": true,
|
||||
}
|
||||
|
||||
// mimeTypes maps file extensions to MIME types (package-level to avoid re-allocation).
|
||||
var mimeTypes = map[string]string{
|
||||
".txt": "text/plain",
|
||||
".md": "text/markdown",
|
||||
".json": "application/json",
|
||||
".csv": "text/csv",
|
||||
".xml": "text/xml",
|
||||
".html": "text/html",
|
||||
".css": "text/css",
|
||||
".js": "application/javascript",
|
||||
".ts": "application/typescript",
|
||||
".py": "text/x-python",
|
||||
".go": "text/x-go",
|
||||
".rs": "text/x-rust",
|
||||
".java": "text/x-java",
|
||||
".yaml": "application/x-yaml",
|
||||
".yml": "application/x-yaml",
|
||||
".toml": "application/toml",
|
||||
".sql": "application/sql",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".svg": "image/svg+xml",
|
||||
".pdf": "application/pdf",
|
||||
".zip": "application/zip",
|
||||
".tar": "application/x-tar",
|
||||
".gz": "application/gzip",
|
||||
}
|
||||
|
||||
// inferMimeType returns MIME type from file extension.
|
||||
// Blocks executable types.
|
||||
func inferMimeType(fileName string) (string, error) {
|
||||
ext := strings.ToLower(filepath.Ext(fileName))
|
||||
if blockedExtensions[ext] {
|
||||
return "", fmt.Errorf("executable file type %q is not allowed", ext)
|
||||
}
|
||||
if mt, ok := mimeTypes[ext]; ok {
|
||||
return mt, nil
|
||||
}
|
||||
return "application/octet-stream", nil
|
||||
}
|
||||
|
||||
// isBinaryMime returns true if the MIME type represents binary content.
|
||||
func isBinaryMime(mimeType string) bool {
|
||||
if strings.HasPrefix(mimeType, "text/") {
|
||||
return false
|
||||
}
|
||||
switch mimeType {
|
||||
case "application/json", "application/javascript", "application/typescript",
|
||||
"application/x-yaml", "application/toml", "application/sql",
|
||||
"application/xml":
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// resolveWorkspaceScope resolves the workspace scope (channel, chatID) for tools.
|
||||
// Scope is (team_id, userID) where userID is the system-derived stable user ID.
|
||||
// channel is always "" (kept for signature compatibility); chatID = userID.
|
||||
// Priority: WorkspaceChatID context key (set during delegation) > store.UserIDFromContext
|
||||
func resolveWorkspaceScope(ctx context.Context) (channel, chatID string) {
|
||||
chatID = WorkspaceChatIDFromCtx(ctx)
|
||||
if chatID == "" {
|
||||
chatID = store.UserIDFromContext(ctx)
|
||||
}
|
||||
return "", chatID
|
||||
}
|
||||
|
||||
// workspaceSettings parses workspace-related fields from team settings JSON once.
|
||||
type workspaceSettings struct {
|
||||
WorkspaceScope string `json:"workspace_scope"`
|
||||
WorkspaceQuotaMB *int `json:"workspace_quota_mb"`
|
||||
WorkspaceTemplates []workspaceTempl `json:"workspace_templates"`
|
||||
}
|
||||
|
||||
type workspaceTempl struct {
|
||||
FileName string `json:"file_name"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
func parseWorkspaceSettings(raw json.RawMessage) workspaceSettings {
|
||||
var ws workspaceSettings
|
||||
if len(raw) > 0 {
|
||||
_ = json.Unmarshal(raw, &ws)
|
||||
}
|
||||
return ws
|
||||
}
|
||||
|
||||
func (ws workspaceSettings) isShared() bool {
|
||||
return ws.WorkspaceScope == "shared"
|
||||
}
|
||||
|
||||
func (ws workspaceSettings) quotaMB(defaultMB int) int {
|
||||
if ws.WorkspaceQuotaMB != nil {
|
||||
return *ws.WorkspaceQuotaMB
|
||||
}
|
||||
return defaultMB
|
||||
}
|
||||
|
||||
// resolveWorkspaceScopeFromArgs resolves scope (channel, chatID) from tool args + context.
|
||||
// Returns error result string if scope=team but team setting is isolated.
|
||||
func resolveWorkspaceScopeFromArgs(ctx context.Context, args map[string]any, ws workspaceSettings) (channel, chatID, errMsg string) {
|
||||
scope, _ := args["scope"].(string)
|
||||
if scope == "" {
|
||||
scope = "channel"
|
||||
}
|
||||
channel, chatID = resolveWorkspaceScope(ctx)
|
||||
if scope == "team" {
|
||||
if !ws.isShared() {
|
||||
return "", "", "team workspace_scope is 'isolated' — set to 'shared' in team settings to use scope=team"
|
||||
}
|
||||
chatID = ""
|
||||
}
|
||||
return "", chatID, ""
|
||||
}
|
||||
|
||||
// validTags is the set of allowed tag values.
|
||||
var validTags = map[string]bool{
|
||||
"deliverable": true,
|
||||
"handoff": true,
|
||||
"reference": true,
|
||||
"draft": true,
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
||||
)
|
||||
|
||||
// WorkspaceReadTool allows agents to read, list, delete, pin, tag, and comment
|
||||
// on files in the team shared workspace.
|
||||
type WorkspaceReadTool struct {
|
||||
manager *TeamToolManager
|
||||
dataDir string
|
||||
}
|
||||
|
||||
func NewWorkspaceReadTool(manager *TeamToolManager, dataDir string) *WorkspaceReadTool {
|
||||
return &WorkspaceReadTool{manager: manager, dataDir: dataDir}
|
||||
}
|
||||
|
||||
func (t *WorkspaceReadTool) Name() string { return "workspace_read" }
|
||||
|
||||
func (t *WorkspaceReadTool) Description() string {
|
||||
return "Read and manage files in the team shared workspace. " +
|
||||
"Actions: list, read (default), delete, pin/tag (lead only), history, comment, comments."
|
||||
}
|
||||
|
||||
func (t *WorkspaceReadTool) Parameters() map[string]any {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"action": map[string]any{
|
||||
"type": "string",
|
||||
"description": "'list', 'read' (default), 'delete', 'pin', 'tag', 'history', 'comment', 'comments'",
|
||||
},
|
||||
"file_name": map[string]any{
|
||||
"type": "string",
|
||||
"description": "File name (required for most actions)",
|
||||
},
|
||||
"scope": map[string]any{
|
||||
"type": "string",
|
||||
"description": "'channel' (default, per-user) or 'team' (shared, requires workspace_scope=shared)",
|
||||
},
|
||||
"pinned": map[string]any{
|
||||
"type": "boolean",
|
||||
"description": "For action=pin: set pinned state",
|
||||
},
|
||||
"tags": map[string]any{
|
||||
"type": "array",
|
||||
"description": "For action=tag: tags to set (deliverable, handoff, reference, draft)",
|
||||
"items": map[string]any{"type": "string"},
|
||||
},
|
||||
"version": map[string]any{
|
||||
"type": "integer",
|
||||
"description": "For action=read: read specific version",
|
||||
},
|
||||
"text": map[string]any{
|
||||
"type": "string",
|
||||
"description": "For action=comment: comment text",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *WorkspaceReadTool) Execute(ctx context.Context, args map[string]any) *Result {
|
||||
team, agentID, err := t.manager.resolveTeam(ctx)
|
||||
if err != nil {
|
||||
return ErrorResult(err.Error())
|
||||
}
|
||||
|
||||
role, err := t.manager.resolveTeamRole(ctx, team, agentID)
|
||||
if err != nil {
|
||||
return ErrorResult(err.Error())
|
||||
}
|
||||
|
||||
ws := parseWorkspaceSettings(team.Settings)
|
||||
|
||||
// Resolve scope.
|
||||
channel, chatID, scopeErr := resolveWorkspaceScopeFromArgs(ctx, args, ws)
|
||||
if scopeErr != "" {
|
||||
return ErrorResult(scopeErr)
|
||||
}
|
||||
|
||||
action, _ := args["action"].(string)
|
||||
if action == "" {
|
||||
action = "read"
|
||||
}
|
||||
|
||||
switch action {
|
||||
case "list":
|
||||
return t.executeList(ctx, team, channel, chatID, ws)
|
||||
case "read":
|
||||
return t.executeRead(ctx, args, team, channel, chatID)
|
||||
case "delete":
|
||||
return t.executeDelete(ctx, args, team, agentID, role, channel, chatID)
|
||||
case "pin":
|
||||
return t.executePin(ctx, args, team, role, channel, chatID)
|
||||
case "tag":
|
||||
return t.executeTag(ctx, args, team, role, channel, chatID)
|
||||
case "history":
|
||||
return t.executeHistory(ctx, args, team, channel, chatID)
|
||||
case "comment":
|
||||
return t.executeComment(ctx, args, team, agentID, channel, chatID)
|
||||
case "comments":
|
||||
return t.executeComments(ctx, args, team, channel, chatID)
|
||||
default:
|
||||
return ErrorResult(fmt.Sprintf("unknown action %q", action))
|
||||
}
|
||||
}
|
||||
|
||||
func (t *WorkspaceReadTool) executeList(ctx context.Context, team *store.TeamData, channel, chatID string, ws workspaceSettings) *Result {
|
||||
files, err := t.manager.teamStore.ListWorkspaceFiles(ctx, team.ID, channel, chatID)
|
||||
if err != nil {
|
||||
return ErrorResult("failed to list workspace files: " + err.Error())
|
||||
}
|
||||
if len(files) == 0 {
|
||||
return NewResult("No workspace files in this scope.")
|
||||
}
|
||||
|
||||
// Show quota info.
|
||||
var header string
|
||||
quotaMB := ws.quotaMB(defaultQuotaMB)
|
||||
if quotaMB > 0 {
|
||||
totalSize, _ := t.manager.teamStore.GetWorkspaceTotalSize(ctx, team.ID)
|
||||
usedMB := float64(totalSize) / (1024 * 1024)
|
||||
header = fmt.Sprintf("Workspace files (%d files, %.1f MB / %d MB quota):\n", len(files), usedMB, quotaMB)
|
||||
} else {
|
||||
header = fmt.Sprintf("Workspace files (%d files):\n", len(files))
|
||||
}
|
||||
|
||||
var lines []string
|
||||
for _, f := range files {
|
||||
var tags strings.Builder
|
||||
if f.Pinned {
|
||||
tags.WriteString(" [pinned]")
|
||||
}
|
||||
for _, tag := range f.Tags {
|
||||
tags.WriteString(" [" + tag + "]")
|
||||
}
|
||||
taskLabel := ""
|
||||
if f.TaskID == nil {
|
||||
taskLabel = " [no task]"
|
||||
}
|
||||
missing := ""
|
||||
if _, err := os.Stat(f.FilePath); os.IsNotExist(err) {
|
||||
missing = " [missing]"
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("- %s (%s, %s, by %s)%s%s%s",
|
||||
f.FileName, f.MimeType, formatBytes(f.SizeBytes), f.UploadedByKey, tags.String(), taskLabel, missing))
|
||||
}
|
||||
return NewResult(header + strings.Join(lines, "\n"))
|
||||
}
|
||||
|
||||
func (t *WorkspaceReadTool) executeRead(ctx context.Context, args map[string]any, team *store.TeamData, channel, chatID string) *Result {
|
||||
fileName, _ := args["file_name"].(string)
|
||||
if fileName == "" {
|
||||
return ErrorResult("file_name is required for action=read")
|
||||
}
|
||||
|
||||
file, err := t.manager.teamStore.GetWorkspaceFile(ctx, team.ID, channel, chatID, fileName)
|
||||
if err != nil {
|
||||
return ErrorResult(err.Error())
|
||||
}
|
||||
|
||||
// Check for specific version.
|
||||
if v, ok := args["version"].(float64); ok && int(v) > 0 {
|
||||
version, err := t.manager.teamStore.GetFileVersion(ctx, file.ID, int(v))
|
||||
if err != nil {
|
||||
return ErrorResult(err.Error())
|
||||
}
|
||||
data, err := os.ReadFile(version.FilePath)
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("version %d file not found on disk", int(v)))
|
||||
}
|
||||
content := string(data)
|
||||
if len(content) > 100000 {
|
||||
content = content[:100000] + "\n\n[...truncated at 100K chars]"
|
||||
}
|
||||
return NewResult(fmt.Sprintf("--- %s (version %d, %s, by %s) ---\n%s",
|
||||
fileName, version.Version, formatBytes(version.SizeBytes), version.UploadedByKey, content))
|
||||
}
|
||||
|
||||
// Binary files: return metadata only.
|
||||
if isBinaryMime(file.MimeType) {
|
||||
return NewResult(fmt.Sprintf("Binary file: %s (%s, %s, by %s). Use other tools to process binary files.",
|
||||
fileName, file.MimeType, formatBytes(file.SizeBytes), file.UploadedByKey))
|
||||
}
|
||||
|
||||
// Read from disk.
|
||||
data, err := os.ReadFile(file.FilePath)
|
||||
if err != nil {
|
||||
return ErrorResult("file not found on disk: " + err.Error())
|
||||
}
|
||||
content := string(data)
|
||||
if len(content) > 100000 {
|
||||
content = content[:100000] + "\n\n[...truncated at 100K chars]"
|
||||
}
|
||||
|
||||
return NewResult(fmt.Sprintf("--- %s (%s, %s, by %s) ---\n%s",
|
||||
fileName, file.MimeType, formatBytes(file.SizeBytes), file.UploadedByKey, content))
|
||||
}
|
||||
|
||||
func (t *WorkspaceReadTool) executeDelete(ctx context.Context, args map[string]any, team *store.TeamData, agentID uuid.UUID, role, channel, chatID string) *Result {
|
||||
fileName, _ := args["file_name"].(string)
|
||||
if fileName == "" {
|
||||
return ErrorResult("file_name is required for action=delete")
|
||||
}
|
||||
|
||||
// Check ownership for non-lead.
|
||||
file, err := t.manager.teamStore.GetWorkspaceFile(ctx, team.ID, channel, chatID, fileName)
|
||||
if err != nil {
|
||||
return ErrorResult(err.Error())
|
||||
}
|
||||
|
||||
if file.Pinned {
|
||||
return ErrorResult("pinned files cannot be deleted — unpin first using action=pin pinned=false")
|
||||
}
|
||||
|
||||
switch role {
|
||||
case store.TeamRoleReviewer:
|
||||
return ErrorResult("reviewers cannot delete workspace files")
|
||||
case store.TeamRoleMember:
|
||||
if file.UploadedBy != agentID {
|
||||
return ErrorResult("members can only delete their own files")
|
||||
}
|
||||
case store.TeamRoleLead:
|
||||
// Lead deleting another agent's file: check escalation.
|
||||
if file.UploadedBy != agentID {
|
||||
if esc := t.manager.checkEscalation(team, "delete"); esc != EscalationNone {
|
||||
if esc == EscalationReject {
|
||||
return ErrorResult("deleting others' files is not allowed by team escalation policy")
|
||||
}
|
||||
return t.manager.createEscalationTask(ctx, team, agentID,
|
||||
fmt.Sprintf("Delete file: %s", fileName),
|
||||
fmt.Sprintf("Agent requested to delete file %q uploaded by another agent.", fileName))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete versions from disk.
|
||||
versions, _ := t.manager.teamStore.ListFileVersions(ctx, file.ID)
|
||||
for _, v := range versions {
|
||||
_ = os.Remove(v.FilePath)
|
||||
}
|
||||
|
||||
// Delete DB record (cascades versions + comments).
|
||||
filePath, err := t.manager.teamStore.DeleteWorkspaceFile(ctx, team.ID, channel, chatID, fileName)
|
||||
if err != nil {
|
||||
return ErrorResult(err.Error())
|
||||
}
|
||||
|
||||
// Delete disk file.
|
||||
_ = os.Remove(filePath)
|
||||
|
||||
// Broadcast event.
|
||||
t.manager.broadcastTeamEvent(protocol.EventWorkspaceFileChanged, map[string]string{
|
||||
"team_id": team.ID.String(),
|
||||
"channel": channel,
|
||||
"chat_id": chatID,
|
||||
"file_name": fileName,
|
||||
"action": "delete",
|
||||
})
|
||||
|
||||
return NewResult(fmt.Sprintf("Deleted workspace file %q", fileName))
|
||||
}
|
||||
|
||||
func (t *WorkspaceReadTool) executePin(ctx context.Context, args map[string]any, team *store.TeamData, role, channel, chatID string) *Result {
|
||||
if role != store.TeamRoleLead {
|
||||
return ErrorResult("only the team lead can pin/unpin files")
|
||||
}
|
||||
|
||||
// Check escalation policy.
|
||||
if esc := t.manager.checkEscalation(team, "pin"); esc != EscalationNone {
|
||||
if esc == EscalationReject {
|
||||
return ErrorResult("pin action is not allowed by team escalation policy")
|
||||
}
|
||||
agentID := store.AgentIDFromContext(ctx)
|
||||
fileName, _ := args["file_name"].(string)
|
||||
return t.manager.createEscalationTask(ctx, team, agentID,
|
||||
fmt.Sprintf("Pin file: %s", fileName),
|
||||
fmt.Sprintf("Agent requested to pin/unpin file %q in workspace.", fileName))
|
||||
}
|
||||
|
||||
fileName, _ := args["file_name"].(string)
|
||||
if fileName == "" {
|
||||
return ErrorResult("file_name is required for action=pin")
|
||||
}
|
||||
|
||||
pinned := true
|
||||
if v, ok := args["pinned"].(bool); ok {
|
||||
pinned = v
|
||||
}
|
||||
|
||||
if err := t.manager.teamStore.PinWorkspaceFile(ctx, team.ID, channel, chatID, fileName, pinned); err != nil {
|
||||
return ErrorResult(err.Error())
|
||||
}
|
||||
|
||||
action := "pinned"
|
||||
if !pinned {
|
||||
action = "unpinned"
|
||||
}
|
||||
return NewResult(fmt.Sprintf("File %q %s", fileName, action))
|
||||
}
|
||||
|
||||
func (t *WorkspaceReadTool) executeTag(ctx context.Context, args map[string]any, team *store.TeamData, role, channel, chatID string) *Result {
|
||||
if role != store.TeamRoleLead {
|
||||
return ErrorResult("only the team lead can set file tags")
|
||||
}
|
||||
|
||||
// Check escalation policy.
|
||||
if esc := t.manager.checkEscalation(team, "tag"); esc != EscalationNone {
|
||||
if esc == EscalationReject {
|
||||
return ErrorResult("tag action is not allowed by team escalation policy")
|
||||
}
|
||||
agentID := store.AgentIDFromContext(ctx)
|
||||
fileName, _ := args["file_name"].(string)
|
||||
return t.manager.createEscalationTask(ctx, team, agentID,
|
||||
fmt.Sprintf("Tag file: %s", fileName),
|
||||
fmt.Sprintf("Agent requested to tag file %q in workspace.", fileName))
|
||||
}
|
||||
|
||||
fileName, _ := args["file_name"].(string)
|
||||
if fileName == "" {
|
||||
return ErrorResult("file_name is required for action=tag")
|
||||
}
|
||||
|
||||
tagsRaw, ok := args["tags"]
|
||||
if !ok {
|
||||
return ErrorResult("tags parameter is required for action=tag")
|
||||
}
|
||||
|
||||
var tags []string
|
||||
switch v := tagsRaw.(type) {
|
||||
case []any:
|
||||
for _, item := range v {
|
||||
if s, ok := item.(string); ok {
|
||||
if !validTags[s] {
|
||||
return ErrorResult(fmt.Sprintf("invalid tag %q (valid: deliverable, handoff, reference, draft)", s))
|
||||
}
|
||||
tags = append(tags, s)
|
||||
}
|
||||
}
|
||||
default:
|
||||
return ErrorResult("tags must be an array of strings")
|
||||
}
|
||||
|
||||
if err := t.manager.teamStore.TagWorkspaceFile(ctx, team.ID, channel, chatID, fileName, tags); err != nil {
|
||||
return ErrorResult(err.Error())
|
||||
}
|
||||
|
||||
return NewResult(fmt.Sprintf("File %q tagged: %s", fileName, strings.Join(tags, ", ")))
|
||||
}
|
||||
|
||||
func (t *WorkspaceReadTool) executeHistory(ctx context.Context, args map[string]any, team *store.TeamData, channel, chatID string) *Result {
|
||||
fileName, _ := args["file_name"].(string)
|
||||
if fileName == "" {
|
||||
return ErrorResult("file_name is required for action=history")
|
||||
}
|
||||
|
||||
file, err := t.manager.teamStore.GetWorkspaceFile(ctx, team.ID, channel, chatID, fileName)
|
||||
if err != nil {
|
||||
return ErrorResult(err.Error())
|
||||
}
|
||||
|
||||
versions, err := t.manager.teamStore.ListFileVersions(ctx, file.ID)
|
||||
if err != nil {
|
||||
return ErrorResult("failed to list versions: " + err.Error())
|
||||
}
|
||||
|
||||
if len(versions) == 0 {
|
||||
return NewResult(fmt.Sprintf("No version history for %q (current version only)", fileName))
|
||||
}
|
||||
|
||||
var lines []string
|
||||
lines = append(lines, fmt.Sprintf("Version history for %q:", fileName))
|
||||
lines = append(lines, fmt.Sprintf(" current: %s, by %s, %s", formatBytes(file.SizeBytes), file.UploadedByKey, file.UpdatedAt.Format("2006-01-02 15:04")))
|
||||
for _, v := range versions {
|
||||
lines = append(lines, fmt.Sprintf(" v%d: %s, by %s, %s", v.Version, formatBytes(v.SizeBytes), v.UploadedByKey, v.CreatedAt.Format("2006-01-02 15:04")))
|
||||
}
|
||||
return NewResult(strings.Join(lines, "\n"))
|
||||
}
|
||||
|
||||
func (t *WorkspaceReadTool) executeComment(ctx context.Context, args map[string]any, team *store.TeamData, agentID uuid.UUID, channel, chatID string) *Result {
|
||||
fileName, _ := args["file_name"].(string)
|
||||
if fileName == "" {
|
||||
return ErrorResult("file_name is required for action=comment")
|
||||
}
|
||||
|
||||
text, _ := args["text"].(string)
|
||||
if text == "" {
|
||||
return ErrorResult("text is required for action=comment")
|
||||
}
|
||||
const maxCommentLength = 10000
|
||||
if len(text) > maxCommentLength {
|
||||
return ErrorResult(fmt.Sprintf("comment exceeds max length (%d chars)", maxCommentLength))
|
||||
}
|
||||
|
||||
file, err := t.manager.teamStore.GetWorkspaceFile(ctx, team.ID, channel, chatID, fileName)
|
||||
if err != nil {
|
||||
return ErrorResult(err.Error())
|
||||
}
|
||||
|
||||
if err := t.manager.teamStore.AddFileComment(ctx, &store.TeamWorkspaceCommentData{
|
||||
FileID: file.ID,
|
||||
AgentID: agentID,
|
||||
Content: text,
|
||||
}); err != nil {
|
||||
return ErrorResult("failed to add comment: " + err.Error())
|
||||
}
|
||||
|
||||
return NewResult(fmt.Sprintf("Comment added to %q", fileName))
|
||||
}
|
||||
|
||||
func (t *WorkspaceReadTool) executeComments(ctx context.Context, args map[string]any, team *store.TeamData, channel, chatID string) *Result {
|
||||
fileName, _ := args["file_name"].(string)
|
||||
if fileName == "" {
|
||||
return ErrorResult("file_name is required for action=comments")
|
||||
}
|
||||
|
||||
file, err := t.manager.teamStore.GetWorkspaceFile(ctx, team.ID, channel, chatID, fileName)
|
||||
if err != nil {
|
||||
return ErrorResult(err.Error())
|
||||
}
|
||||
|
||||
comments, err := t.manager.teamStore.ListFileComments(ctx, file.ID)
|
||||
if err != nil {
|
||||
return ErrorResult("failed to list comments: " + err.Error())
|
||||
}
|
||||
|
||||
if len(comments) == 0 {
|
||||
return NewResult(fmt.Sprintf("No comments on %q", fileName))
|
||||
}
|
||||
|
||||
var lines []string
|
||||
lines = append(lines, fmt.Sprintf("Comments on %q:", fileName))
|
||||
for _, c := range comments {
|
||||
lines = append(lines, fmt.Sprintf(" [%s] %s: %s", c.CreatedAt.Format("2006-01-02 15:04"), c.AgentKey, c.Content))
|
||||
}
|
||||
return NewResult(strings.Join(lines, "\n"))
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
||||
)
|
||||
|
||||
const (
|
||||
maxFileSizeBytes = 10 * 1024 * 1024 // 10MB
|
||||
maxFilesPerScope = 100
|
||||
maxBatchSize = 20
|
||||
maxVersionsPerFile = 5
|
||||
)
|
||||
|
||||
// WorkspaceWriteTool allows agents to write files to the team shared workspace.
|
||||
type WorkspaceWriteTool struct {
|
||||
manager *TeamToolManager
|
||||
dataDir string
|
||||
}
|
||||
|
||||
func NewWorkspaceWriteTool(manager *TeamToolManager, dataDir string) *WorkspaceWriteTool {
|
||||
return &WorkspaceWriteTool{manager: manager, dataDir: dataDir}
|
||||
}
|
||||
|
||||
func (t *WorkspaceWriteTool) Name() string { return "workspace_write" }
|
||||
|
||||
func (t *WorkspaceWriteTool) Description() string {
|
||||
return "Write files to the team shared workspace (visible to all members). Supports batch write and template management (lead only)."
|
||||
}
|
||||
|
||||
func (t *WorkspaceWriteTool) Parameters() map[string]any {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"action": map[string]any{
|
||||
"type": "string",
|
||||
"description": "'write' (default) or 'set_template' (lead only)",
|
||||
},
|
||||
"file_name": map[string]any{
|
||||
"type": "string",
|
||||
"description": "File name (alphanumeric + hyphens/underscores/dots, max 255 chars)",
|
||||
},
|
||||
"content": map[string]any{
|
||||
"type": "string",
|
||||
"description": "File content (text)",
|
||||
},
|
||||
"files": map[string]any{
|
||||
"type": "array",
|
||||
"description": "Batch write: array of {file_name, content} objects (max 20)",
|
||||
"items": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"file_name": map[string]any{"type": "string"},
|
||||
"content": map[string]any{"type": "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"scope": map[string]any{
|
||||
"type": "string",
|
||||
"description": "'channel' (default, per-user) or 'team' (shared, requires workspace_scope=shared)",
|
||||
},
|
||||
"task_id": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Link file to a team task ID (optional)",
|
||||
},
|
||||
"templates": map[string]any{
|
||||
"type": "array",
|
||||
"description": "For action=set_template: array of {file_name, content}",
|
||||
"items": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"file_name": map[string]any{"type": "string"},
|
||||
"content": map[string]any{"type": "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type writeFileEntry struct {
|
||||
FileName string `json:"file_name"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
func (t *WorkspaceWriteTool) Execute(ctx context.Context, args map[string]any) *Result {
|
||||
action, _ := args["action"].(string)
|
||||
if action == "" {
|
||||
action = "write"
|
||||
}
|
||||
|
||||
team, agentID, err := t.manager.resolveTeam(ctx)
|
||||
if err != nil {
|
||||
return ErrorResult(err.Error())
|
||||
}
|
||||
|
||||
role, err := t.manager.resolveTeamRole(ctx, team, agentID)
|
||||
if err != nil {
|
||||
return ErrorResult(err.Error())
|
||||
}
|
||||
|
||||
ws := parseWorkspaceSettings(team.Settings)
|
||||
|
||||
switch action {
|
||||
case "set_template":
|
||||
return t.executeSetTemplate(ctx, args, team, role)
|
||||
case "write":
|
||||
return t.executeWrite(ctx, args, team, agentID, role, ws)
|
||||
default:
|
||||
return ErrorResult(fmt.Sprintf("unknown action %q (use 'write' or 'set_template')", action))
|
||||
}
|
||||
}
|
||||
|
||||
func (t *WorkspaceWriteTool) executeSetTemplate(ctx context.Context, args map[string]any, team *store.TeamData, role string) *Result {
|
||||
if role != store.TeamRoleLead {
|
||||
return ErrorResult("only the team lead can set workspace templates")
|
||||
}
|
||||
|
||||
// Check escalation policy.
|
||||
if esc := t.manager.checkEscalation(team, "set_template"); esc != EscalationNone {
|
||||
if esc == EscalationReject {
|
||||
return ErrorResult("set_template action is not allowed by team escalation policy")
|
||||
}
|
||||
agentID := store.AgentIDFromContext(ctx)
|
||||
return t.manager.createEscalationTask(ctx, team, agentID,
|
||||
"Set workspace templates",
|
||||
"Agent requested to update workspace templates.")
|
||||
}
|
||||
|
||||
templatesRaw, ok := args["templates"]
|
||||
if !ok {
|
||||
return ErrorResult("templates parameter is required for action=set_template")
|
||||
}
|
||||
templatesJSON, err := json.Marshal(templatesRaw)
|
||||
if err != nil {
|
||||
return ErrorResult("invalid templates format")
|
||||
}
|
||||
var templates []writeFileEntry
|
||||
if err := json.Unmarshal(templatesJSON, &templates); err != nil {
|
||||
return ErrorResult("templates must be array of {file_name, content}")
|
||||
}
|
||||
|
||||
// Validate template file names.
|
||||
for _, tmpl := range templates {
|
||||
if _, err := sanitizeFileName(tmpl.FileName); err != nil {
|
||||
return ErrorResult(fmt.Sprintf("template %q: %s", tmpl.FileName, err))
|
||||
}
|
||||
}
|
||||
|
||||
// Update team settings with templates.
|
||||
var settings map[string]any
|
||||
if team.Settings != nil {
|
||||
_ = json.Unmarshal(team.Settings, &settings)
|
||||
}
|
||||
if settings == nil {
|
||||
settings = make(map[string]any)
|
||||
}
|
||||
settings["workspace_templates"] = templates
|
||||
settingsJSON, _ := json.Marshal(settings)
|
||||
|
||||
if err := t.manager.teamStore.UpdateTeam(ctx, team.ID, map[string]any{"settings": settingsJSON}); err != nil {
|
||||
return ErrorResult("failed to save templates: " + err.Error())
|
||||
}
|
||||
t.manager.InvalidateTeam()
|
||||
|
||||
return NewResult(fmt.Sprintf("Set %d workspace template(s)", len(templates)))
|
||||
}
|
||||
|
||||
func (t *WorkspaceWriteTool) executeWrite(ctx context.Context, args map[string]any, team *store.TeamData, agentID uuid.UUID, role string, ws workspaceSettings) *Result {
|
||||
if role == store.TeamRoleReviewer {
|
||||
return ErrorResult("reviewers cannot write to the workspace")
|
||||
}
|
||||
|
||||
// Resolve scope.
|
||||
channel, chatID, scopeErr := resolveWorkspaceScopeFromArgs(ctx, args, ws)
|
||||
if scopeErr != "" {
|
||||
return ErrorResult(scopeErr)
|
||||
}
|
||||
|
||||
// Optional task linkage.
|
||||
var taskID *uuid.UUID
|
||||
if tid, ok := args["task_id"].(string); ok && tid != "" {
|
||||
parsed, err := uuid.Parse(tid)
|
||||
if err != nil {
|
||||
return ErrorResult("invalid task_id: " + err.Error())
|
||||
}
|
||||
taskID = &parsed
|
||||
}
|
||||
|
||||
// Normalize input to batch.
|
||||
var entries []writeFileEntry
|
||||
if filesRaw, ok := args["files"]; ok {
|
||||
filesJSON, err := json.Marshal(filesRaw)
|
||||
if err != nil {
|
||||
return ErrorResult("invalid files format")
|
||||
}
|
||||
if err := json.Unmarshal(filesJSON, &entries); err != nil {
|
||||
return ErrorResult("files must be array of {file_name, content}")
|
||||
}
|
||||
} else {
|
||||
fn, _ := args["file_name"].(string)
|
||||
content, _ := args["content"].(string)
|
||||
if fn == "" {
|
||||
return ErrorResult("file_name is required")
|
||||
}
|
||||
entries = []writeFileEntry{{FileName: fn, Content: content}}
|
||||
}
|
||||
|
||||
if len(entries) == 0 {
|
||||
return ErrorResult("no files to write")
|
||||
}
|
||||
if len(entries) > maxBatchSize {
|
||||
return ErrorResult(fmt.Sprintf("batch size exceeds limit (%d max)", maxBatchSize))
|
||||
}
|
||||
|
||||
// Validate all entries before writing.
|
||||
totalNewBytes := int64(0)
|
||||
for i, e := range entries {
|
||||
name, err := sanitizeFileName(e.FileName)
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("file %d: %s", i+1, err))
|
||||
}
|
||||
entries[i].FileName = name
|
||||
|
||||
if _, err := inferMimeType(name); err != nil {
|
||||
return ErrorResult(fmt.Sprintf("file %q: %s", name, err))
|
||||
}
|
||||
if len(e.Content) > maxFileSizeBytes {
|
||||
return ErrorResult(fmt.Sprintf("file %q exceeds max size (10MB)", name))
|
||||
}
|
||||
totalNewBytes += int64(len(e.Content))
|
||||
}
|
||||
|
||||
// Check file count limit.
|
||||
count, err := t.manager.teamStore.CountWorkspaceFiles(ctx, team.ID, channel, chatID)
|
||||
if err != nil {
|
||||
return ErrorResult("failed to count workspace files: " + err.Error())
|
||||
}
|
||||
|
||||
// Auto-seed templates on first write to this scope.
|
||||
if count == 0 {
|
||||
t.seedTemplates(ctx, team, agentID, channel, chatID, ws)
|
||||
// Recount after seeding.
|
||||
count, _ = t.manager.teamStore.CountWorkspaceFiles(ctx, team.ID, channel, chatID)
|
||||
}
|
||||
|
||||
if count+len(entries) > maxFilesPerScope {
|
||||
return ErrorResult(fmt.Sprintf("workspace file limit reached (%d/%d)", count, maxFilesPerScope))
|
||||
}
|
||||
|
||||
// Check quota.
|
||||
quotaMB := ws.quotaMB(defaultQuotaMB)
|
||||
if quotaMB > 0 {
|
||||
totalSize, err := t.manager.teamStore.GetWorkspaceTotalSize(ctx, team.ID)
|
||||
if err == nil && totalSize+totalNewBytes > int64(quotaMB)*1024*1024 {
|
||||
usedMB := float64(totalSize) / (1024 * 1024)
|
||||
return ErrorResult(fmt.Sprintf("team workspace quota exceeded (used %.1f MB / %d MB limit)", usedMB, quotaMB))
|
||||
}
|
||||
}
|
||||
|
||||
// Write files.
|
||||
dir, err := workspaceDir(t.dataDir, team.ID, channel, chatID)
|
||||
if err != nil {
|
||||
return ErrorResult(err.Error())
|
||||
}
|
||||
|
||||
var results []string
|
||||
var errors []string
|
||||
for _, e := range entries {
|
||||
mimeType, _ := inferMimeType(e.FileName)
|
||||
diskPath := filepath.Join(dir, e.FileName)
|
||||
|
||||
// Upsert DB metadata with disk I/O inside advisory lock.
|
||||
// diskWriteFn runs after lock is acquired — prevents concurrent writes to same file.
|
||||
file := &store.TeamWorkspaceFileData{
|
||||
TeamID: team.ID,
|
||||
Channel: channel,
|
||||
ChatID: chatID,
|
||||
FileName: e.FileName,
|
||||
MimeType: mimeType,
|
||||
FilePath: diskPath,
|
||||
SizeBytes: int64(len(e.Content)),
|
||||
UploadedBy: agentID,
|
||||
TaskID: taskID,
|
||||
}
|
||||
diskWriteFn := func(isNew bool) error {
|
||||
// Version existing file before overwrite.
|
||||
if !isNew {
|
||||
existing, _ := t.manager.teamStore.GetWorkspaceFile(ctx, team.ID, channel, chatID, e.FileName)
|
||||
if existing != nil {
|
||||
versions, _ := t.manager.teamStore.ListFileVersions(ctx, existing.ID)
|
||||
nextVersion := 1
|
||||
if len(versions) > 0 {
|
||||
nextVersion = versions[0].Version + 1
|
||||
}
|
||||
versionPath := fmt.Sprintf("%s.v%d", diskPath, nextVersion)
|
||||
if _, statErr := os.Stat(diskPath); statErr == nil {
|
||||
_ = os.Rename(diskPath, versionPath)
|
||||
_ = t.manager.teamStore.CreateFileVersion(ctx, &store.TeamWorkspaceFileVersionData{
|
||||
FileID: existing.ID,
|
||||
Version: nextVersion,
|
||||
FilePath: versionPath,
|
||||
SizeBytes: existing.SizeBytes,
|
||||
UploadedBy: existing.UploadedBy,
|
||||
})
|
||||
prunedPaths, _ := t.manager.teamStore.PruneOldVersions(ctx, existing.ID, maxVersionsPerFile)
|
||||
for _, p := range prunedPaths {
|
||||
_ = os.Remove(p)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return os.WriteFile(diskPath, []byte(e.Content), 0640)
|
||||
}
|
||||
|
||||
_, err := t.manager.teamStore.UpsertWorkspaceFile(ctx, file, diskWriteFn)
|
||||
if err != nil {
|
||||
errors = append(errors, fmt.Sprintf("%s: %s", e.FileName, err))
|
||||
continue
|
||||
}
|
||||
|
||||
results = append(results, fmt.Sprintf("%s (%s)", e.FileName, formatBytes(int64(len(e.Content)))))
|
||||
|
||||
// Broadcast event.
|
||||
t.manager.broadcastTeamEvent(protocol.EventWorkspaceFileChanged, map[string]string{
|
||||
"team_id": team.ID.String(),
|
||||
"channel": channel,
|
||||
"chat_id": chatID,
|
||||
"file_name": e.FileName,
|
||||
"action": "write",
|
||||
})
|
||||
}
|
||||
|
||||
if len(results) == 0 && len(errors) > 0 {
|
||||
return ErrorResult("all writes failed:\n" + strings.Join(errors, "\n"))
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("Written %d file(s) to workspace: %s", len(results), strings.Join(results, ", "))
|
||||
if len(errors) > 0 {
|
||||
msg += fmt.Sprintf("\n%d failed: %s", len(errors), strings.Join(errors, "; "))
|
||||
}
|
||||
return NewResult(msg)
|
||||
}
|
||||
|
||||
func (t *WorkspaceWriteTool) seedTemplates(ctx context.Context, team *store.TeamData, agentID uuid.UUID, channel, chatID string, ws workspaceSettings) {
|
||||
if len(ws.WorkspaceTemplates) == 0 {
|
||||
return
|
||||
}
|
||||
templates := ws.WorkspaceTemplates
|
||||
|
||||
dir, err := workspaceDir(t.dataDir, team.ID, channel, chatID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, tmpl := range templates {
|
||||
name, err := sanitizeFileName(tmpl.FileName)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
mimeType, _ := inferMimeType(name)
|
||||
diskPath := filepath.Join(dir, name)
|
||||
|
||||
file := &store.TeamWorkspaceFileData{
|
||||
TeamID: team.ID,
|
||||
Channel: channel,
|
||||
ChatID: chatID,
|
||||
FileName: name,
|
||||
MimeType: mimeType,
|
||||
FilePath: diskPath,
|
||||
SizeBytes: int64(len(tmpl.Content)),
|
||||
UploadedBy: agentID,
|
||||
Tags: []string{"reference"},
|
||||
}
|
||||
content := tmpl.Content
|
||||
if _, err := t.manager.teamStore.UpsertWorkspaceFile(ctx, file, func(_ bool) error {
|
||||
return os.WriteFile(diskPath, []byte(content), 0640)
|
||||
}); err != nil {
|
||||
slog.Warn("workspace: template seed failed", "file", name, "error", err)
|
||||
}
|
||||
}
|
||||
slog.Info("workspace: seeded templates", "count", len(templates), "team", team.ID, "channel", channel, "chat_id", chatID)
|
||||
}
|
||||
|
||||
func formatBytes(b int64) string {
|
||||
switch {
|
||||
case b >= 1024*1024:
|
||||
return fmt.Sprintf("%.1f MB", float64(b)/(1024*1024))
|
||||
case b >= 1024:
|
||||
return fmt.Sprintf("%.1f KB", float64(b)/1024)
|
||||
default:
|
||||
return fmt.Sprintf("%d B", b)
|
||||
}
|
||||
}
|
||||
@@ -2,4 +2,4 @@ package upgrade
|
||||
|
||||
// RequiredSchemaVersion is the schema migration version this binary requires.
|
||||
// Bump this whenever adding a new SQL migration file.
|
||||
const RequiredSchemaVersion uint = 17
|
||||
const RequiredSchemaVersion uint = 18
|
||||
@@ -0,0 +1,40 @@
|
||||
-- Part 5: handoff team_id
|
||||
DROP INDEX IF EXISTS idx_hr_team;
|
||||
ALTER TABLE handoff_routes DROP COLUMN IF EXISTS team_id;
|
||||
|
||||
-- Part 3: followup
|
||||
DROP INDEX IF EXISTS idx_tt_followup;
|
||||
ALTER TABLE team_tasks DROP COLUMN IF EXISTS followup_chat_id;
|
||||
ALTER TABLE team_tasks DROP COLUMN IF EXISTS followup_channel;
|
||||
ALTER TABLE team_tasks DROP COLUMN IF EXISTS followup_message;
|
||||
ALTER TABLE team_tasks DROP COLUMN IF EXISTS followup_max;
|
||||
ALTER TABLE team_tasks DROP COLUMN IF EXISTS followup_count;
|
||||
ALTER TABLE team_tasks DROP COLUMN IF EXISTS followup_at;
|
||||
|
||||
-- Part 2: team tasks v2
|
||||
DROP TABLE IF EXISTS team_task_attachments;
|
||||
DROP TABLE IF EXISTS team_task_events;
|
||||
DROP TABLE IF EXISTS team_task_comments;
|
||||
|
||||
DROP INDEX IF EXISTS idx_tt_parent;
|
||||
DROP INDEX IF EXISTS idx_tt_scope;
|
||||
DROP INDEX IF EXISTS idx_tt_type;
|
||||
DROP INDEX IF EXISTS idx_tt_lock;
|
||||
DROP INDEX IF EXISTS idx_tt_identifier;
|
||||
|
||||
ALTER TABLE team_tasks DROP COLUMN IF EXISTS task_type;
|
||||
ALTER TABLE team_tasks DROP COLUMN IF EXISTS task_number;
|
||||
ALTER TABLE team_tasks DROP COLUMN IF EXISTS identifier;
|
||||
ALTER TABLE team_tasks DROP COLUMN IF EXISTS created_by_agent_id;
|
||||
ALTER TABLE team_tasks DROP COLUMN IF EXISTS assignee_user_id;
|
||||
ALTER TABLE team_tasks DROP COLUMN IF EXISTS parent_id;
|
||||
ALTER TABLE team_tasks DROP COLUMN IF EXISTS chat_id;
|
||||
ALTER TABLE team_tasks DROP COLUMN IF EXISTS locked_at;
|
||||
ALTER TABLE team_tasks DROP COLUMN IF EXISTS lock_expires_at;
|
||||
ALTER TABLE team_tasks DROP COLUMN IF EXISTS progress_percent;
|
||||
ALTER TABLE team_tasks DROP COLUMN IF EXISTS progress_step;
|
||||
|
||||
-- Part 1: workspace
|
||||
DROP TABLE IF EXISTS team_workspace_comments;
|
||||
DROP TABLE IF EXISTS team_workspace_file_versions;
|
||||
DROP TABLE IF EXISTS team_workspace_files;
|
||||
@@ -0,0 +1,169 @@
|
||||
-- ============================================================
|
||||
-- Part 1: Team workspace (shared file storage)
|
||||
-- ============================================================
|
||||
|
||||
-- Team workspace: shared file storage scoped by (team, chat_id).
|
||||
-- chat_id stores the system-derived userID (stable across WS reconnects).
|
||||
CREATE TABLE team_workspace_files (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
|
||||
team_id UUID NOT NULL REFERENCES agent_teams(id) ON DELETE CASCADE,
|
||||
channel VARCHAR(50) NOT NULL DEFAULT '',
|
||||
chat_id VARCHAR(255) NOT NULL DEFAULT '',
|
||||
file_name VARCHAR(255) NOT NULL,
|
||||
mime_type VARCHAR(100),
|
||||
file_path TEXT NOT NULL,
|
||||
size_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
uploaded_by UUID NOT NULL REFERENCES agents(id),
|
||||
task_id UUID REFERENCES team_tasks(id) ON DELETE SET NULL,
|
||||
pinned BOOLEAN NOT NULL DEFAULT false,
|
||||
tags TEXT[] NOT NULL DEFAULT '{}',
|
||||
metadata JSONB DEFAULT '{}',
|
||||
archived_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE(team_id, chat_id, file_name)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_twf_team_scope ON team_workspace_files(team_id, chat_id);
|
||||
CREATE INDEX idx_twf_uploaded_by ON team_workspace_files(uploaded_by);
|
||||
CREATE INDEX idx_twf_task ON team_workspace_files(task_id) WHERE task_id IS NOT NULL;
|
||||
CREATE INDEX idx_twf_archived ON team_workspace_files(archived_at) WHERE archived_at IS NOT NULL;
|
||||
CREATE INDEX idx_twf_pinned ON team_workspace_files(team_id, pinned) WHERE pinned = true;
|
||||
CREATE INDEX idx_twf_tags ON team_workspace_files USING GIN(tags);
|
||||
|
||||
-- File version history.
|
||||
CREATE TABLE team_workspace_file_versions (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
|
||||
file_id UUID NOT NULL REFERENCES team_workspace_files(id) ON DELETE CASCADE,
|
||||
version INT NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
size_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
uploaded_by UUID NOT NULL REFERENCES agents(id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE(file_id, version)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_twfv_file ON team_workspace_file_versions(file_id);
|
||||
|
||||
-- File comments / annotations.
|
||||
CREATE TABLE team_workspace_comments (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
|
||||
file_id UUID NOT NULL REFERENCES team_workspace_files(id) ON DELETE CASCADE,
|
||||
agent_id UUID NOT NULL REFERENCES agents(id),
|
||||
content TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_twfc_file ON team_workspace_comments(file_id);
|
||||
|
||||
-- ============================================================
|
||||
-- Part 2: Team tasks v2 (locking, progress, audit, comments)
|
||||
-- ============================================================
|
||||
|
||||
-- New columns on team_tasks
|
||||
ALTER TABLE team_tasks ADD COLUMN task_type VARCHAR(30) NOT NULL DEFAULT 'general';
|
||||
ALTER TABLE team_tasks ADD COLUMN task_number INT NOT NULL DEFAULT 0;
|
||||
ALTER TABLE team_tasks ADD COLUMN identifier VARCHAR(20);
|
||||
ALTER TABLE team_tasks ADD COLUMN created_by_agent_id UUID REFERENCES agents(id);
|
||||
ALTER TABLE team_tasks ADD COLUMN assignee_user_id VARCHAR(255);
|
||||
ALTER TABLE team_tasks ADD COLUMN parent_id UUID REFERENCES team_tasks(id) ON DELETE SET NULL;
|
||||
ALTER TABLE team_tasks ADD COLUMN chat_id VARCHAR(255) DEFAULT '';
|
||||
ALTER TABLE team_tasks ADD COLUMN locked_at TIMESTAMPTZ;
|
||||
ALTER TABLE team_tasks ADD COLUMN lock_expires_at TIMESTAMPTZ;
|
||||
ALTER TABLE team_tasks ADD COLUMN progress_percent INT DEFAULT 0 CHECK (progress_percent BETWEEN 0 AND 100);
|
||||
ALTER TABLE team_tasks ADD COLUMN progress_step TEXT;
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX idx_tt_parent ON team_tasks(parent_id) WHERE parent_id IS NOT NULL;
|
||||
CREATE INDEX idx_tt_scope ON team_tasks(team_id, channel, chat_id);
|
||||
CREATE INDEX idx_tt_type ON team_tasks(team_id, task_type);
|
||||
CREATE INDEX idx_tt_lock ON team_tasks(lock_expires_at) WHERE lock_expires_at IS NOT NULL AND status = 'in_progress';
|
||||
CREATE UNIQUE INDEX idx_tt_identifier ON team_tasks(team_id, identifier) WHERE identifier IS NOT NULL;
|
||||
|
||||
-- Task comments
|
||||
CREATE TABLE team_task_comments (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
|
||||
task_id UUID NOT NULL REFERENCES team_tasks(id) ON DELETE CASCADE,
|
||||
agent_id UUID REFERENCES agents(id),
|
||||
user_id VARCHAR(255),
|
||||
content TEXT NOT NULL,
|
||||
metadata JSONB DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX idx_ttc_task ON team_task_comments(task_id);
|
||||
|
||||
-- Audit history
|
||||
CREATE TABLE team_task_events (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
|
||||
task_id UUID NOT NULL REFERENCES team_tasks(id) ON DELETE CASCADE,
|
||||
event_type VARCHAR(30) NOT NULL,
|
||||
actor_type VARCHAR(10) NOT NULL,
|
||||
actor_id VARCHAR(255) NOT NULL,
|
||||
data JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX idx_tte_task ON team_task_events(task_id);
|
||||
|
||||
-- Task-workspace attachments
|
||||
CREATE TABLE team_task_attachments (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
|
||||
task_id UUID NOT NULL REFERENCES team_tasks(id) ON DELETE CASCADE,
|
||||
file_id UUID NOT NULL REFERENCES team_workspace_files(id) ON DELETE CASCADE,
|
||||
added_by UUID REFERENCES agents(id),
|
||||
metadata JSONB DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE(task_id, file_id)
|
||||
);
|
||||
CREATE INDEX idx_tta_task ON team_task_attachments(task_id);
|
||||
|
||||
-- Backfill task_number (per-team sequential) and identifiers for existing tasks
|
||||
DO $$
|
||||
DECLARE
|
||||
r RECORD;
|
||||
seq INT;
|
||||
prev_team UUID := '00000000-0000-0000-0000-000000000000';
|
||||
BEGIN
|
||||
FOR r IN
|
||||
SELECT t.id, t.team_id,
|
||||
UPPER(LEFT(COALESCE(tm.name, 'TSK'), 3)) AS team_prefix
|
||||
FROM team_tasks t
|
||||
JOIN agent_teams tm ON tm.id = t.team_id
|
||||
WHERE t.identifier IS NULL
|
||||
ORDER BY t.team_id, t.created_at
|
||||
LOOP
|
||||
IF r.team_id != prev_team THEN
|
||||
seq := 0;
|
||||
prev_team := r.team_id;
|
||||
END IF;
|
||||
seq := seq + 1;
|
||||
UPDATE team_tasks SET task_number = seq, identifier = r.team_prefix || '-' || seq WHERE id = r.id;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
-- ============================================================
|
||||
-- Part 3: Task followup reminders
|
||||
-- ============================================================
|
||||
|
||||
ALTER TABLE team_tasks ADD COLUMN followup_at TIMESTAMPTZ;
|
||||
ALTER TABLE team_tasks ADD COLUMN followup_count INT NOT NULL DEFAULT 0;
|
||||
ALTER TABLE team_tasks ADD COLUMN followup_max INT NOT NULL DEFAULT 0;
|
||||
ALTER TABLE team_tasks ADD COLUMN followup_message TEXT;
|
||||
ALTER TABLE team_tasks ADD COLUMN followup_channel VARCHAR(60);
|
||||
ALTER TABLE team_tasks ADD COLUMN followup_chat_id VARCHAR(255);
|
||||
|
||||
CREATE INDEX idx_tt_followup ON team_tasks(followup_at)
|
||||
WHERE followup_at IS NOT NULL AND status = 'in_progress';
|
||||
|
||||
-- ============================================================
|
||||
-- Part 4: Fix blocked_by DEFAULT (was NULL, should be empty array)
|
||||
-- ============================================================
|
||||
|
||||
ALTER TABLE team_tasks ALTER COLUMN blocked_by SET DEFAULT '{}'::uuid[];
|
||||
UPDATE team_tasks SET blocked_by = '{}' WHERE blocked_by IS NULL;
|
||||
|
||||
-- ============================================================
|
||||
-- Part 5: Add team_id to handoff_routes
|
||||
-- ============================================================
|
||||
|
||||
ALTER TABLE handoff_routes ADD COLUMN team_id UUID REFERENCES agent_teams(id) ON DELETE SET NULL;
|
||||
CREATE INDEX idx_hr_team ON handoff_routes(team_id) WHERE team_id IS NOT NULL;
|
||||
@@ -43,8 +43,12 @@ const (
|
||||
// Team task lifecycle events.
|
||||
EventTeamTaskClaimed = "team.task.claimed"
|
||||
EventTeamTaskCancelled = "team.task.cancelled"
|
||||
EventTeamTaskReviewed = "team.task.reviewed"
|
||||
EventTeamTaskApproved = "team.task.approved"
|
||||
EventTeamTaskRejected = "team.task.rejected"
|
||||
EventTeamTaskProgress = "team.task.progress"
|
||||
EventTeamTaskCommented = "team.task.commented"
|
||||
EventTeamTaskAssigned = "team.task.assigned"
|
||||
|
||||
// Team CRUD events (admin operations).
|
||||
EventTeamCreated = "team.created"
|
||||
@@ -53,6 +57,9 @@ const (
|
||||
EventTeamMemberAdded = "team.member.added"
|
||||
EventTeamMemberRemoved = "team.member.removed"
|
||||
|
||||
// Workspace events (team file changes).
|
||||
EventWorkspaceFileChanged = "workspace.file.changed"
|
||||
|
||||
// Agent link events (admin operations).
|
||||
EventAgentLinkCreated = "agent_link.created"
|
||||
EventAgentLinkUpdated = "agent_link.updated"
|
||||
|
||||
@@ -107,12 +107,26 @@ const (
|
||||
MethodTeamsGet = "teams.get"
|
||||
MethodTeamsDelete = "teams.delete"
|
||||
MethodTeamsTaskList = "teams.tasks.list"
|
||||
MethodTeamsTaskGet = "teams.tasks.get"
|
||||
MethodTeamsTaskApprove = "teams.tasks.approve"
|
||||
MethodTeamsTaskReject = "teams.tasks.reject"
|
||||
MethodTeamsTaskComment = "teams.tasks.comment"
|
||||
MethodTeamsTaskComments = "teams.tasks.comments"
|
||||
MethodTeamsTaskEvents = "teams.tasks.events"
|
||||
MethodTeamsTaskCreate = "teams.tasks.create"
|
||||
MethodTeamsTaskAssign = "teams.tasks.assign"
|
||||
MethodTeamsMembersAdd = "teams.members.add"
|
||||
MethodTeamsMembersRemove = "teams.members.remove"
|
||||
MethodTeamsUpdate = "teams.update"
|
||||
MethodTeamsKnownUsers = "teams.known_users"
|
||||
MethodTeamsScopes = "teams.scopes"
|
||||
)
|
||||
|
||||
// Team workspace
|
||||
const (
|
||||
MethodTeamsWorkspaceList = "teams.workspace.list"
|
||||
MethodTeamsWorkspaceRead = "teams.workspace.read"
|
||||
MethodTeamsWorkspaceDelete = "teams.workspace.delete"
|
||||
)
|
||||
|
||||
// Delegation history
|
||||
|
||||
@@ -132,12 +132,22 @@ export const Methods = {
|
||||
TEAMS_GET: "teams.get",
|
||||
TEAMS_DELETE: "teams.delete",
|
||||
TEAMS_TASK_LIST: "teams.tasks.list",
|
||||
TEAMS_TASK_GET: "teams.tasks.get",
|
||||
TEAMS_TASK_APPROVE: "teams.tasks.approve",
|
||||
TEAMS_TASK_REJECT: "teams.tasks.reject",
|
||||
TEAMS_TASK_COMMENT: "teams.tasks.comment",
|
||||
TEAMS_TASK_COMMENTS: "teams.tasks.comments",
|
||||
TEAMS_TASK_EVENTS: "teams.tasks.events",
|
||||
TEAMS_TASK_CREATE: "teams.tasks.create",
|
||||
TEAMS_TASK_ASSIGN: "teams.tasks.assign",
|
||||
TEAMS_MEMBERS_ADD: "teams.members.add",
|
||||
TEAMS_MEMBERS_REMOVE: "teams.members.remove",
|
||||
TEAMS_UPDATE: "teams.update",
|
||||
TEAMS_KNOWN_USERS: "teams.known_users",
|
||||
TEAMS_SCOPES: "teams.scopes",
|
||||
TEAMS_WORKSPACE_LIST: "teams.workspace.list",
|
||||
TEAMS_WORKSPACE_READ: "teams.workspace.read",
|
||||
TEAMS_WORKSPACE_DELETE: "teams.workspace.delete",
|
||||
|
||||
// Delegation history
|
||||
DELEGATIONS_LIST: "delegations.list",
|
||||
@@ -183,6 +193,12 @@ export const Events = {
|
||||
TEAM_TASK_CLAIMED: "team.task.claimed",
|
||||
TEAM_TASK_COMPLETED: "team.task.completed",
|
||||
TEAM_TASK_CANCELLED: "team.task.cancelled",
|
||||
TEAM_TASK_REVIEWED: "team.task.reviewed",
|
||||
TEAM_TASK_APPROVED: "team.task.approved",
|
||||
TEAM_TASK_REJECTED: "team.task.rejected",
|
||||
TEAM_TASK_PROGRESS: "team.task.progress",
|
||||
TEAM_TASK_COMMENTED: "team.task.commented",
|
||||
TEAM_TASK_ASSIGNED: "team.task.assigned",
|
||||
|
||||
// Team messages
|
||||
TEAM_MESSAGE_SENT: "team.message.sent",
|
||||
@@ -194,6 +210,9 @@ export const Events = {
|
||||
TEAM_MEMBER_ADDED: "team.member.added",
|
||||
TEAM_MEMBER_REMOVED: "team.member.removed",
|
||||
|
||||
// Workspace
|
||||
WORKSPACE_FILE_CHANGED: "workspace.file.changed",
|
||||
|
||||
// Agent links
|
||||
AGENT_LINK_CREATED: "agent_link.created",
|
||||
AGENT_LINK_UPDATED: "agent_link.updated",
|
||||
@@ -219,12 +238,16 @@ export const TEAM_RELATED_EVENTS: Set<string> = new Set([
|
||||
Events.DELEGATION_ANNOUNCE, Events.DELEGATION_QUALITY_GATE_RETRY,
|
||||
Events.TEAM_TASK_CREATED, Events.TEAM_TASK_CLAIMED,
|
||||
Events.TEAM_TASK_COMPLETED, Events.TEAM_TASK_CANCELLED,
|
||||
Events.TEAM_TASK_REVIEWED, Events.TEAM_TASK_APPROVED,
|
||||
Events.TEAM_TASK_REJECTED, Events.TEAM_TASK_PROGRESS,
|
||||
Events.TEAM_TASK_COMMENTED, Events.TEAM_TASK_ASSIGNED,
|
||||
Events.TEAM_MESSAGE_SENT,
|
||||
Events.TEAM_CREATED, Events.TEAM_UPDATED, Events.TEAM_DELETED,
|
||||
Events.TEAM_MEMBER_ADDED, Events.TEAM_MEMBER_REMOVED,
|
||||
Events.AGENT_LINK_CREATED, Events.AGENT_LINK_UPDATED,
|
||||
Events.AGENT_LINK_DELETED,
|
||||
Events.AGENT,
|
||||
Events.WORKSPACE_FILE_CHANGED,
|
||||
]);
|
||||
|
||||
// Agent event subtypes (in payload.type)
|
||||
|
||||
@@ -39,7 +39,9 @@ export function ConfirmDeleteDialog({
|
||||
if (!open) setInputValue("");
|
||||
}, [open]);
|
||||
|
||||
const isMatch = inputValue.toLowerCase() === confirmValue.toLowerCase();
|
||||
const isMatch = confirmValue
|
||||
? inputValue.toLowerCase() === confirmValue.toLowerCase()
|
||||
: inputValue.length > 0;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
|
||||
@@ -54,17 +54,28 @@ export function Combobox({
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [open]);
|
||||
|
||||
// Resolve the actual portal target: explicit prop > closest dialog content > document.body
|
||||
const resolvedPortal = React.useMemo(() => {
|
||||
if (portalContainer?.current) return portalContainer.current;
|
||||
// Auto-detect if inside a Radix Dialog (which sets pointer-events:none on body)
|
||||
const el = containerRef.current?.closest<HTMLElement>('[data-slot="dialog-content"]');
|
||||
return el ?? null;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [portalContainer, open]);
|
||||
|
||||
// Compute dropdown position — always use fixed positioning for portal rendering
|
||||
React.useLayoutEffect(() => {
|
||||
if (!open || !containerRef.current) return;
|
||||
const inputRect = containerRef.current.getBoundingClientRect();
|
||||
if (portalContainer?.current) {
|
||||
const portalRect = portalContainer.current.getBoundingClientRect();
|
||||
const left = inputRect.left - portalRect.left;
|
||||
const maxWidth = portalRect.width - left;
|
||||
if (resolvedPortal) {
|
||||
const portalRect = resolvedPortal.getBoundingClientRect();
|
||||
const scrollTop = resolvedPortal.scrollTop || 0;
|
||||
const scrollLeft = resolvedPortal.scrollLeft || 0;
|
||||
const left = inputRect.left - portalRect.left + scrollLeft;
|
||||
const maxWidth = portalRect.width - (inputRect.left - portalRect.left);
|
||||
setDropdownStyle({
|
||||
position: "absolute",
|
||||
top: inputRect.bottom - portalRect.top + 4,
|
||||
top: inputRect.bottom - portalRect.top + scrollTop + 4,
|
||||
left,
|
||||
width: inputRect.width,
|
||||
maxWidth,
|
||||
@@ -79,7 +90,7 @@ export function Combobox({
|
||||
zIndex: 9999,
|
||||
});
|
||||
}
|
||||
}, [open, search, portalContainer]);
|
||||
}, [open, search, resolvedPortal]);
|
||||
|
||||
const filtered = React.useMemo(() => {
|
||||
if (!search) return options;
|
||||
@@ -148,7 +159,7 @@ export function Combobox({
|
||||
)}
|
||||
{dropdownContent && createPortal(
|
||||
dropdownContent,
|
||||
portalContainer?.current ?? document.body,
|
||||
resolvedPortal ?? document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
"emptyDescription": "Create your first team to get started.",
|
||||
"noMatchTitle": "No matching teams",
|
||||
"noMatchDescription": "Try a different search term.",
|
||||
"scope": {
|
||||
"all": "All scopes",
|
||||
"label": "Scope"
|
||||
},
|
||||
"delete": {
|
||||
"title": "Delete Team",
|
||||
"description": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
|
||||
@@ -21,6 +25,7 @@
|
||||
"tasks": "Tasks",
|
||||
"members": "Members",
|
||||
"delegations": "Delegations",
|
||||
"workspace": "Workspace",
|
||||
"settings": "Settings"
|
||||
}
|
||||
},
|
||||
@@ -49,7 +54,22 @@
|
||||
"noTasks": "No tasks yet",
|
||||
"noTasksDescription": "Tasks are created by team agents during collaboration.",
|
||||
"refresh": "Refresh",
|
||||
"createTask": "Create Task",
|
||||
"createTaskTitle": "Create Task",
|
||||
"subjectPlaceholder": "Task subject...",
|
||||
"descriptionPlaceholder": "Optional description...",
|
||||
"priorityLabel": "Priority",
|
||||
"assign": "Assign",
|
||||
"assignTo": "Assign to...",
|
||||
"unassigned": "Unassigned",
|
||||
"filters": {
|
||||
"active": "Active",
|
||||
"inReview": "In Review",
|
||||
"completed": "Completed",
|
||||
"all": "All"
|
||||
},
|
||||
"columns": {
|
||||
"id": "ID",
|
||||
"subject": "Subject",
|
||||
"status": "Status",
|
||||
"owner": "Owner",
|
||||
@@ -64,6 +84,7 @@
|
||||
"status": "Status:",
|
||||
"priority": "Priority:",
|
||||
"owner": "Owner:",
|
||||
"type": "Type:",
|
||||
"blockedBy": "Blocked by:",
|
||||
"description": "Description",
|
||||
"result": "Result",
|
||||
@@ -71,29 +92,25 @@
|
||||
"updated": "Updated:",
|
||||
"taskId": "Task ID:",
|
||||
"noDescription": "No description",
|
||||
"noResult": "No result yet"
|
||||
},
|
||||
"actions": {
|
||||
"noResult": "No result yet",
|
||||
"progress": "Progress",
|
||||
"approve": "Approve",
|
||||
"reject": "Reject",
|
||||
"cancel": "Cancel",
|
||||
"reasonPlaceholder": "Reason (optional)...",
|
||||
"confirmCancel": "Cancel this task?",
|
||||
"confirmReject": "Reject this task?"
|
||||
"comments": "Comments",
|
||||
"noComments": "No comments yet",
|
||||
"commentPlaceholder": "Add a comment...",
|
||||
"addComment": "Comment",
|
||||
"timeline": "Timeline",
|
||||
"attachments": "Attachments",
|
||||
"followupStatus": "Awaiting Reply",
|
||||
"followupMessage": "Reminder message:",
|
||||
"followupCount": "Reminders sent: {{count}}",
|
||||
"followupCountMax": "Reminders sent: {{count}}/{{max}}",
|
||||
"followupNext": "Next reminder:",
|
||||
"followupDone": "All reminders sent"
|
||||
},
|
||||
"filter": {
|
||||
"all": "All",
|
||||
"active": "Active",
|
||||
"completed": "Completed",
|
||||
"allUsers": "All users"
|
||||
},
|
||||
"status": {
|
||||
"pending": "pending",
|
||||
"pending_approval": "pending approval",
|
||||
"in_progress": "in progress",
|
||||
"completed": "completed",
|
||||
"blocked": "blocked",
|
||||
"failed": "failed"
|
||||
"badges": {
|
||||
"awaitingReply": "awaiting reply"
|
||||
}
|
||||
},
|
||||
"delegations": {
|
||||
@@ -108,6 +125,25 @@
|
||||
"time": "Time"
|
||||
}
|
||||
},
|
||||
"workspace": {
|
||||
"title": "Workspace",
|
||||
"noFiles": "No workspace files yet",
|
||||
"noFilesDescription": "Files are created by team agents during collaboration.",
|
||||
"refresh": "Refresh",
|
||||
"delete": "Delete",
|
||||
"confirmDelete": "Delete this file?",
|
||||
"columns": {
|
||||
"fileName": "File Name",
|
||||
"size": "Size",
|
||||
"uploadedBy": "Uploaded By",
|
||||
"scope": "Scope",
|
||||
"updated": "Updated"
|
||||
},
|
||||
"detail": {
|
||||
"title": "File Content",
|
||||
"empty": "File is empty or binary"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Team Settings",
|
||||
"name": "Name",
|
||||
@@ -135,7 +171,56 @@
|
||||
"progressNotifications": "Progress notifications",
|
||||
"progressNotificationsHint": "Send periodic \"Your team is working on it...\" messages to chat during async delegations.",
|
||||
"searchUsers": "Search users...",
|
||||
"selectChannel": "Select channel..."
|
||||
"selectChannel": "Select channel...",
|
||||
"escalationPolicy": "Escalation Policy",
|
||||
"escalationMode": "Escalation Mode",
|
||||
"escalationModeHint": "Controls how privileged workspace actions (pin, tag, delete others' files, set_template) are handled when requested by agents.",
|
||||
"escalationModeAuto": "Auto (default)",
|
||||
"escalationModeAutoDesc": "LLM lead evaluates the request — either rejects or creates a review task. Never executes directly.",
|
||||
"escalationModeReview": "Review",
|
||||
"escalationModeReviewDesc": "Always creates a pending task for human approval. No auto-reject.",
|
||||
"escalationModeReject": "Reject",
|
||||
"escalationModeRejectDesc": "Rejects all privileged action requests outright. Lockdown mode.",
|
||||
"escalationModeNone": "None",
|
||||
"escalationModeNoneDesc": "No escalation policy. Lead agent executes actions directly.",
|
||||
"escalationActions": "Escalation Actions",
|
||||
"escalationActionsHint": "Select which workspace actions require escalation. If none selected, all privileged actions are subject to escalation.",
|
||||
"followupReminders": "Follow-up Reminders",
|
||||
"followupInterval": "Reminder interval (minutes)",
|
||||
"followupIntervalHint": "How long to wait before sending each follow-up reminder to the user.",
|
||||
"followupMaxReminders": "Max reminders",
|
||||
"followupMaxRemindersHint": "Maximum number of reminders per task. 0 = unlimited (remind until user replies).",
|
||||
"teamVersion": "Team Version",
|
||||
"versionBasic": "Basic",
|
||||
"versionAdvanced": "Advanced",
|
||||
"versionBasicDesc": "Task management and shared workspace.",
|
||||
"versionAdvancedDesc": "All V1 features plus execution locking, follow-up reminders, review workflow, progress tracking, comments, audit trail, and auto-recovery.",
|
||||
"whatsNew": "What's different?",
|
||||
"versionModal": {
|
||||
"title": "Team Versions",
|
||||
"feature": "Feature",
|
||||
"taskManagement": "Task Management",
|
||||
"taskManagementDesc": "Create, assign, complete, cancel tasks",
|
||||
"sharedWorkspace": "Shared Workspace",
|
||||
"sharedWorkspaceDesc": "Files, versions, team collaboration",
|
||||
"executionLocking": "Execution Locking",
|
||||
"executionLockingDesc": "Prevents task conflicts with auto-recovery",
|
||||
"followupReminders": "Follow-up Reminders",
|
||||
"followupRemindersDesc": "Auto-remind when waiting for user reply",
|
||||
"reviewWorkflow": "Review Workflow",
|
||||
"reviewWorkflowDesc": "Submit for human review before completing",
|
||||
"progressTracking": "Progress Tracking",
|
||||
"progressTrackingDesc": "Real-time percentage and step updates",
|
||||
"commentsAudit": "Comments & Audit",
|
||||
"commentsAuditDesc": "Task comments and full event history",
|
||||
"autoRecovery": "Auto-Recovery",
|
||||
"autoRecoveryDesc": "Stuck tasks auto-reset after timeout",
|
||||
"escalationPolicy": "Escalation Policy",
|
||||
"escalationPolicyDesc": "Control what agents can do autonomously",
|
||||
"downgradeNote": "Downgrade is safe — V2 data is preserved but hidden.",
|
||||
"betaNote": "V2 is currently in Beta.",
|
||||
"gotIt": "Got it"
|
||||
}
|
||||
},
|
||||
"create": {
|
||||
"title": "Create Team",
|
||||
@@ -168,11 +253,9 @@
|
||||
"failedUpdate": "Failed to update team",
|
||||
"failedAddMember": "Failed to add member",
|
||||
"failedRemoveMember": "Failed to remove member",
|
||||
"taskApproved": "Task approved",
|
||||
"taskRejected": "Task rejected",
|
||||
"taskCancelled": "Task cancelled",
|
||||
"failedApproveTask": "Failed to approve task",
|
||||
"failedRejectTask": "Failed to reject task",
|
||||
"failedCancelTask": "Failed to cancel task"
|
||||
"taskCreated": "Task created",
|
||||
"taskAssigned": "Task assigned",
|
||||
"failedCreateTask": "Failed to create task",
|
||||
"failedAssignTask": "Failed to assign task"
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,10 @@
|
||||
"emptyDescription": "Tạo Team đầu tiên để bắt đầu.",
|
||||
"noMatchTitle": "Không tìm thấy Team phù hợp",
|
||||
"noMatchDescription": "Thử từ khóa tìm kiếm khác.",
|
||||
"scope": {
|
||||
"all": "Tất cả phạm vi",
|
||||
"label": "Phạm vi"
|
||||
},
|
||||
"delete": {
|
||||
"title": "Xóa Team",
|
||||
"description": "Bạn có chắc chắn muốn xóa \"{{name}}\"? Hành động này không thể hoàn tác.",
|
||||
@@ -21,6 +25,7 @@
|
||||
"tasks": "Nhiệm vụ",
|
||||
"members": "Thành viên",
|
||||
"delegations": "Ủy quyền",
|
||||
"workspace": "Không gian làm việc",
|
||||
"settings": "Cài đặt"
|
||||
}
|
||||
},
|
||||
@@ -49,7 +54,22 @@
|
||||
"noTasks": "Chưa có nhiệm vụ",
|
||||
"noTasksDescription": "Nhiệm vụ được tạo bởi agent nhóm trong quá trình cộng tác.",
|
||||
"refresh": "Làm mới",
|
||||
"createTask": "Tạo nhiệm vụ",
|
||||
"createTaskTitle": "Tạo nhiệm vụ",
|
||||
"subjectPlaceholder": "Tiêu đề nhiệm vụ...",
|
||||
"descriptionPlaceholder": "Mô tả tùy chọn...",
|
||||
"priorityLabel": "Độ ưu tiên",
|
||||
"assign": "Gán",
|
||||
"assignTo": "Gán cho...",
|
||||
"unassigned": "Chưa gán",
|
||||
"filters": {
|
||||
"active": "Đang hoạt động",
|
||||
"inReview": "Chờ duyệt",
|
||||
"completed": "Đã hoàn thành",
|
||||
"all": "Tất cả"
|
||||
},
|
||||
"columns": {
|
||||
"id": "ID",
|
||||
"subject": "Tiêu đề",
|
||||
"status": "Trạng thái",
|
||||
"owner": "Người phụ trách",
|
||||
@@ -64,6 +84,7 @@
|
||||
"status": "Trạng thái:",
|
||||
"priority": "Độ ưu tiên:",
|
||||
"owner": "Người phụ trách:",
|
||||
"type": "Loại:",
|
||||
"blockedBy": "Bị chặn bởi:",
|
||||
"description": "Mô tả",
|
||||
"result": "Kết quả",
|
||||
@@ -71,29 +92,25 @@
|
||||
"updated": "Đã cập nhật:",
|
||||
"taskId": "ID nhiệm vụ:",
|
||||
"noDescription": "Không có mô tả",
|
||||
"noResult": "Chưa có kết quả"
|
||||
},
|
||||
"actions": {
|
||||
"approve": "Duyệt",
|
||||
"noResult": "Chưa có kết quả",
|
||||
"progress": "Tiến trình",
|
||||
"approve": "Phê duyệt",
|
||||
"reject": "Từ chối",
|
||||
"cancel": "Huỷ",
|
||||
"reasonPlaceholder": "Lý do (tùy chọn)...",
|
||||
"confirmCancel": "Huỷ nhiệm vụ này?",
|
||||
"confirmReject": "Từ chối nhiệm vụ này?"
|
||||
"comments": "Bình luận",
|
||||
"noComments": "Chưa có bình luận",
|
||||
"commentPlaceholder": "Thêm bình luận...",
|
||||
"addComment": "Bình luận",
|
||||
"timeline": "Lịch sử",
|
||||
"attachments": "Tệp đính kèm",
|
||||
"followupStatus": "Chờ phản hồi",
|
||||
"followupMessage": "Nội dung nhắc:",
|
||||
"followupCount": "Đã nhắc: {{count}} lần",
|
||||
"followupCountMax": "Đã nhắc: {{count}}/{{max}} lần",
|
||||
"followupNext": "Nhắc tiếp:",
|
||||
"followupDone": "Đã nhắc hết"
|
||||
},
|
||||
"filter": {
|
||||
"all": "Tất cả",
|
||||
"active": "Đang hoạt động",
|
||||
"completed": "Đã hoàn thành",
|
||||
"allUsers": "Tất cả người dùng"
|
||||
},
|
||||
"status": {
|
||||
"pending": "chờ xử lý",
|
||||
"pending_approval": "chờ duyệt",
|
||||
"in_progress": "đang thực hiện",
|
||||
"completed": "hoàn thành",
|
||||
"blocked": "bị chặn",
|
||||
"failed": "thất bại"
|
||||
"badges": {
|
||||
"awaitingReply": "chờ trả lời"
|
||||
}
|
||||
},
|
||||
"delegations": {
|
||||
@@ -108,6 +125,25 @@
|
||||
"time": "Thời gian"
|
||||
}
|
||||
},
|
||||
"workspace": {
|
||||
"title": "Không gian làm việc",
|
||||
"noFiles": "Chưa có file trong workspace",
|
||||
"noFilesDescription": "File được tạo bởi agent nhóm trong quá trình cộng tác.",
|
||||
"refresh": "Làm mới",
|
||||
"delete": "Xóa",
|
||||
"confirmDelete": "Xóa file này?",
|
||||
"columns": {
|
||||
"fileName": "Tên file",
|
||||
"size": "Kích thước",
|
||||
"uploadedBy": "Người tải lên",
|
||||
"scope": "Phạm vi",
|
||||
"updated": "Cập nhật"
|
||||
},
|
||||
"detail": {
|
||||
"title": "Nội dung file",
|
||||
"empty": "File trống hoặc là file nhị phân"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Cài đặt nhóm",
|
||||
"name": "Tên",
|
||||
@@ -135,7 +171,56 @@
|
||||
"progressNotifications": "Thông báo tiến trình",
|
||||
"progressNotificationsHint": "Gửi định kỳ thông báo \"Team của bạn đang xử lý...\" trong quá trình ủy quyền bất đồng bộ.",
|
||||
"searchUsers": "Tìm kiếm người dùng...",
|
||||
"selectChannel": "Chọn channel..."
|
||||
"selectChannel": "Chọn channel...",
|
||||
"escalationPolicy": "Chính sách Leo thang",
|
||||
"escalationMode": "Chế độ Leo thang",
|
||||
"escalationModeHint": "Kiểm soát cách xử lý action workspace nhạy cảm (pin, tag, xóa file người khác, set_template) khi agent yêu cầu.",
|
||||
"escalationModeAuto": "Tự động (mặc định)",
|
||||
"escalationModeAutoDesc": "LLM lead đánh giá yêu cầu — từ chối hoặc tạo task review. Không bao giờ thực thi trực tiếp.",
|
||||
"escalationModeReview": "Review",
|
||||
"escalationModeReviewDesc": "Luôn tạo task chờ duyệt. Không tự động từ chối.",
|
||||
"escalationModeReject": "Từ chối",
|
||||
"escalationModeRejectDesc": "Từ chối mọi yêu cầu action nhạy cảm. Chế độ khóa.",
|
||||
"escalationModeNone": "Không",
|
||||
"escalationModeNoneDesc": "Không có chính sách leo thang. Lead agent thực thi trực tiếp.",
|
||||
"escalationActions": "Action leo thang",
|
||||
"escalationActionsHint": "Chọn action workspace nào cần leo thang. Nếu không chọn, tất cả action nhạy cảm đều phải leo thang.",
|
||||
"followupReminders": "Nhắc nhở theo dõi",
|
||||
"followupInterval": "Khoảng cách nhắc (phút)",
|
||||
"followupIntervalHint": "Thời gian chờ trước khi gửi mỗi lần nhắc nhở cho người dùng.",
|
||||
"followupMaxReminders": "Số lần nhắc tối đa",
|
||||
"followupMaxRemindersHint": "Số lần nhắc tối đa cho mỗi nhiệm vụ. 0 = không giới hạn (nhắc cho đến khi người dùng trả lời).",
|
||||
"teamVersion": "Phiên bản Team",
|
||||
"versionBasic": "Cơ bản",
|
||||
"versionAdvanced": "Nâng cao",
|
||||
"versionBasicDesc": "Quản lý nhiệm vụ và workspace chung.",
|
||||
"versionAdvancedDesc": "Tất cả tính năng V1 cùng khóa thực thi, nhắc nhở theo dõi, quy trình duyệt, theo dõi tiến độ, bình luận, nhật ký hoạt động và tự phục hồi.",
|
||||
"whatsNew": "Có gì mới?",
|
||||
"versionModal": {
|
||||
"title": "Phiên bản Team",
|
||||
"feature": "Tính năng",
|
||||
"taskManagement": "Quản lý nhiệm vụ",
|
||||
"taskManagementDesc": "Tạo, phân công, hoàn thành, hủy nhiệm vụ",
|
||||
"sharedWorkspace": "Workspace chung",
|
||||
"sharedWorkspaceDesc": "Tệp, phiên bản, cộng tác nhóm",
|
||||
"executionLocking": "Khóa thực thi",
|
||||
"executionLockingDesc": "Ngăn xung đột nhiệm vụ với tự phục hồi",
|
||||
"followupReminders": "Nhắc nhở theo dõi",
|
||||
"followupRemindersDesc": "Tự nhắc khi đợi người dùng trả lời",
|
||||
"reviewWorkflow": "Quy trình duyệt",
|
||||
"reviewWorkflowDesc": "Gửi để người duyệt trước khi hoàn thành",
|
||||
"progressTracking": "Theo dõi tiến độ",
|
||||
"progressTrackingDesc": "Cập nhật phần trăm và bước theo thời gian thực",
|
||||
"commentsAudit": "Bình luận & Nhật ký",
|
||||
"commentsAuditDesc": "Bình luận nhiệm vụ và lịch sử sự kiện đầy đủ",
|
||||
"autoRecovery": "Tự phục hồi",
|
||||
"autoRecoveryDesc": "Nhiệm vụ bị treo tự đặt lại sau thời gian chờ",
|
||||
"escalationPolicy": "Chính sách leo thang",
|
||||
"escalationPolicyDesc": "Kiểm soát những gì agent có thể tự thực hiện",
|
||||
"downgradeNote": "Hạ cấp an toàn — dữ liệu V2 được giữ lại nhưng ẩn đi.",
|
||||
"betaNote": "V2 hiện đang ở giai đoạn Beta.",
|
||||
"gotIt": "Đã hiểu"
|
||||
}
|
||||
},
|
||||
"create": {
|
||||
"title": "Tạo Team",
|
||||
@@ -168,11 +253,9 @@
|
||||
"failedUpdate": "Không thể cập nhật nhóm",
|
||||
"failedAddMember": "Không thể thêm thành viên",
|
||||
"failedRemoveMember": "Không thể xóa thành viên",
|
||||
"taskApproved": "Đã duyệt nhiệm vụ",
|
||||
"taskRejected": "Đã từ chối nhiệm vụ",
|
||||
"taskCancelled": "Đã huỷ nhiệm vụ",
|
||||
"failedApproveTask": "Không thể duyệt nhiệm vụ",
|
||||
"failedRejectTask": "Không thể từ chối nhiệm vụ",
|
||||
"failedCancelTask": "Không thể huỷ nhiệm vụ"
|
||||
"taskCreated": "Đã tạo nhiệm vụ",
|
||||
"taskAssigned": "Đã gán nhiệm vụ",
|
||||
"failedCreateTask": "Không thể tạo nhiệm vụ",
|
||||
"failedAssignTask": "Không thể gán nhiệm vụ"
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,10 @@
|
||||
"emptyDescription": "创建您的第一个Team以开始使用。",
|
||||
"noMatchTitle": "未找到匹配Team",
|
||||
"noMatchDescription": "请尝试其他搜索词。",
|
||||
"scope": {
|
||||
"all": "所有范围",
|
||||
"label": "范围"
|
||||
},
|
||||
"delete": {
|
||||
"title": "删除Team",
|
||||
"description": "确定要删除「{{name}}」吗?此操作无法撤销。",
|
||||
@@ -21,6 +25,7 @@
|
||||
"tasks": "任务",
|
||||
"members": "成员",
|
||||
"delegations": "委托",
|
||||
"workspace": "工作空间",
|
||||
"settings": "设置"
|
||||
}
|
||||
},
|
||||
@@ -49,7 +54,22 @@
|
||||
"noTasks": "暂无任务",
|
||||
"noTasksDescription": "任务由TeamAgent在协作过程中创建。",
|
||||
"refresh": "刷新",
|
||||
"createTask": "创建任务",
|
||||
"createTaskTitle": "创建任务",
|
||||
"subjectPlaceholder": "任务主题...",
|
||||
"descriptionPlaceholder": "可选描述...",
|
||||
"priorityLabel": "优先级",
|
||||
"assign": "分配",
|
||||
"assignTo": "分配给...",
|
||||
"unassigned": "未分配",
|
||||
"filters": {
|
||||
"active": "进行中",
|
||||
"inReview": "待审核",
|
||||
"completed": "已完成",
|
||||
"all": "全部"
|
||||
},
|
||||
"columns": {
|
||||
"id": "ID",
|
||||
"subject": "主题",
|
||||
"status": "状态",
|
||||
"owner": "负责人",
|
||||
@@ -64,6 +84,7 @@
|
||||
"status": "状态:",
|
||||
"priority": "优先级:",
|
||||
"owner": "负责人:",
|
||||
"type": "类型:",
|
||||
"blockedBy": "被阻塞:",
|
||||
"description": "描述",
|
||||
"result": "结果",
|
||||
@@ -71,29 +92,25 @@
|
||||
"updated": "更新时间:",
|
||||
"taskId": "任务 ID:",
|
||||
"noDescription": "无描述",
|
||||
"noResult": "暂无结果"
|
||||
},
|
||||
"actions": {
|
||||
"noResult": "暂无结果",
|
||||
"progress": "进度",
|
||||
"approve": "批准",
|
||||
"reject": "拒绝",
|
||||
"cancel": "取消",
|
||||
"reasonPlaceholder": "原因(可选)...",
|
||||
"confirmCancel": "取消此任务?",
|
||||
"confirmReject": "拒绝此任务?"
|
||||
"comments": "评论",
|
||||
"noComments": "暂无评论",
|
||||
"commentPlaceholder": "添加评论...",
|
||||
"addComment": "评论",
|
||||
"timeline": "时间线",
|
||||
"attachments": "附件",
|
||||
"followupStatus": "等待回复",
|
||||
"followupMessage": "提醒内容:",
|
||||
"followupCount": "已提醒:{{count}} 次",
|
||||
"followupCountMax": "已提醒:{{count}}/{{max}} 次",
|
||||
"followupNext": "下次提醒:",
|
||||
"followupDone": "已全部提醒"
|
||||
},
|
||||
"filter": {
|
||||
"all": "全部",
|
||||
"active": "进行中",
|
||||
"completed": "已完成",
|
||||
"allUsers": "所有用户"
|
||||
},
|
||||
"status": {
|
||||
"pending": "待处理",
|
||||
"pending_approval": "待审批",
|
||||
"in_progress": "进行中",
|
||||
"completed": "已完成",
|
||||
"blocked": "被阻塞",
|
||||
"failed": "失败"
|
||||
"badges": {
|
||||
"awaitingReply": "等待回复"
|
||||
}
|
||||
},
|
||||
"delegations": {
|
||||
@@ -108,6 +125,25 @@
|
||||
"time": "时间"
|
||||
}
|
||||
},
|
||||
"workspace": {
|
||||
"title": "工作空间",
|
||||
"noFiles": "暂无工作空间文件",
|
||||
"noFilesDescription": "文件由TeamAgent在协作过程中创建。",
|
||||
"refresh": "刷新",
|
||||
"delete": "删除",
|
||||
"confirmDelete": "删除此文件?",
|
||||
"columns": {
|
||||
"fileName": "文件名",
|
||||
"size": "大小",
|
||||
"uploadedBy": "上传者",
|
||||
"scope": "范围",
|
||||
"updated": "更新时间"
|
||||
},
|
||||
"detail": {
|
||||
"title": "文件内容",
|
||||
"empty": "文件为空或为二进制文件"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Team设置",
|
||||
"name": "名称",
|
||||
@@ -135,7 +171,56 @@
|
||||
"progressNotifications": "进度通知",
|
||||
"progressNotificationsHint": "在异步委托期间,定期向对话发送「您的Team正在处理中...」消息。",
|
||||
"searchUsers": "搜索用户...",
|
||||
"selectChannel": "选择Channel..."
|
||||
"selectChannel": "选择Channel...",
|
||||
"escalationPolicy": "升级策略",
|
||||
"escalationMode": "升级模式",
|
||||
"escalationModeHint": "控制Agent请求执行敏感工作空间操作(置顶、标记、删除他人文件、设置模板)时的处理方式。",
|
||||
"escalationModeAuto": "自动(默认)",
|
||||
"escalationModeAutoDesc": "LLM负责人评估请求——拒绝或创建审核任务。从不直接执行。",
|
||||
"escalationModeReview": "审核",
|
||||
"escalationModeReviewDesc": "始终创建待审核任务。不自动拒绝。",
|
||||
"escalationModeReject": "拒绝",
|
||||
"escalationModeRejectDesc": "直接拒绝所有敏感操作请求。锁定模式。",
|
||||
"escalationModeNone": "无",
|
||||
"escalationModeNoneDesc": "无升级策略。负责人Agent直接执行操作。",
|
||||
"escalationActions": "升级操作",
|
||||
"escalationActionsHint": "选择哪些工作空间操作需要升级。如未选择,所有敏感操作均需升级。",
|
||||
"followupReminders": "跟进提醒",
|
||||
"followupInterval": "提醒间隔(分钟)",
|
||||
"followupIntervalHint": "在每次向用户发送跟进提醒前等待的时间。",
|
||||
"followupMaxReminders": "最大提醒次数",
|
||||
"followupMaxRemindersHint": "每个任务的最大提醒次数。0 = 无限制(持续提醒直到用户回复)。",
|
||||
"teamVersion": "团队版本",
|
||||
"versionBasic": "基础版",
|
||||
"versionAdvanced": "高级版",
|
||||
"versionBasicDesc": "任务管理和共享工作区。",
|
||||
"versionAdvancedDesc": "所有V1功能,外加执行锁定、跟进提醒、审核工作流、进度追踪、评论、审计日志和自动恢复。",
|
||||
"whatsNew": "有什么不同?",
|
||||
"versionModal": {
|
||||
"title": "团队版本",
|
||||
"feature": "功能",
|
||||
"taskManagement": "任务管理",
|
||||
"taskManagementDesc": "创建、分配、完成、取消任务",
|
||||
"sharedWorkspace": "共享工作区",
|
||||
"sharedWorkspaceDesc": "文件、版本、团队协作",
|
||||
"executionLocking": "执行锁定",
|
||||
"executionLockingDesc": "防止任务冲突,自动恢复",
|
||||
"followupReminders": "跟进提醒",
|
||||
"followupRemindersDesc": "等待用户回复时自动提醒",
|
||||
"reviewWorkflow": "审核工作流",
|
||||
"reviewWorkflowDesc": "完成前提交人工审核",
|
||||
"progressTracking": "进度追踪",
|
||||
"progressTrackingDesc": "实时百分比和步骤更新",
|
||||
"commentsAudit": "评论与审计",
|
||||
"commentsAuditDesc": "任务评论和完整事件历史",
|
||||
"autoRecovery": "自动恢复",
|
||||
"autoRecoveryDesc": "卡住的任务在超时后自动重置",
|
||||
"escalationPolicy": "升级策略",
|
||||
"escalationPolicyDesc": "控制代理可以自主执行的操作",
|
||||
"downgradeNote": "降级是安全的 — V2数据会保留但隐藏。",
|
||||
"betaNote": "V2目前处于Beta阶段。",
|
||||
"gotIt": "知道了"
|
||||
}
|
||||
},
|
||||
"create": {
|
||||
"title": "创建Team",
|
||||
@@ -168,11 +253,9 @@
|
||||
"failedUpdate": "更新Team失败",
|
||||
"failedAddMember": "添加成员失败",
|
||||
"failedRemoveMember": "移除成员失败",
|
||||
"taskApproved": "任务已批准",
|
||||
"taskRejected": "任务已拒绝",
|
||||
"taskCancelled": "任务已取消",
|
||||
"failedApproveTask": "批准任务失败",
|
||||
"failedRejectTask": "拒绝任务失败",
|
||||
"failedCancelTask": "取消任务失败"
|
||||
"taskCreated": "任务已创建",
|
||||
"taskAssigned": "任务已分配",
|
||||
"failedCreateTask": "创建任务失败",
|
||||
"failedAssignTask": "分配任务失败"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useWs } from "@/hooks/use-ws";
|
||||
import { Methods } from "@/api/protocol";
|
||||
import type { TeamWorkspaceFile } from "@/types/team";
|
||||
|
||||
export function useTeamWorkspace() {
|
||||
const ws = useWs();
|
||||
const [files, setFiles] = useState<TeamWorkspaceFile[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const listFiles = useCallback(
|
||||
async (teamId: string, channel?: string, chatId?: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await ws.call<{ files: TeamWorkspaceFile[]; count: number }>(
|
||||
Methods.TEAMS_WORKSPACE_LIST,
|
||||
{ team_id: teamId, channel: channel ?? "", chat_id: chatId ?? "" },
|
||||
);
|
||||
setFiles(res.files ?? []);
|
||||
return res.files ?? [];
|
||||
} catch {
|
||||
return [];
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[ws],
|
||||
);
|
||||
|
||||
const readFile = useCallback(
|
||||
async (teamId: string, fileName: string, channel?: string, chatId?: string) => {
|
||||
const res = await ws.call<{ file: TeamWorkspaceFile; content: string }>(
|
||||
Methods.TEAMS_WORKSPACE_READ,
|
||||
{ team_id: teamId, file_name: fileName, channel: channel ?? "", chat_id: chatId ?? "" },
|
||||
);
|
||||
return res;
|
||||
},
|
||||
[ws],
|
||||
);
|
||||
|
||||
const deleteFile = useCallback(
|
||||
async (teamId: string, fileName: string, channel?: string, chatId?: string) => {
|
||||
await ws.call(Methods.TEAMS_WORKSPACE_DELETE, {
|
||||
team_id: teamId,
|
||||
file_name: fileName,
|
||||
channel: channel ?? "",
|
||||
chat_id: chatId ?? "",
|
||||
});
|
||||
},
|
||||
[ws],
|
||||
);
|
||||
|
||||
return { files, loading, listFiles, readFile, deleteFile };
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { useState, useCallback } from "react";
|
||||
import { useWs } from "@/hooks/use-ws";
|
||||
import { useAuthStore } from "@/stores/use-auth-store";
|
||||
import { Methods } from "@/api/protocol";
|
||||
import type { TeamData, TeamMemberData, TeamTaskData, TeamAccessSettings } from "@/types/team";
|
||||
import type { TeamData, TeamMemberData, TeamTaskData, TeamTaskComment, TeamTaskEvent, TeamTaskAttachment, TeamAccessSettings, ScopeEntry } from "@/types/team";
|
||||
|
||||
export function useTeams() {
|
||||
const ws = useWs();
|
||||
@@ -58,26 +58,97 @@ export function useTeams() {
|
||||
);
|
||||
|
||||
const getTeamTasks = useCallback(
|
||||
async (teamId: string, statusFilter?: string, userId?: string) => {
|
||||
async (teamId: string, status?: string, channel?: string, chatId?: string) => {
|
||||
const res = await ws.call<{ tasks: TeamTaskData[]; count: number }>(
|
||||
Methods.TEAMS_TASK_LIST,
|
||||
{ teamId, statusFilter, userId },
|
||||
{ teamId, status, channel: channel ?? "", chatId: chatId ?? "" },
|
||||
);
|
||||
return res;
|
||||
},
|
||||
[ws],
|
||||
);
|
||||
|
||||
const getTeamScopes = useCallback(
|
||||
async (teamId: string) => {
|
||||
const res = await ws.call<{ scopes: ScopeEntry[] }>(
|
||||
Methods.TEAMS_SCOPES,
|
||||
{ teamId },
|
||||
);
|
||||
return res.scopes ?? [];
|
||||
},
|
||||
[ws],
|
||||
);
|
||||
|
||||
const getTaskDetail = useCallback(
|
||||
async (teamId: string, taskId: string) => {
|
||||
const res = await ws.call<{
|
||||
task: TeamTaskData;
|
||||
comments: TeamTaskComment[];
|
||||
events: TeamTaskEvent[];
|
||||
attachments: TeamTaskAttachment[];
|
||||
}>(Methods.TEAMS_TASK_GET, { teamId, taskId });
|
||||
return res;
|
||||
},
|
||||
[ws],
|
||||
);
|
||||
|
||||
const approveTask = useCallback(
|
||||
async (taskId: string) => {
|
||||
await ws.call(Methods.TEAMS_TASK_APPROVE, { task_id: taskId });
|
||||
async (teamId: string, taskId: string, comment?: string) => {
|
||||
await ws.call(Methods.TEAMS_TASK_APPROVE, { teamId, taskId, comment });
|
||||
},
|
||||
[ws],
|
||||
);
|
||||
|
||||
const rejectTask = useCallback(
|
||||
async (taskId: string, reason?: string) => {
|
||||
await ws.call(Methods.TEAMS_TASK_REJECT, { task_id: taskId, reason });
|
||||
async (teamId: string, taskId: string, reason?: string) => {
|
||||
await ws.call(Methods.TEAMS_TASK_REJECT, { teamId, taskId, reason });
|
||||
},
|
||||
[ws],
|
||||
);
|
||||
|
||||
const addTaskComment = useCallback(
|
||||
async (taskId: string, content: string, teamId?: string) => {
|
||||
await ws.call(Methods.TEAMS_TASK_COMMENT, { teamId, taskId, content });
|
||||
},
|
||||
[ws],
|
||||
);
|
||||
|
||||
const getTaskComments = useCallback(
|
||||
async (teamId: string, taskId: string) => {
|
||||
const res = await ws.call<{ comments: TeamTaskComment[] }>(
|
||||
Methods.TEAMS_TASK_COMMENTS,
|
||||
{ teamId, taskId },
|
||||
);
|
||||
return res.comments ?? [];
|
||||
},
|
||||
[ws],
|
||||
);
|
||||
|
||||
const getTaskEvents = useCallback(
|
||||
async (teamId: string, taskId: string) => {
|
||||
const res = await ws.call<{ events: TeamTaskEvent[] }>(
|
||||
Methods.TEAMS_TASK_EVENTS,
|
||||
{ teamId, taskId },
|
||||
);
|
||||
return res.events ?? [];
|
||||
},
|
||||
[ws],
|
||||
);
|
||||
|
||||
const createTask = useCallback(
|
||||
async (teamId: string, params: { subject: string; description?: string; priority?: number; taskType?: string; assignTo?: string; channel?: string; chatId?: string }) => {
|
||||
const res = await ws.call<{ task: TeamTaskData }>(
|
||||
Methods.TEAMS_TASK_CREATE,
|
||||
{ teamId, ...params },
|
||||
);
|
||||
return res.task;
|
||||
},
|
||||
[ws],
|
||||
);
|
||||
|
||||
const assignTask = useCallback(
|
||||
async (teamId: string, taskId: string, agentId: string) => {
|
||||
await ws.call(Methods.TEAMS_TASK_ASSIGN, { teamId, taskId, agentId });
|
||||
},
|
||||
[ws],
|
||||
);
|
||||
@@ -114,5 +185,10 @@ export function useTeams() {
|
||||
[ws],
|
||||
);
|
||||
|
||||
return { teams, loading, load, createTeam, deleteTeam, getTeam, getTeamTasks, approveTask, rejectTask, addMember, removeMember, updateTeamSettings, getKnownUsers };
|
||||
return {
|
||||
teams, loading, load, createTeam, deleteTeam, getTeam, getTeamTasks, getTeamScopes,
|
||||
getTaskDetail, approveTask, rejectTask, addTaskComment, getTaskComments, getTaskEvents,
|
||||
createTask, assignTask,
|
||||
addMember, removeMember, updateTeamSettings, getKnownUsers,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -7,61 +7,125 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "@/stores/use-toast-store";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import type { TeamTaskData } from "@/types/team";
|
||||
import { taskStatusBadgeVariant, isTaskActionable } from "./task-utils";
|
||||
import type { TeamTaskData, TeamTaskComment, TeamTaskEvent, TeamTaskAttachment } from "@/types/team";
|
||||
import type { TeamMemberData } from "@/types/team";
|
||||
import { taskStatusBadgeVariant } from "./task-utils";
|
||||
|
||||
interface TaskDetailDialogProps {
|
||||
task: TeamTaskData;
|
||||
teamId: string;
|
||||
members: TeamMemberData[];
|
||||
isTeamV2?: boolean;
|
||||
onClose: () => void;
|
||||
onApprove: (taskId: string) => Promise<void>;
|
||||
onReject: (taskId: string, reason?: string) => Promise<void>;
|
||||
getTaskDetail: (teamId: string, taskId: string) => Promise<{
|
||||
task: TeamTaskData;
|
||||
comments: TeamTaskComment[];
|
||||
events: TeamTaskEvent[];
|
||||
attachments: TeamTaskAttachment[];
|
||||
}>;
|
||||
approveTask: (teamId: string, taskId: string, comment?: string) => Promise<void>;
|
||||
rejectTask: (teamId: string, taskId: string, reason?: string) => Promise<void>;
|
||||
addTaskComment: (taskId: string, content: string, teamId?: string) => Promise<void>;
|
||||
assignTask: (teamId: string, taskId: string, agentId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function TaskDetailDialog({ task, onClose, onApprove, onReject }: TaskDetailDialogProps) {
|
||||
export function TaskDetailDialog({
|
||||
task, teamId, members, isTeamV2, onClose,
|
||||
getTaskDetail, approveTask, rejectTask, addTaskComment, assignTask,
|
||||
}: TaskDetailDialogProps) {
|
||||
const { t } = useTranslation("teams");
|
||||
const [reason, setReason] = useState("");
|
||||
const [acting, setActing] = useState(false);
|
||||
const [comments, setComments] = useState<TeamTaskComment[]>([]);
|
||||
const [events, setEvents] = useState<TeamTaskEvent[]>([]);
|
||||
const [attachments, setAttachments] = useState<TeamTaskAttachment[]>([]);
|
||||
const [commentText, setCommentText] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// Assign state
|
||||
const [selectedAgentId, setSelectedAgentId] = useState("");
|
||||
const [assigning, setAssigning] = useState(false);
|
||||
|
||||
const loadDetail = useCallback(async () => {
|
||||
try {
|
||||
const res = await getTaskDetail(teamId, task.id);
|
||||
setComments(res.comments ?? []);
|
||||
setEvents(res.events ?? []);
|
||||
setAttachments(res.attachments ?? []);
|
||||
} catch {
|
||||
// ignore load errors — partial data is acceptable
|
||||
}
|
||||
}, [getTaskDetail, teamId, task.id]);
|
||||
|
||||
useEffect(() => {
|
||||
loadDetail();
|
||||
}, [loadDetail]);
|
||||
|
||||
const handleApprove = async () => {
|
||||
setActing(true);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onApprove(task.id);
|
||||
await approveTask(teamId, task.id);
|
||||
onClose();
|
||||
} catch {
|
||||
toast.error(t("toast.failedUpdate"));
|
||||
} finally {
|
||||
setActing(false);
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReject = async () => {
|
||||
setActing(true);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onReject(task.id, reason || undefined);
|
||||
await rejectTask(teamId, task.id, "Rejected from dashboard");
|
||||
onClose();
|
||||
} catch {
|
||||
toast.error(t("toast.failedUpdate"));
|
||||
} finally {
|
||||
setActing(false);
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async () => {
|
||||
setActing(true);
|
||||
const handleComment = async () => {
|
||||
if (!commentText.trim()) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onReject(task.id, reason || "Cancelled by user");
|
||||
onClose();
|
||||
await addTaskComment(task.id, commentText.trim(), teamId);
|
||||
setCommentText("");
|
||||
loadDetail();
|
||||
} catch {
|
||||
toast.error(t("toast.failedUpdate"));
|
||||
} finally {
|
||||
setActing(false);
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const showActions = isTaskActionable(task.status);
|
||||
const handleAssign = async () => {
|
||||
if (!selectedAgentId) return;
|
||||
setAssigning(true);
|
||||
try {
|
||||
await assignTask(teamId, task.id, selectedAgentId);
|
||||
toast.success(t("toast.taskAssigned"));
|
||||
onClose();
|
||||
} catch {
|
||||
toast.error(t("toast.failedAssignTask"));
|
||||
} finally {
|
||||
setAssigning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const showAssign = task.status === "pending" && !task.owner_agent_key;
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={() => onClose()}>
|
||||
<DialogContent className="max-h-[85vh] w-[95vw] overflow-y-auto sm:max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("tasks.detail.title")}</DialogTitle>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{task.identifier && (
|
||||
<Badge variant="outline" className="font-mono text-xs">{task.identifier}</Badge>
|
||||
)}
|
||||
{t("tasks.detail.title")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
@@ -71,12 +135,60 @@ export function TaskDetailDialog({ task, onClose, onApprove, onReject }: TaskDet
|
||||
<p className="text-sm font-medium">{task.subject}</p>
|
||||
</div>
|
||||
|
||||
{/* Follow-up status banner (V2 only) */}
|
||||
{isTeamV2 && task.followup_at && task.status === "in_progress" && (
|
||||
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 p-3">
|
||||
<p className="mb-1 text-xs font-semibold text-amber-700 dark:text-amber-400">
|
||||
{t("tasks.detail.followupStatus")}
|
||||
</p>
|
||||
{task.followup_message && (
|
||||
<p className="text-sm">
|
||||
<span className="text-xs text-muted-foreground">{t("tasks.detail.followupMessage")}</span>{" "}
|
||||
{task.followup_message}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-1 flex flex-wrap gap-3 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{task.followup_max && task.followup_max > 0
|
||||
? t("tasks.detail.followupCountMax", { count: task.followup_count ?? 0, max: task.followup_max })
|
||||
: t("tasks.detail.followupCount", { count: task.followup_count ?? 0 })}
|
||||
</span>
|
||||
{task.followup_at && (
|
||||
<span>
|
||||
{task.followup_max && task.followup_max > 0 && (task.followup_count ?? 0) >= task.followup_max
|
||||
? t("tasks.detail.followupDone")
|
||||
: `${t("tasks.detail.followupNext")} ${formatDate(task.followup_at)}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress bar (V2 only) */}
|
||||
{isTeamV2 && task.progress_percent != null && task.progress_percent > 0 && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>{t("tasks.detail.progress")}</span>
|
||||
<span>{task.progress_percent}%</span>
|
||||
</div>
|
||||
<div className="h-2 w-full rounded-full bg-muted">
|
||||
<div
|
||||
className="h-2 rounded-full bg-primary transition-all"
|
||||
style={{ width: `${task.progress_percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
{task.progress_step && (
|
||||
<p className="text-xs text-muted-foreground">{task.progress_step}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary grid */}
|
||||
<div className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t("tasks.detail.status")}</span>{" "}
|
||||
<Badge variant={taskStatusBadgeVariant(task.status)} className="text-xs">
|
||||
{t(`tasks.status.${task.status}`, task.status.replace("_", " "))}
|
||||
{task.status.replace(/_/g, " ")}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
@@ -87,6 +199,12 @@ export function TaskDetailDialog({ task, onClose, onApprove, onReject }: TaskDet
|
||||
<span className="text-muted-foreground">{t("tasks.detail.owner")}</span>{" "}
|
||||
<span className="font-medium">{task.owner_agent_key || "—"}</span>
|
||||
</div>
|
||||
{task.task_type && task.task_type !== "general" && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t("tasks.detail.type")}</span>{" "}
|
||||
<Badge variant="outline" className="text-xs">{task.task_type}</Badge>
|
||||
</div>
|
||||
)}
|
||||
{task.created_at && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t("tasks.detail.created")}</span>{" "}
|
||||
@@ -101,6 +219,57 @@ export function TaskDetailDialog({ task, onClose, onApprove, onReject }: TaskDet
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Approve / Reject buttons (V2 only) */}
|
||||
{isTeamV2 && task.status === "in_review" && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleApprove}
|
||||
disabled={submitting}
|
||||
>
|
||||
{t("tasks.detail.approve")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleReject}
|
||||
disabled={submitting}
|
||||
>
|
||||
{t("tasks.detail.reject")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Assign to Agent section */}
|
||||
{showAssign && members.length > 0 && (
|
||||
<div className="rounded-md border p-3">
|
||||
<p className="mb-2 text-xs font-medium text-muted-foreground">{t("tasks.assign")}</p>
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={selectedAgentId}
|
||||
onChange={(e) => setSelectedAgentId(e.target.value)}
|
||||
className="flex-1 rounded-md border bg-background px-3 py-2 text-base md:text-sm"
|
||||
disabled={assigning}
|
||||
>
|
||||
<option value="">{t("tasks.assignTo")}</option>
|
||||
{members.map((m) => (
|
||||
<option key={m.agent_id} value={m.agent_id}>
|
||||
{m.display_name || m.agent_key || m.agent_id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleAssign}
|
||||
disabled={assigning || !selectedAgentId}
|
||||
>
|
||||
{t("tasks.assign")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Blocked by */}
|
||||
{task.blocked_by && task.blocked_by.length > 0 && (
|
||||
<div className="text-sm">
|
||||
@@ -127,33 +296,83 @@ export function TaskDetailDialog({ task, onClose, onApprove, onReject }: TaskDet
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{showActions && (
|
||||
<div className="space-y-3 border-t pt-4">
|
||||
<Input
|
||||
placeholder={t("tasks.actions.reasonPlaceholder")}
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
className="text-base md:text-sm"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
{task.status === "pending_approval" ? (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={handleReject} disabled={acting}>
|
||||
{t("tasks.actions.reject")}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleApprove} disabled={acting}>
|
||||
{t("tasks.actions.approve")}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button variant="destructive" size="sm" onClick={handleCancel} disabled={acting}>
|
||||
{t("tasks.actions.cancel")}
|
||||
</Button>
|
||||
)}
|
||||
{/* Attachments (V2 only) */}
|
||||
{isTeamV2 && attachments.length > 0 && (
|
||||
<div className="rounded-md border p-3">
|
||||
<p className="mb-2 text-xs font-medium text-muted-foreground">{t("tasks.detail.attachments")}</p>
|
||||
<div className="space-y-1">
|
||||
{attachments.map((a) => (
|
||||
<div key={a.id} className="flex items-center gap-2 text-sm">
|
||||
<span className="font-medium">{a.file_name || a.file_id}</span>
|
||||
<span className="text-xs text-muted-foreground">{formatDate(a.created_at)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timeline (V2 only) */}
|
||||
{isTeamV2 && events.length > 0 && (
|
||||
<div className="rounded-md border p-3">
|
||||
<p className="mb-2 text-xs font-medium text-muted-foreground">{t("tasks.detail.timeline")}</p>
|
||||
<div className="space-y-2">
|
||||
{events.map((e) => (
|
||||
<div key={e.id} className="flex items-center gap-2 text-xs">
|
||||
<span className="text-muted-foreground">{formatDate(e.created_at)}</span>
|
||||
<Badge variant="outline" className="text-[10px]">{e.event_type}</Badge>
|
||||
<span className="text-muted-foreground">
|
||||
{e.actor_type === "human" ? "Human" : e.actor_id.slice(0, 8)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Comments (V2 only) */}
|
||||
{isTeamV2 && <div className="rounded-md border p-3">
|
||||
<p className="mb-2 text-xs font-medium text-muted-foreground">{t("tasks.detail.comments")}</p>
|
||||
{comments.length > 0 ? (
|
||||
<div className="mb-3 space-y-2">
|
||||
{comments.map((c) => (
|
||||
<div key={c.id} className="border-l-2 border-muted pl-3">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="font-medium">{c.agent_key || c.user_id || "—"}</span>
|
||||
<span>{formatDate(c.created_at)}</span>
|
||||
</div>
|
||||
<p className="text-sm">{c.content}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mb-3 text-xs text-muted-foreground">{t("tasks.detail.noComments")}</p>
|
||||
)}
|
||||
|
||||
{/* Comment input */}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={commentText}
|
||||
onChange={(e) => setCommentText(e.target.value)}
|
||||
placeholder={t("tasks.detail.commentPlaceholder")}
|
||||
className="flex-1 rounded-md border bg-background px-3 py-1.5 text-base md:text-sm"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleComment();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleComment}
|
||||
disabled={submitting || !commentText.trim()}
|
||||
>
|
||||
{t("tasks.detail.addComment")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,20 +1,34 @@
|
||||
import { useState } from "react";
|
||||
import { ClipboardList, Check, X, Trash2 } from "lucide-react";
|
||||
import { ClipboardList } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TeamTaskData } from "@/types/team";
|
||||
import { taskStatusBadgeVariant, isTaskActionable } from "./task-utils";
|
||||
import type { TeamTaskData, TeamTaskComment, TeamTaskEvent, TeamTaskAttachment } from "@/types/team";
|
||||
import type { TeamMemberData } from "@/types/team";
|
||||
import { taskStatusBadgeVariant } from "./task-utils";
|
||||
import { TaskDetailDialog } from "./task-detail-dialog";
|
||||
|
||||
interface TaskListProps {
|
||||
tasks: TeamTaskData[];
|
||||
loading: boolean;
|
||||
onApprove: (taskId: string) => Promise<void>;
|
||||
onReject: (taskId: string, reason?: string) => Promise<void>;
|
||||
teamId: string;
|
||||
members: TeamMemberData[];
|
||||
isTeamV2?: boolean;
|
||||
getTaskDetail: (teamId: string, taskId: string) => Promise<{
|
||||
task: TeamTaskData;
|
||||
comments: TeamTaskComment[];
|
||||
events: TeamTaskEvent[];
|
||||
attachments: TeamTaskAttachment[];
|
||||
}>;
|
||||
approveTask: (teamId: string, taskId: string, comment?: string) => Promise<void>;
|
||||
rejectTask: (teamId: string, taskId: string, reason?: string) => Promise<void>;
|
||||
addTaskComment: (taskId: string, content: string, teamId?: string) => Promise<void>;
|
||||
assignTask: (teamId: string, taskId: string, agentId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function TaskList({ tasks, loading, onApprove, onReject }: TaskListProps) {
|
||||
export function TaskList({
|
||||
tasks, loading, teamId, members, isTeamV2,
|
||||
getTaskDetail, approveTask, rejectTask, addTaskComment, assignTask,
|
||||
}: TaskListProps) {
|
||||
const { t } = useTranslation("teams");
|
||||
const [selectedTask, setSelectedTask] = useState<TeamTaskData | null>(null);
|
||||
|
||||
@@ -41,19 +55,22 @@ export function TaskList({ tasks, loading, onApprove, onReject }: TaskListProps)
|
||||
return (
|
||||
<>
|
||||
<div className="overflow-x-auto rounded-lg border">
|
||||
<div className="grid min-w-[500px] grid-cols-[1fr_110px_100px_60px_80px] items-center gap-2 border-b bg-muted/50 px-4 py-2.5 text-xs font-medium text-muted-foreground">
|
||||
<div className="grid min-w-[500px] grid-cols-[70px_1fr_90px_100px_60px] items-center gap-2 border-b bg-muted/50 px-4 py-2.5 text-xs font-medium text-muted-foreground">
|
||||
<span>{t("tasks.columns.id")}</span>
|
||||
<span>{t("tasks.columns.subject")}</span>
|
||||
<span>{t("tasks.columns.status")}</span>
|
||||
<span>{t("tasks.columns.owner")}</span>
|
||||
<span>{t("tasks.columns.priority")}</span>
|
||||
<span className="text-right">{t("tasks.columns.actions")}</span>
|
||||
</div>
|
||||
{tasks.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="grid min-w-[500px] cursor-pointer grid-cols-[1fr_110px_100px_60px_80px] items-center gap-2 border-b px-4 py-3 last:border-0 hover:bg-muted/30"
|
||||
className="grid min-w-[500px] cursor-pointer grid-cols-[70px_1fr_90px_100px_60px] items-center gap-2 border-b px-4 py-3 last:border-0 hover:bg-muted/30"
|
||||
onClick={() => setSelectedTask(task)}
|
||||
>
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{task.identifier || "—"}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">{task.subject}</p>
|
||||
{task.description && (
|
||||
@@ -61,56 +78,28 @@ export function TaskList({ tasks, loading, onApprove, onReject }: TaskListProps)
|
||||
{task.description}
|
||||
</p>
|
||||
)}
|
||||
{task.result && (
|
||||
<p className="mt-0.5 line-clamp-1 text-xs text-emerald-600 dark:text-emerald-400">
|
||||
{task.result}
|
||||
</p>
|
||||
{task.task_type && task.task_type !== "general" && (
|
||||
<Badge variant="outline" className="mt-0.5 text-[10px]">
|
||||
{task.task_type}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<Badge variant={taskStatusBadgeVariant(task.status)}>
|
||||
{task.status.replace(/_/g, " ")}
|
||||
</Badge>
|
||||
{isTeamV2 && task.followup_at && task.status === "in_progress" && (
|
||||
<Badge variant="outline" className="border-amber-500/50 bg-amber-500/10 text-[10px] text-amber-700 dark:text-amber-400">
|
||||
{t("tasks.badges.awaitingReply")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Badge variant={taskStatusBadgeVariant(task.status)}>
|
||||
{t(`tasks.status.${task.status}`, task.status.replace("_", " "))}
|
||||
</Badge>
|
||||
<span className="truncate text-sm text-muted-foreground">
|
||||
{task.owner_agent_key || "—"}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{task.priority}
|
||||
</span>
|
||||
<div className="flex justify-end gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
{task.status === "pending_approval" && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-emerald-600 hover:text-emerald-700"
|
||||
title={t("tasks.actions.approve")}
|
||||
onClick={() => onApprove(task.id)}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive hover:text-destructive/80"
|
||||
title={t("tasks.actions.reject")}
|
||||
onClick={() => onReject(task.id)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{isTaskActionable(task.status) && task.status !== "pending_approval" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive hover:text-destructive/80"
|
||||
title={t("tasks.actions.cancel")}
|
||||
onClick={() => onReject(task.id, "Cancelled by user")}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -118,9 +107,15 @@ export function TaskList({ tasks, loading, onApprove, onReject }: TaskListProps)
|
||||
{selectedTask && (
|
||||
<TaskDetailDialog
|
||||
task={selectedTask}
|
||||
teamId={teamId}
|
||||
members={members}
|
||||
isTeamV2={isTeamV2}
|
||||
onClose={() => setSelectedTask(null)}
|
||||
onApprove={onApprove}
|
||||
onReject={onReject}
|
||||
getTaskDetail={getTaskDetail}
|
||||
approveTask={approveTask}
|
||||
rejectTask={rejectTask}
|
||||
addTaskComment={addTaskComment}
|
||||
assignTask={assignTask}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -6,6 +6,8 @@ export function taskStatusBadgeVariant(status: string) {
|
||||
case "completed": return "success" as const;
|
||||
case "blocked": return "warning" as const;
|
||||
case "failed": return "destructive" as const;
|
||||
case "in_review": return "secondary" as const;
|
||||
case "cancelled": return "outline" as const;
|
||||
default: return "outline" as const;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ export function TeamCard({ team, onClick, onDelete }: TeamCardProps) {
|
||||
<div className="flex items-center gap-1.5">
|
||||
{team.lead_agent_key && (
|
||||
<Badge variant="outline" className="text-[11px]">
|
||||
{t("settings.leadAgent")}: {team.lead_agent_key}
|
||||
{t("settings.leadAgent")}: {team.lead_display_name || team.lead_agent_key}
|
||||
</Badge>
|
||||
)}
|
||||
{onDelete && (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -11,7 +12,8 @@ import { TeamMembersTab } from "./team-members-tab";
|
||||
import { TeamTasksTab } from "./team-tasks-tab";
|
||||
import { TeamDelegationsTab } from "./team-delegations-tab";
|
||||
import { TeamSettingsTab } from "./team-settings-tab";
|
||||
import type { TeamData, TeamMemberData } from "@/types/team";
|
||||
import { TeamWorkspaceTab } from "./team-workspace-tab";
|
||||
import type { TeamData, TeamMemberData, TeamAccessSettings, ScopeEntry } from "@/types/team";
|
||||
|
||||
interface TeamDetailPageProps {
|
||||
teamId: string;
|
||||
@@ -20,12 +22,29 @@ interface TeamDetailPageProps {
|
||||
|
||||
export function TeamDetailPage({ teamId, onBack }: TeamDetailPageProps) {
|
||||
const { t } = useTranslation("teams");
|
||||
const { getTeam, getTeamTasks, approveTask, rejectTask, getKnownUsers, addMember, removeMember, deleteTeam } = useTeams();
|
||||
const {
|
||||
getTeam, getTeamTasks, getTeamScopes, addMember, removeMember, deleteTeam,
|
||||
getTaskDetail, approveTask, rejectTask, addTaskComment,
|
||||
createTask, assignTask,
|
||||
} = useTeams();
|
||||
const [team, setTeam] = useState<TeamData | null>(null);
|
||||
const [members, setMembers] = useState<TeamMemberData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState("members");
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const activeTab = searchParams.get("tab") || "members";
|
||||
const setActiveTab = useCallback((tab: string) => {
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (tab === "members") {
|
||||
next.delete("tab");
|
||||
} else {
|
||||
next.set("tab", tab);
|
||||
}
|
||||
return next;
|
||||
}, { replace: true });
|
||||
}, [setSearchParams]);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [scopes, setScopes] = useState<ScopeEntry[]>([]);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
try {
|
||||
@@ -42,10 +61,14 @@ export function TeamDetailPage({ teamId, onBack }: TeamDetailPageProps) {
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getTeam(teamId);
|
||||
const [res, scopeList] = await Promise.all([
|
||||
getTeam(teamId),
|
||||
getTeamScopes(teamId).catch(() => [] as ScopeEntry[]),
|
||||
]);
|
||||
if (!cancelled) {
|
||||
setTeam(res.team);
|
||||
setMembers(res.members ?? []);
|
||||
setScopes(scopeList);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
@@ -54,7 +77,7 @@ export function TeamDetailPage({ teamId, onBack }: TeamDetailPageProps) {
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [teamId, getTeam]);
|
||||
}, [teamId, getTeam, getTeamScopes]);
|
||||
|
||||
const handleAddMember = useCallback(async (agentId: string, role?: string) => {
|
||||
await addMember(teamId, agentId, role);
|
||||
@@ -67,7 +90,7 @@ export function TeamDetailPage({ teamId, onBack }: TeamDetailPageProps) {
|
||||
}, [teamId, removeMember, reload]);
|
||||
|
||||
if (loading || !team) {
|
||||
return <DetailPageSkeleton tabs={4} />;
|
||||
return <DetailPageSkeleton tabs={5} />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -86,11 +109,15 @@ export function TeamDetailPage({ teamId, onBack }: TeamDetailPageProps) {
|
||||
<Badge variant={team.status === "active" ? "success" : "secondary"}>
|
||||
{team.status}
|
||||
</Badge>
|
||||
{((team.settings ?? {}) as TeamAccessSettings).version != null &&
|
||||
((team.settings ?? {}) as TeamAccessSettings).version! >= 2 && (
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">V2 Beta</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-muted-foreground">
|
||||
{team.lead_agent_key && (
|
||||
<>
|
||||
<span>{t("detail.lead")}: {team.lead_agent_key}</span>
|
||||
<span>{t("detail.lead")}: {team.lead_display_name || team.lead_agent_key}</span>
|
||||
<span className="text-border">|</span>
|
||||
</>
|
||||
)}
|
||||
@@ -121,6 +148,7 @@ export function TeamDetailPage({ teamId, onBack }: TeamDetailPageProps) {
|
||||
<TabsTrigger value="members">{t("detail.tabs.members")}</TabsTrigger>
|
||||
<TabsTrigger value="tasks">{t("detail.tabs.tasks")}</TabsTrigger>
|
||||
<TabsTrigger value="delegations">{t("detail.tabs.delegations")}</TabsTrigger>
|
||||
<TabsTrigger value="workspace">{t("detail.tabs.workspace")}</TabsTrigger>
|
||||
<TabsTrigger value="settings">{t("detail.tabs.settings")}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@@ -136,10 +164,16 @@ export function TeamDetailPage({ teamId, onBack }: TeamDetailPageProps) {
|
||||
<TabsContent value="tasks" className="mt-4">
|
||||
<TeamTasksTab
|
||||
teamId={teamId}
|
||||
members={members}
|
||||
scopes={scopes}
|
||||
isTeamV2={((team.settings ?? {}) as TeamAccessSettings).version != null && ((team.settings ?? {}) as TeamAccessSettings).version! >= 2}
|
||||
getTeamTasks={getTeamTasks}
|
||||
getKnownUsers={getKnownUsers}
|
||||
onApprove={approveTask}
|
||||
onReject={rejectTask}
|
||||
getTaskDetail={getTaskDetail}
|
||||
approveTask={approveTask}
|
||||
rejectTask={rejectTask}
|
||||
addTaskComment={addTaskComment}
|
||||
createTask={createTask}
|
||||
assignTask={assignTask}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
@@ -147,6 +181,10 @@ export function TeamDetailPage({ teamId, onBack }: TeamDetailPageProps) {
|
||||
<TeamDelegationsTab teamId={teamId} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="workspace" className="mt-4">
|
||||
<TeamWorkspaceTab teamId={teamId} scopes={scopes} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="settings" className="mt-4">
|
||||
<TeamSettingsTab teamId={teamId} team={team} onSaved={reload} />
|
||||
</TabsContent>
|
||||
|
||||
@@ -3,11 +3,13 @@ import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { X, Save, Check, Bell } from "lucide-react";
|
||||
import { X, Save, Check, Bell, ShieldAlert, Clock, Info } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CHANNEL_TYPES } from "@/constants/channels";
|
||||
import type { TeamData, TeamAccessSettings } from "@/types/team";
|
||||
import type { TeamData, TeamAccessSettings, EscalationMode, EscalationAction } from "@/types/team";
|
||||
import { ESCALATION_ACTIONS } from "@/types/team";
|
||||
import { useTeams } from "./hooks/use-teams";
|
||||
import { TeamVersionModal } from "./team-version-modal";
|
||||
|
||||
interface TeamSettingsTabProps {
|
||||
teamId: string;
|
||||
@@ -65,15 +67,22 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps)
|
||||
|
||||
// Parse initial settings
|
||||
const initial = (team.settings ?? {}) as TeamAccessSettings;
|
||||
const [version, setVersion] = useState<number>(initial.version ?? 1);
|
||||
const [allowUserIds, setAllowUserIds] = useState<string[]>(initial.allow_user_ids ?? []);
|
||||
const [denyUserIds, setDenyUserIds] = useState<string[]>(initial.deny_user_ids ?? []);
|
||||
const [allowChannels, setAllowChannels] = useState<string[]>(initial.allow_channels ?? []);
|
||||
const [denyChannels, setDenyChannels] = useState<string[]>(initial.deny_channels ?? []);
|
||||
const [progressNotifications, setProgressNotifications] = useState(initial.progress_notifications ?? false);
|
||||
const [escalationMode, setEscalationMode] = useState<EscalationMode | "">(initial.escalation_mode ?? "");
|
||||
const [escalationActions, setEscalationActions] = useState<EscalationAction[]>(initial.escalation_actions ?? []);
|
||||
const [followupInterval, setFollowupInterval] = useState<number>(initial.followup_interval_minutes ?? 30);
|
||||
const [followupMaxReminders, setFollowupMaxReminders] = useState<number>(initial.followup_max_reminders ?? 0);
|
||||
const isTeamV2 = version >= 2;
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [versionModalOpen, setVersionModalOpen] = useState(false);
|
||||
|
||||
// Load known users for combobox
|
||||
useEffect(() => {
|
||||
@@ -83,11 +92,16 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps)
|
||||
// Reset when team changes
|
||||
useEffect(() => {
|
||||
const s = (team.settings ?? {}) as TeamAccessSettings;
|
||||
setVersion(s.version ?? 1);
|
||||
setAllowUserIds(s.allow_user_ids ?? []);
|
||||
setDenyUserIds(s.deny_user_ids ?? []);
|
||||
setAllowChannels(s.allow_channels ?? []);
|
||||
setDenyChannels(s.deny_channels ?? []);
|
||||
setProgressNotifications(s.progress_notifications ?? false);
|
||||
setEscalationMode(s.escalation_mode ?? "");
|
||||
setEscalationActions(s.escalation_actions ?? []);
|
||||
setFollowupInterval(s.followup_interval_minutes ?? 30);
|
||||
setFollowupMaxReminders(s.followup_max_reminders ?? 0);
|
||||
setSaved(false);
|
||||
setError(null);
|
||||
}, [team]);
|
||||
@@ -103,6 +117,13 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps)
|
||||
if (allowChannels.length > 0) settings.allow_channels = allowChannels;
|
||||
if (denyChannels.length > 0) settings.deny_channels = denyChannels;
|
||||
if (progressNotifications) settings.progress_notifications = true;
|
||||
if (escalationMode) {
|
||||
settings.escalation_mode = escalationMode;
|
||||
if (escalationActions.length > 0) settings.escalation_actions = escalationActions;
|
||||
}
|
||||
if (followupInterval !== 30) settings.followup_interval_minutes = followupInterval;
|
||||
if (followupMaxReminders !== 0) settings.followup_max_reminders = followupMaxReminders;
|
||||
if (version >= 2) settings.version = version;
|
||||
await updateTeamSettings(teamId, settings);
|
||||
setSaved(true);
|
||||
onSaved();
|
||||
@@ -112,13 +133,63 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps)
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [teamId, allowUserIds, denyUserIds, allowChannels, denyChannels, progressNotifications, updateTeamSettings, onSaved, t]);
|
||||
}, [teamId, version, allowUserIds, denyUserIds, allowChannels, denyChannels, progressNotifications, escalationMode, escalationActions, followupInterval, followupMaxReminders, updateTeamSettings, onSaved, t]);
|
||||
|
||||
const userOptions = knownUsers.map((u) => ({ value: u, label: u }));
|
||||
const channelOptions = CHANNEL_TYPES.map((c) => ({ value: c.value, label: c.label }));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Team Version */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium">{t("settings.teamVersion")}</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVersionModalOpen(true)}
|
||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Info className="h-3.5 w-3.5" />
|
||||
{t("settings.whatsNew")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVersion(1)}
|
||||
className={
|
||||
"rounded-lg border p-3 text-left transition-colors " +
|
||||
(version === 1
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/50")
|
||||
}
|
||||
>
|
||||
<div className="text-sm font-medium">V1 — {t("settings.versionBasic")}</div>
|
||||
<div className="mt-0.5 text-xs text-muted-foreground">
|
||||
{t("settings.versionBasicDesc")}
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVersion(2)}
|
||||
className={
|
||||
"rounded-lg border p-3 text-left transition-colors " +
|
||||
(version >= 2
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/50")
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm font-medium">V2 — {t("settings.versionAdvanced")}</span>
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">Beta</Badge>
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-muted-foreground">
|
||||
{t("settings.versionAdvancedDesc")}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User Access Control */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">{t("settings.userAccessControl")}</h3>
|
||||
@@ -205,6 +276,126 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Escalation Policy (V2 only) */}
|
||||
{isTeamV2 && <div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">{t("settings.escalationPolicy")}</h3>
|
||||
<div className="rounded-lg border bg-gradient-to-r from-orange-500/5 to-red-500/5 p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="rounded-lg bg-orange-500/10 p-2.5 text-orange-600 dark:text-orange-400">
|
||||
<ShieldAlert className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex-1 space-y-4">
|
||||
<div className="space-y-1">
|
||||
<span className="text-sm font-semibold">{t("settings.escalationMode")}</span>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
{t("settings.escalationModeHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{([
|
||||
{ value: "", labelKey: "escalationModeNone", descKey: "escalationModeNoneDesc" },
|
||||
{ value: "auto", labelKey: "escalationModeAuto", descKey: "escalationModeAutoDesc" },
|
||||
{ value: "review", labelKey: "escalationModeReview", descKey: "escalationModeReviewDesc" },
|
||||
{ value: "reject", labelKey: "escalationModeReject", descKey: "escalationModeRejectDesc" },
|
||||
] as const).map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => setEscalationMode(opt.value as EscalationMode | "")}
|
||||
className={
|
||||
"rounded-lg border p-3 text-left transition-colors " +
|
||||
(escalationMode === opt.value
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/50")
|
||||
}
|
||||
>
|
||||
<div className="text-sm font-medium">{t(`settings.${opt.labelKey}`)}</div>
|
||||
<div className="mt-0.5 text-xs text-muted-foreground">
|
||||
{t(`settings.${opt.descKey}`)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{escalationMode && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">{t("settings.escalationActions")}</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.escalationActionsHint")}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{ESCALATION_ACTIONS.map((action) => (
|
||||
<button
|
||||
key={action}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEscalationActions((prev) =>
|
||||
prev.includes(action)
|
||||
? prev.filter((a) => a !== action)
|
||||
: [...prev, action],
|
||||
);
|
||||
}}
|
||||
className={
|
||||
"rounded-md border px-3 py-1.5 text-xs font-medium transition-colors " +
|
||||
(escalationActions.includes(action)
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border text-muted-foreground hover:border-primary/50")
|
||||
}
|
||||
>
|
||||
{action}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{/* Follow-up Reminders (V2 only) */}
|
||||
{isTeamV2 && <div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">{t("settings.followupReminders")}</h3>
|
||||
<div className="rounded-lg border bg-gradient-to-r from-amber-500/5 to-yellow-500/5 p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="rounded-lg bg-amber-500/10 p-2.5 text-amber-600 dark:text-amber-400">
|
||||
<Clock className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex-1 space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">{t("settings.followupInterval")}</label>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
{t("settings.followupIntervalHint")}
|
||||
</p>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={1440}
|
||||
value={followupInterval}
|
||||
onChange={(e) => setFollowupInterval(Math.max(1, parseInt(e.target.value) || 30))}
|
||||
className="w-24 rounded-md border bg-background px-3 py-1.5 text-base md:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">{t("settings.followupMaxReminders")}</label>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
{t("settings.followupMaxRemindersHint")}
|
||||
</p>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={followupMaxReminders}
|
||||
onChange={(e) => setFollowupMaxReminders(Math.max(0, parseInt(e.target.value) || 0))}
|
||||
className="w-24 rounded-md border bg-background px-3 py-1.5 text-base md:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{/* Save button */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button onClick={handleSave} disabled={saving} className="gap-2">
|
||||
@@ -222,6 +413,8 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps)
|
||||
</Button>
|
||||
{error && <span className="text-sm text-destructive">{error}</span>}
|
||||
</div>
|
||||
|
||||
<TeamVersionModal open={versionModalOpen} onOpenChange={setVersionModalOpen} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,118 +1,189 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { RefreshCw, Plus } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMinLoading } from "@/hooks/use-min-loading";
|
||||
import { useWsEvent } from "@/hooks/use-ws-event";
|
||||
import { Events } from "@/api/protocol";
|
||||
import { toast } from "@/stores/use-toast-store";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { TeamTaskData } from "@/types/team";
|
||||
import type { TeamTaskData, TeamTaskComment, TeamTaskEvent, TeamTaskAttachment, ScopeEntry } from "@/types/team";
|
||||
import type { TeamMemberData } from "@/types/team";
|
||||
import { TaskList } from "./task-sections";
|
||||
|
||||
type StatusFilter = "" | "in_review" | "completed" | "all";
|
||||
|
||||
interface TeamTasksTabProps {
|
||||
teamId: string;
|
||||
getTeamTasks: (teamId: string, statusFilter?: string, userId?: string) => Promise<{ tasks: TeamTaskData[]; count: number }>;
|
||||
getKnownUsers: (teamId: string) => Promise<string[]>;
|
||||
onApprove: (taskId: string) => Promise<void>;
|
||||
onReject: (taskId: string, reason?: string) => Promise<void>;
|
||||
members: TeamMemberData[];
|
||||
scopes?: ScopeEntry[];
|
||||
isTeamV2?: boolean;
|
||||
getTeamTasks: (teamId: string, status?: string, channel?: string, chatId?: string) => Promise<{ tasks: TeamTaskData[]; count: number }>;
|
||||
getTaskDetail: (teamId: string, taskId: string) => Promise<{
|
||||
task: TeamTaskData;
|
||||
comments: TeamTaskComment[];
|
||||
events: TeamTaskEvent[];
|
||||
attachments: TeamTaskAttachment[];
|
||||
}>;
|
||||
approveTask: (teamId: string, taskId: string, comment?: string) => Promise<void>;
|
||||
rejectTask: (teamId: string, taskId: string, reason?: string) => Promise<void>;
|
||||
addTaskComment: (taskId: string, content: string, teamId?: string) => Promise<void>;
|
||||
createTask: (teamId: string, params: { subject: string; description?: string; priority?: number; taskType?: string; assignTo?: string; channel?: string; chatId?: string }) => Promise<TeamTaskData>;
|
||||
assignTask: (teamId: string, taskId: string, agentId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const STATUS_FILTERS = ["all", "active", "completed"] as const;
|
||||
const v1Filters: { value: StatusFilter; labelKey: string }[] = [
|
||||
{ value: "", labelKey: "tasks.filters.active" },
|
||||
{ value: "completed", labelKey: "tasks.filters.completed" },
|
||||
{ value: "all", labelKey: "tasks.filters.all" },
|
||||
];
|
||||
|
||||
export function TeamTasksTab({ teamId, getTeamTasks, getKnownUsers, onApprove, onReject }: TeamTasksTabProps) {
|
||||
const v2Filters: { value: StatusFilter; labelKey: string }[] = [
|
||||
{ value: "", labelKey: "tasks.filters.active" },
|
||||
{ value: "in_review", labelKey: "tasks.filters.inReview" },
|
||||
{ value: "completed", labelKey: "tasks.filters.completed" },
|
||||
{ value: "all", labelKey: "tasks.filters.all" },
|
||||
];
|
||||
|
||||
export function TeamTasksTab({
|
||||
teamId, members, scopes, isTeamV2,
|
||||
getTeamTasks,
|
||||
getTaskDetail, approveTask, rejectTask, addTaskComment,
|
||||
createTask, assignTask,
|
||||
}: TeamTasksTabProps) {
|
||||
const { t } = useTranslation("teams");
|
||||
const [tasks, setTasks] = useState<TeamTaskData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("");
|
||||
const [selectedScope, setSelectedScope] = useState<ScopeEntry | null>(null);
|
||||
const spinning = useMinLoading(loading);
|
||||
|
||||
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||
const [userFilter, setUserFilter] = useState<string>("");
|
||||
const [knownUsers, setKnownUsers] = useState<string[]>([]);
|
||||
|
||||
// Load known users once
|
||||
useEffect(() => {
|
||||
getKnownUsers(teamId).then(setKnownUsers).catch(() => {});
|
||||
}, [teamId, getKnownUsers]);
|
||||
// Create dialog state
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [subject, setSubject] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [priority, setPriority] = useState(0);
|
||||
const [taskType, setTaskType] = useState("general");
|
||||
const [assignTo, setAssignTo] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getTeamTasks(teamId, statusFilter, userFilter || undefined);
|
||||
const res = await getTeamTasks(teamId, statusFilter || undefined, selectedScope?.channel, selectedScope?.chat_id);
|
||||
setTasks(res.tasks ?? []);
|
||||
} catch {
|
||||
// ignore
|
||||
} catch (err) {
|
||||
console.error("[TeamTasksTab] load failed:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [teamId, getTeamTasks, statusFilter, userFilter]);
|
||||
}, [teamId, getTeamTasks, statusFilter, selectedScope]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const handleApprove = useCallback(async (taskId: string) => {
|
||||
try {
|
||||
await onApprove(taskId);
|
||||
toast.success(t("toast.taskApproved"));
|
||||
load();
|
||||
} catch {
|
||||
toast.error(t("toast.failedApproveTask"));
|
||||
}
|
||||
}, [onApprove, load, t]);
|
||||
// Auto-reload task list on real-time team task events.
|
||||
const reloadOnEvent = useCallback((payload: unknown) => {
|
||||
const p = payload as { team_id?: string };
|
||||
if (!p?.team_id || p.team_id === teamId) load();
|
||||
}, [teamId, load]);
|
||||
useWsEvent(Events.TEAM_TASK_CREATED, reloadOnEvent);
|
||||
useWsEvent(Events.TEAM_TASK_CLAIMED, reloadOnEvent);
|
||||
useWsEvent(Events.TEAM_TASK_COMPLETED, reloadOnEvent);
|
||||
useWsEvent(Events.TEAM_TASK_CANCELLED, reloadOnEvent);
|
||||
useWsEvent(Events.TEAM_TASK_REVIEWED, reloadOnEvent);
|
||||
useWsEvent(Events.TEAM_TASK_APPROVED, reloadOnEvent);
|
||||
useWsEvent(Events.TEAM_TASK_REJECTED, reloadOnEvent);
|
||||
useWsEvent(Events.TEAM_TASK_PROGRESS, reloadOnEvent);
|
||||
useWsEvent(Events.TEAM_TASK_ASSIGNED, reloadOnEvent);
|
||||
|
||||
const handleReject = useCallback(async (taskId: string, reason?: string) => {
|
||||
const handleOpenCreate = () => {
|
||||
setSubject("");
|
||||
setDescription("");
|
||||
setPriority(0);
|
||||
setTaskType("general");
|
||||
setAssignTo("");
|
||||
setCreateOpen(true);
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!subject.trim()) return;
|
||||
setCreating(true);
|
||||
try {
|
||||
await onReject(taskId, reason);
|
||||
toast.success(t("toast.taskCancelled"));
|
||||
await createTask(teamId, {
|
||||
subject: subject.trim(),
|
||||
description: description.trim() || undefined,
|
||||
priority,
|
||||
taskType: taskType || undefined,
|
||||
assignTo: assignTo || undefined,
|
||||
channel: selectedScope?.channel || undefined,
|
||||
chatId: selectedScope?.chat_id || undefined,
|
||||
});
|
||||
toast.success(t("toast.taskCreated"));
|
||||
setCreateOpen(false);
|
||||
load();
|
||||
} catch {
|
||||
toast.error(t("toast.failedCancelTask"));
|
||||
toast.error(t("toast.failedCreateTask"));
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}, [onReject, load, t]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Filter bar */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Status filter toggle buttons */}
|
||||
<div className="flex rounded-md border">
|
||||
{STATUS_FILTERS.map((f) => (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Status filter toggle */}
|
||||
<div className="flex rounded-lg border bg-muted/50 p-0.5">
|
||||
{(isTeamV2 ? v2Filters : v1Filters).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setStatusFilter(f)}
|
||||
key={f.value}
|
||||
onClick={() => setStatusFilter(f.value)}
|
||||
className={
|
||||
"px-3 py-1.5 text-xs font-medium transition-colors first:rounded-l-md last:rounded-r-md" +
|
||||
(statusFilter === f
|
||||
? " bg-primary text-primary-foreground"
|
||||
: " hover:bg-muted")
|
||||
"rounded-md px-3 py-1 text-xs font-medium transition-colors " +
|
||||
(statusFilter === f.value
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground")
|
||||
}
|
||||
>
|
||||
{t(`tasks.filter.${f}`)}
|
||||
{t(f.labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* User filter dropdown */}
|
||||
{knownUsers.length > 0 && (
|
||||
<Select value={userFilter} onValueChange={(v) => setUserFilter(v === "_all" ? "" : v)}>
|
||||
<SelectTrigger className="h-8 w-auto min-w-[140px] text-xs">
|
||||
<SelectValue placeholder={t("tasks.filter.allUsers")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" className="max-h-[50vh] sm:max-h-96">
|
||||
<SelectItem value="_all">{t("tasks.filter.allUsers")}</SelectItem>
|
||||
{knownUsers.map((u) => (
|
||||
<SelectItem key={u} value={u}>{u}</SelectItem>
|
||||
</div>
|
||||
{/* Scope filter */}
|
||||
{scopes && scopes.length > 0 && (
|
||||
<select
|
||||
value={selectedScope ? `${selectedScope.channel}:${selectedScope.chat_id}` : ""}
|
||||
onChange={(e) => {
|
||||
if (!e.target.value) {
|
||||
setSelectedScope(null);
|
||||
} else {
|
||||
const idx = e.target.value.indexOf(":");
|
||||
setSelectedScope({ channel: e.target.value.slice(0, idx), chat_id: e.target.value.slice(idx + 1) });
|
||||
}
|
||||
}}
|
||||
className="rounded-md border bg-background px-2 py-1 text-base md:text-sm"
|
||||
>
|
||||
<option value="">{t("scope.all")}</option>
|
||||
{scopes.map((s) => (
|
||||
<option key={`${s.channel}:${s.chat_id}`} value={`${s.channel}:${s.chat_id}`}>
|
||||
{s.channel}:{s.chat_id.length > 12 ? s.chat_id.slice(0, 12) + "…" : s.chat_id}
|
||||
</option>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
<div className="ml-auto">
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleOpenCreate} className="gap-1">
|
||||
<Plus className="h-3.5 w-3.5" /> {t("tasks.createTask")}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={load} disabled={spinning} className="gap-1">
|
||||
<RefreshCw className={"h-3.5 w-3.5" + (spinning ? " animate-spin" : "")} /> {t("tasks.refresh")}
|
||||
</Button>
|
||||
@@ -122,9 +193,114 @@ export function TeamTasksTab({ teamId, getTeamTasks, getKnownUsers, onApprove, o
|
||||
<TaskList
|
||||
tasks={tasks}
|
||||
loading={loading}
|
||||
onApprove={handleApprove}
|
||||
onReject={handleReject}
|
||||
teamId={teamId}
|
||||
members={members}
|
||||
isTeamV2={isTeamV2}
|
||||
getTaskDetail={getTaskDetail}
|
||||
approveTask={approveTask}
|
||||
rejectTask={rejectTask}
|
||||
addTaskComment={addTaskComment}
|
||||
assignTask={assignTask}
|
||||
/>
|
||||
|
||||
{/* Create Task Dialog */}
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogContent className="w-[95vw] sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("tasks.createTaskTitle")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
{/* Subject */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">
|
||||
{t("tasks.columns.subject")} *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
placeholder={t("tasks.subjectPlaceholder")}
|
||||
className="w-full rounded-md border bg-background px-3 py-2 text-base md:text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">
|
||||
{t("tasks.detail.description")}
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder={t("tasks.descriptionPlaceholder")}
|
||||
rows={4}
|
||||
className="w-full resize-none rounded-md border bg-background px-3 py-2 text-base md:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Task Type + Priority row */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">
|
||||
{t("tasks.detail.type").replace(":", "")}
|
||||
</label>
|
||||
<select
|
||||
value={taskType}
|
||||
onChange={(e) => setTaskType(e.target.value)}
|
||||
className="w-full rounded-md border bg-background px-3 py-2 text-base md:text-sm"
|
||||
>
|
||||
<option value="general">General</option>
|
||||
<option value="delegation">Delegation</option>
|
||||
<option value="escalation">Escalation</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">
|
||||
{t("tasks.priorityLabel")}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(Number(e.target.value))}
|
||||
min={0}
|
||||
className="w-full rounded-md border bg-background px-3 py-2 text-base md:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Assign To */}
|
||||
{members.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">
|
||||
{t("tasks.assignTo")}
|
||||
</label>
|
||||
<select
|
||||
value={assignTo}
|
||||
onChange={(e) => setAssignTo(e.target.value)}
|
||||
className="w-full rounded-md border bg-background px-3 py-2 text-base md:text-sm"
|
||||
>
|
||||
<option value="">{t("tasks.unassigned")}</option>
|
||||
{members.map((m) => (
|
||||
<option key={m.agent_id} value={m.agent_id}>
|
||||
{m.display_name || m.agent_key || m.agent_id}
|
||||
{m.role === "lead" ? " (Lead)" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setCreateOpen(false)} disabled={creating}>
|
||||
{t("create.cancel")}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleCreate} disabled={creating || !subject.trim()}>
|
||||
{creating ? t("create.creating") : t("tasks.createTask")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Check, Minus } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface TeamVersionModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const FEATURES = [
|
||||
{ key: "taskManagement", v1: true },
|
||||
{ key: "sharedWorkspace", v1: true },
|
||||
{ key: "executionLocking", v1: false },
|
||||
{ key: "followupReminders", v1: false },
|
||||
{ key: "reviewWorkflow", v1: false },
|
||||
{ key: "progressTracking", v1: false },
|
||||
{ key: "commentsAudit", v1: false },
|
||||
{ key: "autoRecovery", v1: false },
|
||||
{ key: "escalationPolicy", v1: false },
|
||||
] as const;
|
||||
|
||||
export function TeamVersionModal({ open, onOpenChange }: TeamVersionModalProps) {
|
||||
const { t } = useTranslation("teams");
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-[95vw] sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("settings.versionModal.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[400px] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="py-2 pr-4 text-left font-medium">{t("settings.versionModal.feature")}</th>
|
||||
<th className="w-16 py-2 text-center font-medium">V1</th>
|
||||
<th className="w-16 py-2 text-center font-medium">V2</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{FEATURES.map((f) => (
|
||||
<tr key={f.key} className="border-b last:border-0">
|
||||
<td className="py-2.5 pr-4">
|
||||
<div className="font-medium">{t(`settings.versionModal.${f.key}`)}</div>
|
||||
<div className="text-xs text-muted-foreground">{t(`settings.versionModal.${f.key}Desc`)}</div>
|
||||
</td>
|
||||
<td className="py-2.5 text-center">
|
||||
{f.v1 ? (
|
||||
<Check className="mx-auto h-4 w-4 text-green-600 dark:text-green-400" />
|
||||
) : (
|
||||
<Minus className="mx-auto h-4 w-4 text-muted-foreground/40" />
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2.5 text-center">
|
||||
<Check className="mx-auto h-4 w-4 text-green-600 dark:text-green-400" />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 text-xs text-muted-foreground">
|
||||
<p>{t("settings.versionModal.downgradeNote")}</p>
|
||||
<p>{t("settings.versionModal.betaNote")}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button variant="outline" size="sm" onClick={() => onOpenChange(false)}>
|
||||
{t("settings.versionModal.gotIt")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { RefreshCw, Trash2, FileText, Pin } from "lucide-react";
|
||||
import { ConfirmDeleteDialog } from "@/components/shared/confirm-delete-dialog";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMinLoading } from "@/hooks/use-min-loading";
|
||||
import { useTeamWorkspace } from "./hooks/use-team-workspace";
|
||||
import type { TeamWorkspaceFile, ScopeEntry } from "@/types/team";
|
||||
|
||||
interface TeamWorkspaceTabProps {
|
||||
teamId: string;
|
||||
scopes?: ScopeEntry[];
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${bytes} B`;
|
||||
}
|
||||
|
||||
function formatDate(dateStr?: string): string {
|
||||
if (!dateStr) return "-";
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
export function TeamWorkspaceTab({ teamId, scopes }: TeamWorkspaceTabProps) {
|
||||
const { t } = useTranslation("teams");
|
||||
const { files, loading, listFiles, readFile, deleteFile } =
|
||||
useTeamWorkspace();
|
||||
const [selectedFile, setSelectedFile] = useState<{
|
||||
file: TeamWorkspaceFile;
|
||||
content: string;
|
||||
} | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
||||
const [selectedScope, setSelectedScope] = useState<ScopeEntry | null>(null);
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
const spinning = useMinLoading(loading);
|
||||
|
||||
const load = useCallback(() => {
|
||||
listFiles(teamId, selectedScope?.channel, selectedScope?.chat_id).then(() => {
|
||||
setInitialized(true);
|
||||
});
|
||||
}, [teamId, listFiles, selectedScope]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
async (file: TeamWorkspaceFile) => {
|
||||
try {
|
||||
const res = await readFile(teamId, file.file_name, file.channel, file.chat_id);
|
||||
setSelectedFile({ file: res.file ?? file, content: res.content ?? "" });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
[teamId, readFile],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (fileName: string) => {
|
||||
const file = files.find((f) => f.file_name === fileName);
|
||||
try {
|
||||
await deleteFile(teamId, fileName, file?.channel, file?.chat_id);
|
||||
setDeleteTarget(null);
|
||||
load();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
[teamId, files, deleteFile, load],
|
||||
);
|
||||
|
||||
if (!initialized) {
|
||||
return (
|
||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-medium">{t("workspace.title")}</h3>
|
||||
{scopes && scopes.length > 0 && (
|
||||
<select
|
||||
value={selectedScope ? `${selectedScope.channel}:${selectedScope.chat_id}` : ""}
|
||||
onChange={(e) => {
|
||||
if (!e.target.value) {
|
||||
setSelectedScope(null);
|
||||
} else {
|
||||
const idx = e.target.value.indexOf(":");
|
||||
setSelectedScope({ channel: e.target.value.slice(0, idx), chat_id: e.target.value.slice(idx + 1) });
|
||||
}
|
||||
}}
|
||||
className="rounded-md border bg-background px-2 py-1 text-base md:text-sm"
|
||||
>
|
||||
<option value="">{t("scope.all")}</option>
|
||||
{scopes.map((s) => (
|
||||
<option key={`${s.channel}:${s.chat_id}`} value={`${s.channel}:${s.chat_id}`}>
|
||||
{s.channel}:{s.chat_id.length > 12 ? s.chat_id.slice(0, 12) + "…" : s.chat_id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={load} disabled={spinning}>
|
||||
<RefreshCw className={`mr-1.5 h-3.5 w-3.5 ${spinning ? "animate-spin" : ""}`} />
|
||||
{t("workspace.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{files.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<FileText className="mx-auto mb-3 h-10 w-10 text-muted-foreground/30" />
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
{t("workspace.noFiles")}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground/70">
|
||||
{t("workspace.noFilesDescription")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[600px] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="pb-2 font-medium">{t("workspace.columns.fileName")}</th>
|
||||
<th className="pb-2 font-medium">{t("workspace.columns.size")}</th>
|
||||
<th className="pb-2 font-medium">{t("workspace.columns.uploadedBy")}</th>
|
||||
<th className="pb-2 font-medium">{t("workspace.columns.scope")}</th>
|
||||
<th className="pb-2 font-medium">{t("workspace.columns.updated")}</th>
|
||||
<th className="pb-2 font-medium" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{files.map((file) => (
|
||||
<tr
|
||||
key={file.id}
|
||||
className="cursor-pointer border-b border-border/50 transition-colors hover:bg-muted/50"
|
||||
onClick={() => handleRowClick(file)}
|
||||
>
|
||||
<td className="py-2.5 pr-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{file.pinned && <Pin className="h-3 w-3 text-amber-500" />}
|
||||
<span className="font-mono text-xs">{file.file_name}</span>
|
||||
{file.missing && (
|
||||
<Badge variant="destructive" className="text-[10px]">
|
||||
missing
|
||||
</Badge>
|
||||
)}
|
||||
{file.tags?.map((tag) => (
|
||||
<Badge
|
||||
key={tag}
|
||||
variant={tag === "deliverable" ? "success" : "secondary"}
|
||||
className="text-[10px]"
|
||||
>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 text-muted-foreground">
|
||||
{formatBytes(file.size_bytes)}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 text-muted-foreground">
|
||||
{file.uploaded_by_key || file.uploaded_by?.slice(0, 8)}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 text-muted-foreground">
|
||||
{file.channel || "default"}:{file.chat_id?.slice(0, 12) || "default"}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 text-muted-foreground">
|
||||
{formatDate(file.updated_at)}
|
||||
</td>
|
||||
<td className="py-2.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDeleteTarget(file.file_name);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File content dialog */}
|
||||
<Dialog open={!!selectedFile} onOpenChange={() => setSelectedFile(null)}>
|
||||
<DialogContent className="max-h-[80vh] max-w-2xl overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="font-mono text-sm">
|
||||
{selectedFile?.file.file_name}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<pre className="mt-2 max-h-[60vh] overflow-auto rounded-md bg-muted p-4 text-xs">
|
||||
{selectedFile?.content || t("workspace.detail.empty")}
|
||||
</pre>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete confirmation */}
|
||||
<ConfirmDeleteDialog
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={() => setDeleteTarget(null)}
|
||||
title={t("workspace.delete")}
|
||||
description={t("workspace.confirmDelete")}
|
||||
confirmValue={deleteTarget ?? ""}
|
||||
onConfirm={() => deleteTarget && handleDelete(deleteTarget)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,23 @@
|
||||
/** Team data types matching Go internal/store/team_store.go */
|
||||
|
||||
export type EscalationMode = "auto" | "review" | "reject";
|
||||
|
||||
export const ESCALATION_ACTIONS = ["pin", "unpin", "tag", "set_template", "delete"] as const;
|
||||
export type EscalationAction = (typeof ESCALATION_ACTIONS)[number];
|
||||
|
||||
export interface TeamAccessSettings {
|
||||
version?: number;
|
||||
allow_user_ids?: string[];
|
||||
deny_user_ids?: string[];
|
||||
allow_channels?: string[];
|
||||
deny_channels?: string[];
|
||||
progress_notifications?: boolean;
|
||||
escalation_mode?: EscalationMode;
|
||||
escalation_actions?: EscalationAction[];
|
||||
followup_interval_minutes?: number;
|
||||
followup_max_reminders?: number;
|
||||
workspace_scope?: string;
|
||||
workspace_quota_mb?: number;
|
||||
}
|
||||
|
||||
export interface TeamData {
|
||||
@@ -13,6 +25,7 @@ export interface TeamData {
|
||||
name: string;
|
||||
lead_agent_id: string;
|
||||
lead_agent_key?: string;
|
||||
lead_display_name?: string;
|
||||
description?: string;
|
||||
status: "active" | "archived";
|
||||
settings?: Record<string, unknown>;
|
||||
@@ -31,12 +44,31 @@ export interface TeamMemberData {
|
||||
joined_at?: string;
|
||||
}
|
||||
|
||||
export interface TeamWorkspaceFile {
|
||||
id: string;
|
||||
team_id: string;
|
||||
channel: string;
|
||||
chat_id: string;
|
||||
file_name: string;
|
||||
mime_type?: string;
|
||||
size_bytes: number;
|
||||
uploaded_by: string;
|
||||
uploaded_by_key?: string;
|
||||
task_id?: string;
|
||||
pinned: boolean;
|
||||
tags?: string[];
|
||||
archived_at?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
missing?: boolean;
|
||||
}
|
||||
|
||||
export interface TeamTaskData {
|
||||
id: string;
|
||||
team_id: string;
|
||||
subject: string;
|
||||
description?: string;
|
||||
status: "pending" | "pending_approval" | "in_progress" | "completed" | "blocked" | "failed";
|
||||
status: "pending" | "in_progress" | "completed" | "blocked" | "failed" | "in_review" | "cancelled";
|
||||
owner_agent_id?: string;
|
||||
owner_agent_key?: string;
|
||||
blocked_by?: string[];
|
||||
@@ -45,4 +77,58 @@ export interface TeamTaskData {
|
||||
user_id?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
// V2 fields
|
||||
task_type?: string;
|
||||
task_number?: number;
|
||||
identifier?: string;
|
||||
created_by_agent_id?: string;
|
||||
created_by_agent_key?: string;
|
||||
assignee_user_id?: string;
|
||||
parent_id?: string;
|
||||
chat_id?: string;
|
||||
locked_at?: string;
|
||||
lock_expires_at?: string;
|
||||
progress_percent?: number;
|
||||
progress_step?: string;
|
||||
// Follow-up reminder fields
|
||||
followup_at?: string;
|
||||
followup_count?: number;
|
||||
followup_max?: number;
|
||||
followup_message?: string;
|
||||
followup_channel?: string;
|
||||
followup_chat_id?: string;
|
||||
}
|
||||
|
||||
export interface TeamTaskComment {
|
||||
id: string;
|
||||
task_id: string;
|
||||
agent_id?: string;
|
||||
user_id?: string;
|
||||
agent_key?: string;
|
||||
content: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface TeamTaskEvent {
|
||||
id: string;
|
||||
task_id: string;
|
||||
event_type: string;
|
||||
actor_type: "agent" | "human";
|
||||
actor_id: string;
|
||||
data?: Record<string, unknown>;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ScopeEntry {
|
||||
channel: string;
|
||||
chat_id: string;
|
||||
}
|
||||
|
||||
export interface TeamTaskAttachment {
|
||||
id: string;
|
||||
task_id: string;
|
||||
file_id: string;
|
||||
added_by?: string;
|
||||
file_name?: string;
|
||||
created_at: string;
|
||||
}
|
||||
Reference in new issue
Block a user