mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-15 06:16:10 +00:00
feat(teams): blocker escalation, audit events, review workflow + docs
- Add comment_type column (note/blocker) with migration 000028 - Blocker comments auto-fail task, cancel member session via EventTeamTaskFailed broadcast, and escalate to leader via InboundMessage (system:escalation bypasses debounce) - Enrich audit events: fix tenant scope bug (context.Background → WithTenantID), add 4 missing types (commented/progress/updated/stale), populate data field (reason, comment_text, progress_percent) - Add blocker escalation config toggle in team settings UI - Add audit logs modal with paginated team events (Clock icon) - Update features modal: all features now available (no coming soon) - Update code comments: reviewer role not yet active, all flows via leader - Update docs: remove v1/v2 versioning, add blocker escalation and review workflow sections, document enriched audit events
This commit is contained in:
1 parent
8d3eb5bb6f
commit
ae6615d962
26 files changed
+678
-116
No files matched your search
+30
-1
@@ -2,6 +2,7 @@ package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
@@ -546,11 +547,31 @@ func runGateway() {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if err := teamEventStore.RecordTaskEvent(context.Background(), &store.TeamTaskEventData{
|
||||
|
||||
// Propagate tenant from bus event to ensure correct tenant isolation.
|
||||
auditCtx := store.WithTenantID(context.Background(), evt.TenantID)
|
||||
|
||||
// Populate data field with event-specific context for audit trail.
|
||||
var data json.RawMessage
|
||||
switch evt.Name {
|
||||
case protocol.EventTeamTaskFailed, protocol.EventTeamTaskRejected, protocol.EventTeamTaskCancelled:
|
||||
if payload.Reason != "" {
|
||||
data, _ = json.Marshal(map[string]string{"reason": payload.Reason})
|
||||
}
|
||||
case protocol.EventTeamTaskCommented:
|
||||
if payload.CommentText != "" {
|
||||
data, _ = json.Marshal(map[string]string{"comment_text": payload.CommentText})
|
||||
}
|
||||
case protocol.EventTeamTaskProgress:
|
||||
data, _ = json.Marshal(map[string]any{"progress_percent": payload.ProgressPercent, "progress_step": payload.ProgressStep})
|
||||
}
|
||||
|
||||
if err := teamEventStore.RecordTaskEvent(auditCtx, &store.TeamTaskEventData{
|
||||
TaskID: taskID,
|
||||
EventType: eventType,
|
||||
ActorType: payload.ActorType,
|
||||
ActorID: payload.ActorID,
|
||||
Data: data,
|
||||
}); err != nil {
|
||||
slog.Warn("team_task_audit.record_failed", "task_id", payload.TaskID, "event", eventType, "error", err)
|
||||
}
|
||||
@@ -1060,6 +1081,14 @@ func teamTaskEventType(eventName string) string {
|
||||
return "approved"
|
||||
case protocol.EventTeamTaskRejected:
|
||||
return "rejected"
|
||||
case protocol.EventTeamTaskCommented:
|
||||
return "commented"
|
||||
case protocol.EventTeamTaskProgress:
|
||||
return "progress"
|
||||
case protocol.EventTeamTaskUpdated:
|
||||
return "updated"
|
||||
case protocol.EventTeamTaskStale:
|
||||
return "stale"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -108,6 +108,12 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents
|
||||
continue
|
||||
}
|
||||
|
||||
// Blocker escalation messages bypass debounce — deliver immediately to leader.
|
||||
if msg.SenderID == "system:escalation" {
|
||||
go processNormalMessage(ctx, msg, agents, cfg, sched, channelMgr, teamStore, quotaChecker, sessStore, agentStore, contactCollector, postTurn, msgBus)
|
||||
continue
|
||||
}
|
||||
|
||||
// --- Normal messages: route through debouncer ---
|
||||
debouncer.Push(msg)
|
||||
}
|
||||
|
||||
@@ -118,8 +118,8 @@ Context keys ensure each tool call receives the correct per-call values without
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `team_tasks` | Task board: list, create, claim, complete, search |
|
||||
| `team_message` | Mailbox: send, broadcast, read unread messages |
|
||||
| `team_tasks` | Task board: create, list, get, claim, complete, cancel, assign, review, approve, reject, comment (with type: note/blocker), progress, attach, ask_user, search, update |
|
||||
| `team_message` | Mailbox: send direct message to teammate, broadcast to all, read unread messages |
|
||||
|
||||
### Media Generation
|
||||
|
||||
|
||||
+57
-20
@@ -161,30 +161,42 @@ flowchart TD
|
||||
| `retry` | Re-dispatch stale or failed tasks back to pending | Admin |
|
||||
| `update` | Update task metadata (priority, description, etc.) | Lead |
|
||||
|
||||
### Team Versioning
|
||||
|
||||
Many task actions require **team version >= 2**. Teams created with v1 only support basic actions.
|
||||
|
||||
**V2-Required Actions:**
|
||||
- `approve` — Approve completed task
|
||||
- `reject` — Reject and cancel task
|
||||
- `review` — Submit for review
|
||||
- `comment` — Add comments
|
||||
- `progress` — Update progress
|
||||
- `attach` — Attach files
|
||||
- `update` — Update metadata
|
||||
- `ask_user` — Set reminders
|
||||
- `clear_ask_user` — Cancel reminders
|
||||
- `retry` — Retry failed tasks
|
||||
|
||||
**V1 Teams** support only: `create`, `claim`, `complete`, `cancel`, `assign`, `list`, `get`, `search`
|
||||
|
||||
If a v1 team tries a v2 action, error: `"action 'X' requires team version 2 — upgrade in team settings"`
|
||||
|
||||
### Atomic Claiming
|
||||
|
||||
Two agents grabbing the same task is prevented at the database level. The claim operation uses a conditional update: `SET status = 'in_progress', owner = agent WHERE status = 'pending' AND owner IS NULL`. One row updated means claimed; zero rows means someone else got it first. No distributed mutex needed.
|
||||
|
||||
### Blocker Escalation
|
||||
|
||||
Members can flag themselves as blocked on a task by adding a blocker comment:
|
||||
|
||||
```
|
||||
team_tasks(action="comment", task_id="...", text="Cannot find API documentation", type="blocker")
|
||||
```
|
||||
|
||||
When a blocker comment is posted:
|
||||
1. The comment is saved with `comment_type='blocker'`
|
||||
2. The task is **auto-failed** (status: in_progress → failed)
|
||||
3. An `EventTeamTaskFailed` is broadcast:
|
||||
- Member's session is cancelled via scheduler
|
||||
- Chat channel receives "❌ Task failed" notification
|
||||
- Web UI dashboard updates in real-time
|
||||
4. The **lead agent receives an escalation message** from `system:escalation` with:
|
||||
- Blocked member name
|
||||
- Task number and subject
|
||||
- Blocker reason
|
||||
- Instructions to retry with `team_tasks(action="retry", task_id="...")`
|
||||
|
||||
Blocker escalation is **enabled by default** but can be disabled per-team via settings:
|
||||
```json
|
||||
{
|
||||
"blocker_escalation": {
|
||||
"enabled": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When disabled, blocker comments are saved but do not trigger auto-fail or escalation.
|
||||
|
||||
### Task Dependencies & Blocking
|
||||
|
||||
Tasks can declare `blocked_by` — a list of prerequisite task IDs. When a task has blocking dependencies:
|
||||
@@ -289,6 +301,30 @@ Workspace files can be attached to tasks:
|
||||
- Auto-links files created during task execution
|
||||
- Metadata captures which agent/user attached the file
|
||||
|
||||
### Review Workflow
|
||||
|
||||
Tasks can require human approval before final completion. When creating a task, pass `require_approval: true`:
|
||||
|
||||
```
|
||||
team_tasks(action="create", ..., require_approval=true)
|
||||
```
|
||||
|
||||
**Flow:**
|
||||
|
||||
1. **Create with approval flag**: Task created with status `pending`, `require_approval` set
|
||||
2. **Member submits for review**: When done, member calls `team_tasks(action="review", task_id="...")`
|
||||
- Task transitions to `in_review` status
|
||||
- Emits `EventTeamTaskReviewed` event
|
||||
3. **Human approves**: Via dashboard, human clicks "Approve" → `teams.tasks.approve` RPC
|
||||
- Task transitions to `completed`
|
||||
- Emits `EventTeamTaskApproved` event
|
||||
4. **Human rejects**: Via dashboard, human clicks "Reject" with reason → `teams.tasks.reject` RPC
|
||||
- Task transitions to `cancelled` with reason
|
||||
- Emits `EventTeamTaskRejected` event
|
||||
- Lead receives notification to retry or investigate
|
||||
|
||||
Without `require_approval`, tasks move directly to `completed` after member calls `complete` (no in-review stage).
|
||||
|
||||
---
|
||||
|
||||
## 5. Team Mailbox
|
||||
@@ -546,6 +582,7 @@ Teams support fine-grained access control through team settings.
|
||||
| `followup_max_reminders` | Integer | Max ask_user reminders before escalation |
|
||||
| `escalation_mode` | String | How to escalate stale tasks: "notify_lead", "fail_task" |
|
||||
| `escalation_actions` | String list | Actions to take on escalation |
|
||||
| `blocker_escalation` | Object | Blocker comment escalation settings: `{enabled: true}` (default enabled) |
|
||||
|
||||
System channels (`teammate`, `system`) always pass access checks. Empty settings mean open access.
|
||||
|
||||
|
||||
+100
-12
@@ -270,7 +270,7 @@ Emitted when a human rejects a task via the dashboard (status becomes cancelled
|
||||
```
|
||||
|
||||
#### `team.task.commented`
|
||||
Emitted when a human adds a comment to a task (no status change).
|
||||
Emitted when a human or agent adds a comment to a task. For agent blocker comments, also triggers task failure and leader escalation (see below).
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -279,10 +279,19 @@ Emitted when a human adds a comment to a task (no status change).
|
||||
"user_id": "user123",
|
||||
"channel": "dashboard",
|
||||
"chat_id": "-100123456",
|
||||
"timestamp": "2026-03-05T10:02:45Z"
|
||||
"timestamp": "2026-03-05T10:02:45Z",
|
||||
"actor_type": "human",
|
||||
"actor_id": "user123"
|
||||
}
|
||||
```
|
||||
|
||||
**Agent blocker comments** (type='blocker') trigger special handling:
|
||||
- Comment is saved with `comment_type='blocker'`
|
||||
- Task is auto-failed (transitions to failed status)
|
||||
- `EventTeamTaskFailed` is emitted (see below)
|
||||
- Lead agent receives escalation message via `system:escalation`
|
||||
- Member's session is cancelled
|
||||
|
||||
#### `team.task.deleted`
|
||||
Emitted when a terminal-status task is hard-deleted via the dashboard.
|
||||
|
||||
@@ -301,19 +310,98 @@ Emitted when a terminal-status task is hard-deleted via the dashboard.
|
||||
```
|
||||
|
||||
#### `team.task.failed`
|
||||
Reserved for future use. Emitted when a task auto-execution fails (not currently triggered).
|
||||
Emitted when a task is auto-failed due to blocker escalation or system errors.
|
||||
|
||||
```json
|
||||
{
|
||||
"team_id": "019c9503-...",
|
||||
"task_id": "019ca84f-...",
|
||||
"task_number": 5,
|
||||
"subject": "Create Instagram image",
|
||||
"status": "failed",
|
||||
"reason": "Blocked: Cannot find API documentation",
|
||||
"owner_agent_key": "tieu-la",
|
||||
"owner_display_name": "Tieu La",
|
||||
"user_id": "user123",
|
||||
"channel": "telegram",
|
||||
"chat_id": "-100123456",
|
||||
"timestamp": "2026-03-05T10:02:15Z",
|
||||
"actor_type": "system",
|
||||
"actor_id": "blocker-escalation"
|
||||
}
|
||||
```
|
||||
|
||||
#### `team.task.reviewed`
|
||||
Reserved for future use. Emitted when a task enters review stage (not currently triggered).
|
||||
Emitted when a member submits a task for review via `team_tasks(action="review", task_id="...")`.
|
||||
|
||||
```json
|
||||
{
|
||||
"team_id": "019c9503-...",
|
||||
"task_id": "019ca84f-...",
|
||||
"status": "in_review",
|
||||
"owner_agent_key": "tieu-la",
|
||||
"owner_display_name": "Tieu La",
|
||||
"user_id": "user123",
|
||||
"channel": "dashboard",
|
||||
"chat_id": "-100123456",
|
||||
"timestamp": "2026-03-05T10:02:30Z",
|
||||
"actor_type": "agent",
|
||||
"actor_id": "tieu-la"
|
||||
}
|
||||
```
|
||||
|
||||
#### `team.task.progress`
|
||||
Reserved for future use. Emitted periodically for long-running tasks (not currently triggered).
|
||||
Emitted when a member updates task progress via `team_tasks(action="progress", task_id="...", progress_percent=..., progress_step="...")`.
|
||||
|
||||
```json
|
||||
{
|
||||
"team_id": "019c9503-...",
|
||||
"task_id": "019ca84f-...",
|
||||
"progress_percent": 50,
|
||||
"progress_step": "Waiting for API response",
|
||||
"user_id": "user123",
|
||||
"channel": "system",
|
||||
"chat_id": "-100123456",
|
||||
"timestamp": "2026-03-05T10:02:00Z",
|
||||
"actor_type": "agent",
|
||||
"actor_id": "tieu-la"
|
||||
}
|
||||
```
|
||||
|
||||
#### `team.task.updated`
|
||||
Reserved for future use. Emitted when task metadata is updated (not currently triggered).
|
||||
Emitted when task metadata (priority, description, etc.) is updated via `team_tasks(action="update", task_id="...", ...)`.
|
||||
|
||||
```json
|
||||
{
|
||||
"team_id": "019c9503-...",
|
||||
"task_id": "019ca84f-...",
|
||||
"changes": ["priority", "description"],
|
||||
"user_id": "user123",
|
||||
"channel": "dashboard",
|
||||
"chat_id": "-100123456",
|
||||
"timestamp": "2026-03-05T10:01:30Z",
|
||||
"actor_type": "human",
|
||||
"actor_id": "user123"
|
||||
}
|
||||
```
|
||||
|
||||
#### `team.task.stale`
|
||||
Reserved for future use. Emitted when a task hasn't been updated within a timeout threshold (not currently triggered).
|
||||
Emitted when a task hasn't been updated within a timeout threshold and is marked as stale by the system.
|
||||
|
||||
```json
|
||||
{
|
||||
"team_id": "019c9503-...",
|
||||
"task_id": "019ca84f-...",
|
||||
"status": "stale",
|
||||
"reason": "No activity for 7 days",
|
||||
"user_id": "user123",
|
||||
"channel": "system",
|
||||
"chat_id": "-100123456",
|
||||
"timestamp": "2026-03-05T10:00:00Z",
|
||||
"actor_type": "system",
|
||||
"actor_id": "stale-detector"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -558,11 +646,11 @@ All event name constants are defined in `pkg/protocol/events.go`:
|
||||
| `EventTeamTaskRejected` | `team.task.rejected` | Active |
|
||||
| `EventTeamTaskCommented` | `team.task.commented` | Active |
|
||||
| `EventTeamTaskDeleted` | `team.task.deleted` | Active |
|
||||
| `EventTeamTaskFailed` | `team.task.failed` | Reserved (future) |
|
||||
| `EventTeamTaskReviewed` | `team.task.reviewed` | Reserved (future) |
|
||||
| `EventTeamTaskProgress` | `team.task.progress` | Reserved (future) |
|
||||
| `EventTeamTaskUpdated` | `team.task.updated` | Reserved (future) |
|
||||
| `EventTeamTaskStale` | `team.task.stale` | Reserved (future) |
|
||||
| `EventTeamTaskFailed` | `team.task.failed` | Active |
|
||||
| `EventTeamTaskReviewed` | `team.task.reviewed` | Active |
|
||||
| `EventTeamTaskProgress` | `team.task.progress` | Active |
|
||||
| `EventTeamTaskUpdated` | `team.task.updated` | Active |
|
||||
| `EventTeamTaskStale` | `team.task.stale` | Active |
|
||||
|
||||
### Team CRUD Events
|
||||
| Constant | Event Name |
|
||||
|
||||
@@ -130,6 +130,10 @@ func buildTeamMD(team *store.TeamData, members []store.TeamMemberData, selfID uu
|
||||
sb.WriteString("\n## Communication\n\n")
|
||||
sb.WriteString("Use `team_tasks(action=\"comment\")` to report issues or ask questions on your current task.\n")
|
||||
}
|
||||
sb.WriteString("\n## Blocker Escalation\n\n")
|
||||
sb.WriteString("If blocked (missing info, unclear requirements, need credentials):\n")
|
||||
sb.WriteString("```\nteam_tasks(action=\"comment\", type=\"blocker\", text=\"what you need\")\n```\n")
|
||||
sb.WriteString("This auto-fails the task and notifies the leader, who can retry with updated instructions.\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
|
||||
@@ -20,12 +20,16 @@ func (s *PGTeamStore) AddTaskComment(ctx context.Context, comment *store.TeamTas
|
||||
comment.ID = store.GenNewID()
|
||||
}
|
||||
comment.CreatedAt = time.Now()
|
||||
commentType := comment.CommentType
|
||||
if commentType == "" {
|
||||
commentType = "note"
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO team_task_comments (id, task_id, agent_id, user_id, content, created_at, tenant_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
`INSERT INTO team_task_comments (id, task_id, agent_id, user_id, content, comment_type, created_at, tenant_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||
comment.ID, comment.TaskID, comment.AgentID,
|
||||
sql.NullString{String: comment.UserID, Valid: comment.UserID != ""},
|
||||
comment.Content, comment.CreatedAt, tenantIDForInsert(ctx),
|
||||
comment.Content, commentType, comment.CreatedAt, tenantIDForInsert(ctx),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -39,7 +43,7 @@ func (s *PGTeamStore) AddTaskComment(ctx context.Context, comment *store.TeamTas
|
||||
func (s *PGTeamStore) ListTaskComments(ctx context.Context, taskID uuid.UUID) ([]store.TeamTaskCommentData, error) {
|
||||
tid := tenantIDForInsert(ctx)
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT c.id, c.task_id, c.agent_id, c.user_id, c.content, c.created_at,
|
||||
`SELECT c.id, c.task_id, c.agent_id, c.user_id, c.content, c.comment_type, 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
|
||||
@@ -55,7 +59,7 @@ func (s *PGTeamStore) ListTaskComments(ctx context.Context, taskID uuid.UUID) ([
|
||||
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 {
|
||||
if err := rows.Scan(&c.ID, &c.TaskID, &agentID, &userID, &c.Content, &c.CommentType, &c.CreatedAt, &c.AgentKey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.AgentID = agentID
|
||||
@@ -72,7 +76,7 @@ func (s *PGTeamStore) ListTaskComments(ctx context.Context, taskID uuid.UUID) ([
|
||||
func (s *PGTeamStore) ListRecentTaskComments(ctx context.Context, taskID uuid.UUID, limit int) ([]store.TeamTaskCommentData, error) {
|
||||
tid := tenantIDForInsert(ctx)
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT c.id, c.task_id, c.agent_id, c.user_id, c.content, c.created_at,
|
||||
`SELECT c.id, c.task_id, c.agent_id, c.user_id, c.content, c.comment_type, 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
|
||||
@@ -89,7 +93,7 @@ func (s *PGTeamStore) ListRecentTaskComments(ctx context.Context, taskID uuid.UU
|
||||
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 {
|
||||
if err := rows.Scan(&c.ID, &c.TaskID, &agentID, &userID, &c.Content, &c.CommentType, &c.CreatedAt, &c.AgentKey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.AgentID = agentID
|
||||
|
||||
@@ -137,12 +137,13 @@ type TeamTaskData struct {
|
||||
|
||||
// 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"`
|
||||
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"`
|
||||
CommentType string `json:"comment_type,omitempty"` // "note" (default) or "blocker"
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
// Joined
|
||||
AgentKey string `json:"agent_key,omitempty"`
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package tools
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// BlockerEscalationConfig controls whether blocker comments trigger
|
||||
// auto-fail + leader escalation.
|
||||
type BlockerEscalationConfig struct {
|
||||
Enabled bool `json:"enabled"` // default: true
|
||||
}
|
||||
|
||||
// DefaultBlockerEscalationConfig returns the default (enabled) config.
|
||||
func DefaultBlockerEscalationConfig() BlockerEscalationConfig {
|
||||
return BlockerEscalationConfig{Enabled: true}
|
||||
}
|
||||
|
||||
// ParseBlockerEscalationConfig extracts blocker escalation config from team settings.
|
||||
func ParseBlockerEscalationConfig(settings json.RawMessage) BlockerEscalationConfig {
|
||||
cfg := DefaultBlockerEscalationConfig()
|
||||
if len(settings) == 0 {
|
||||
return cfg
|
||||
}
|
||||
var s struct {
|
||||
BlockerEscalation *struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
} `json:"blocker_escalation"`
|
||||
}
|
||||
if json.Unmarshal(settings, &s) != nil || s.BlockerEscalation == nil {
|
||||
return cfg
|
||||
}
|
||||
if s.BlockerEscalation.Enabled != nil {
|
||||
cfg.Enabled = *s.BlockerEscalation.Enabled
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
||||
)
|
||||
|
||||
// handleBlockerComment auto-fails the task, cancels the member session via
|
||||
// EventTeamTaskFailed broadcast, and escalates to the leader agent.
|
||||
func (t *TeamTasksTool) handleBlockerComment(
|
||||
ctx context.Context,
|
||||
team *store.TeamData,
|
||||
task *store.TeamTaskData,
|
||||
taskID, agentID uuid.UUID,
|
||||
text string,
|
||||
) *Result {
|
||||
// Only escalate if task is still in progress.
|
||||
if task.Status != store.TeamTaskStatusInProgress {
|
||||
return NewResult(fmt.Sprintf(
|
||||
"Blocker comment saved on task #%d \"%s\", but task is already %s — no escalation needed.",
|
||||
task.TaskNumber, task.Subject, task.Status))
|
||||
}
|
||||
|
||||
// Auto-fail the task. FailTask guards with AND status='in_progress' so
|
||||
// concurrent calls are safe — only one succeeds.
|
||||
reason := "Blocked: " + text
|
||||
if len([]rune(reason)) > 500 {
|
||||
reason = string([]rune(reason)[:500])
|
||||
}
|
||||
if err := t.manager.teamStore.FailTask(ctx, taskID, team.ID, reason); err != nil {
|
||||
slog.Warn("blocker: FailTask error", "task_id", taskID, "error", err)
|
||||
// Task may have completed concurrently — not a hard error.
|
||||
return NewResult("Blocker comment saved. Task may have already completed — check task status.")
|
||||
}
|
||||
|
||||
// Broadcast EventTeamTaskFailed — triggers:
|
||||
// 1. Cancel subscriber → sched.CancelSession() → member stops
|
||||
// 2. Notify subscriber → "❌ Task failed" → chat channel (direct outbound)
|
||||
// 3. WS broadcast → web UI dashboard real-time update
|
||||
memberKey := t.manager.agentKeyFromID(ctx, agentID)
|
||||
t.manager.broadcastTeamEvent(ctx, protocol.EventTeamTaskFailed, protocol.TeamTaskEventPayload{
|
||||
TeamID: team.ID.String(),
|
||||
TaskID: taskID.String(),
|
||||
TaskNumber: task.TaskNumber,
|
||||
Subject: task.Subject,
|
||||
Status: store.TeamTaskStatusFailed,
|
||||
Reason: reason,
|
||||
OwnerAgentKey: memberKey,
|
||||
UserID: store.UserIDFromContext(ctx),
|
||||
Channel: task.Channel,
|
||||
ChatID: task.ChatID,
|
||||
Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
|
||||
ActorType: "agent",
|
||||
ActorID: memberKey,
|
||||
})
|
||||
|
||||
// Escalate to leader if enabled in team settings.
|
||||
escalationCfg := ParseBlockerEscalationConfig(team.Settings)
|
||||
if escalationCfg.Enabled && t.manager.msgBus != nil {
|
||||
leadAg, err := t.manager.cachedGetAgentByID(ctx, team.LeadAgentID)
|
||||
if err == nil {
|
||||
escalationMsg := fmt.Sprintf(
|
||||
"[Escalation] Member %q is blocked on task #%d \"%s\"\n\n"+
|
||||
"Blocker: %s\n\n"+
|
||||
"Use team_tasks(action=\"retry\", task_id=\"%s\") to reopen with updated instructions.",
|
||||
memberKey, task.TaskNumber, task.Subject, text, taskID)
|
||||
|
||||
if !t.manager.msgBus.TryPublishInbound(bus.InboundMessage{
|
||||
Channel: task.Channel,
|
||||
SenderID: "system:escalation",
|
||||
ChatID: task.ChatID,
|
||||
Content: escalationMsg,
|
||||
UserID: store.UserIDFromContext(ctx),
|
||||
TenantID: store.TenantIDFromContext(ctx),
|
||||
AgentID: leadAg.AgentKey,
|
||||
}) {
|
||||
slog.Warn("blocker: inbound buffer full, escalation dropped",
|
||||
"task_id", taskID, "leader", leadAg.AgentKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NewResult(fmt.Sprintf(
|
||||
"Task #%d \"%s\" auto-failed due to blocker. Leader has been notified and can retry with updated instructions.",
|
||||
task.TaskNumber, task.Subject))
|
||||
}
|
||||
@@ -43,11 +43,9 @@ func (t *TeamTasksTool) executeClaim(ctx context.Context, args map[string]any) *
|
||||
}
|
||||
|
||||
func (t *TeamTasksTool) executeComplete(ctx context.Context, args map[string]any) *Result {
|
||||
// TODO: Enable when reviewer workflow is implemented — teammate agents should
|
||||
// not complete tasks directly when a reviewer is required.
|
||||
// if ToolChannelFromCtx(ctx) == ChannelTeammate {
|
||||
// return ErrorResult("teammate agents cannot complete team tasks directly — results are auto-completed when delegation finishes")
|
||||
// }
|
||||
// Note: reviewer role exists but is not yet active in UI.
|
||||
// All approval flows through leader. When reviewer role is enabled,
|
||||
// restrict teammate agents from completing tasks directly.
|
||||
|
||||
team, agentID, err := t.manager.resolveTeam(ctx)
|
||||
if err != nil {
|
||||
@@ -106,11 +104,7 @@ func (t *TeamTasksTool) executeComplete(ctx context.Context, args map[string]any
|
||||
}
|
||||
|
||||
func (t *TeamTasksTool) executeCancel(ctx context.Context, args map[string]any) *Result {
|
||||
// TODO: Enable when reviewer workflow is implemented — teammate agents should
|
||||
// not cancel tasks directly when a reviewer is required.
|
||||
// if ToolChannelFromCtx(ctx) == ChannelTeammate {
|
||||
// return ErrorResult("teammate agents cannot cancel team tasks directly")
|
||||
// }
|
||||
// Note: reviewer role not yet active. Cancellation goes through leader only.
|
||||
|
||||
team, agentID, err := t.manager.resolveTeam(ctx)
|
||||
if err != nil {
|
||||
@@ -199,11 +193,7 @@ func (t *TeamTasksTool) executeReview(ctx context.Context, args map[string]any)
|
||||
}
|
||||
|
||||
func (t *TeamTasksTool) executeApprove(ctx context.Context, args map[string]any) *Result {
|
||||
// TODO: Enable when reviewer workflow is implemented — teammate agents should
|
||||
// not approve tasks directly when a reviewer is required.
|
||||
// if ToolChannelFromCtx(ctx) == ChannelTeammate {
|
||||
// return ErrorResult("teammate agents cannot approve team tasks")
|
||||
// }
|
||||
// Note: reviewer role not yet active. All approvals flow through leader or dashboard.
|
||||
|
||||
team, agentID, err := t.manager.resolveTeam(ctx)
|
||||
if err != nil {
|
||||
@@ -270,11 +260,7 @@ func (t *TeamTasksTool) executeApprove(ctx context.Context, args map[string]any)
|
||||
}
|
||||
|
||||
func (t *TeamTasksTool) executeReject(ctx context.Context, args map[string]any) *Result {
|
||||
// TODO: Enable when reviewer workflow is implemented — teammate agents should
|
||||
// not reject tasks directly when a reviewer is required.
|
||||
// if ToolChannelFromCtx(ctx) == ChannelTeammate {
|
||||
// return ErrorResult("teammate agents cannot reject team tasks")
|
||||
// }
|
||||
// Note: reviewer role not yet active. Rejections flow through leader or dashboard.
|
||||
|
||||
team, agentID, err := t.manager.resolveTeam(ctx)
|
||||
if err != nil {
|
||||
|
||||
@@ -309,10 +309,16 @@ func (t *TeamTasksTool) executeComment(ctx context.Context, args map[string]any)
|
||||
return ErrorResult("task does not belong to your team")
|
||||
}
|
||||
|
||||
commentType, _ := args["type"].(string)
|
||||
if commentType != "blocker" {
|
||||
commentType = "note"
|
||||
}
|
||||
|
||||
if err := t.manager.teamStore.AddTaskComment(ctx, &store.TeamTaskCommentData{
|
||||
TaskID: taskID,
|
||||
AgentID: &agentID,
|
||||
Content: text,
|
||||
TaskID: taskID,
|
||||
AgentID: &agentID,
|
||||
Content: text,
|
||||
CommentType: commentType,
|
||||
}); err != nil {
|
||||
return ErrorResult("failed to add comment: " + err.Error())
|
||||
}
|
||||
@@ -331,6 +337,11 @@ func (t *TeamTasksTool) executeComment(ctx context.Context, args map[string]any)
|
||||
ActorID: t.manager.agentKeyFromID(ctx, agentID),
|
||||
})
|
||||
|
||||
// Blocker escalation: auto-fail task + notify leader.
|
||||
if commentType == "blocker" {
|
||||
return t.handleBlockerComment(ctx, team, task, taskID, agentID, text)
|
||||
}
|
||||
|
||||
isLead := agentID == team.LeadAgentID
|
||||
msg := fmt.Sprintf("Comment added to task #%d \"%s\" (id: %s).", task.TaskNumber, task.Subject, taskID)
|
||||
switch {
|
||||
@@ -346,6 +357,7 @@ func (t *TeamTasksTool) executeComment(ctx context.Context, args map[string]any)
|
||||
return NewResult(msg)
|
||||
}
|
||||
|
||||
|
||||
func (t *TeamTasksTool) executeProgress(ctx context.Context, args map[string]any) *Result {
|
||||
team, agentID, err := t.manager.resolveTeam(ctx)
|
||||
if err != nil {
|
||||
|
||||
@@ -52,6 +52,10 @@ func (t *TeamTasksTool) Parameters() map[string]any {
|
||||
"type": "string",
|
||||
"description": "Text content: comment text, cancel/reject reason, progress update, or ask_user reminder question (must be a question asking the user for input/decision)",
|
||||
},
|
||||
"type": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Comment type for action=comment: 'note' (default, share findings) or 'blocker' (you are BLOCKED and need leader input — auto-fails task and notifies leader)",
|
||||
},
|
||||
"status": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Filter for list: '' (all, default), 'active', 'completed', 'in_review'",
|
||||
|
||||
@@ -83,7 +83,8 @@ func (m *TeamToolManager) dispatchTaskToAgent(ctx context.Context, task *store.T
|
||||
// Hint: guide member on available team_tasks actions.
|
||||
content += "\n\n[Instructions]\n" +
|
||||
"- Use team_tasks(action=\"progress\", percent=N, text=\"...\") to report progress\n" +
|
||||
"- Use team_tasks(action=\"comment\", text=\"...\") to share findings or ask questions — comments are included in the task report sent to the leader\n" +
|
||||
"- Use team_tasks(action=\"comment\", text=\"...\") to share findings\n" +
|
||||
"- Use team_tasks(action=\"comment\", type=\"blocker\", text=\"...\") when BLOCKED and need leader input — auto-fails task and notifies leader\n" +
|
||||
"- When done: team_tasks(action=\"complete\", result=\"summary of your work\")\n" +
|
||||
"- Write output files to team workspace so lead can review"
|
||||
|
||||
|
||||
@@ -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 = 27
|
||||
const RequiredSchemaVersion uint = 28
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE team_task_comments DROP COLUMN IF EXISTS comment_type;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Add comment_type to distinguish note vs blocker comments.
|
||||
-- Blocker comments trigger task auto-fail + leader escalation.
|
||||
ALTER TABLE team_task_comments ADD COLUMN IF NOT EXISTS comment_type VARCHAR(20) NOT NULL DEFAULT 'note';
|
||||
@@ -151,6 +151,7 @@ export const Methods = {
|
||||
TEAMS_WORKSPACE_LIST: "teams.workspace.list",
|
||||
TEAMS_WORKSPACE_READ: "teams.workspace.read",
|
||||
TEAMS_WORKSPACE_DELETE: "teams.workspace.delete",
|
||||
TEAMS_EVENTS_LIST: "teams.events.list",
|
||||
|
||||
// Heartbeat
|
||||
HEARTBEAT_GET: "heartbeat.get",
|
||||
|
||||
@@ -160,6 +160,27 @@
|
||||
"empty": "File is empty or binary"
|
||||
}
|
||||
},
|
||||
"auditLogs": {
|
||||
"title": "Audit Logs",
|
||||
"empty": "No events yet",
|
||||
"loadMore": "Load more",
|
||||
"event": {
|
||||
"created": "Created",
|
||||
"claimed": "Claimed",
|
||||
"assigned": "Assigned",
|
||||
"dispatched": "Dispatched",
|
||||
"completed": "Completed",
|
||||
"failed": "Failed",
|
||||
"cancelled": "Cancelled",
|
||||
"reviewed": "Reviewed",
|
||||
"approved": "Approved",
|
||||
"rejected": "Rejected",
|
||||
"commented": "Commented",
|
||||
"progress": "Progress",
|
||||
"updated": "Updated",
|
||||
"stale": "Stale"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Team Settings",
|
||||
"name": "Name",
|
||||
@@ -212,6 +233,9 @@
|
||||
"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.",
|
||||
"blockerEscalation": "Blocker Escalation",
|
||||
"blockerEscalationEnabled": "Enable blocker escalation",
|
||||
"blockerEscalationHint": "When members flag a blocker, the task auto-fails and the leader is notified to retry with updated instructions.",
|
||||
"escalationModeAuto": "Auto (default)",
|
||||
"escalationModeAutoDesc": "LLM lead evaluates the request — either rejects or creates a review task. Never executes directly.",
|
||||
"escalationModeReview": "Review",
|
||||
@@ -259,8 +283,8 @@
|
||||
"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",
|
||||
"escalationPolicy": "Blocker Escalation",
|
||||
"escalationPolicyDesc": "Members can flag blockers — auto-fails task and notifies leader to retry",
|
||||
"comingSoon": "Coming soon",
|
||||
"downgradeNote": "Downgrade is safe — V2 data is preserved but hidden.",
|
||||
"betaNote": "Some features are currently in Beta.",
|
||||
|
||||
@@ -160,6 +160,27 @@
|
||||
"empty": "File trống hoặc là file nhị phân"
|
||||
}
|
||||
},
|
||||
"auditLogs": {
|
||||
"title": "Nhật ký hoạt động",
|
||||
"empty": "Chưa có sự kiện",
|
||||
"loadMore": "Tải thêm",
|
||||
"event": {
|
||||
"created": "Tạo mới",
|
||||
"claimed": "Nhận",
|
||||
"assigned": "Phân công",
|
||||
"dispatched": "Gửi đi",
|
||||
"completed": "Hoàn thành",
|
||||
"failed": "Thất bại",
|
||||
"cancelled": "Hủy",
|
||||
"reviewed": "Đánh giá",
|
||||
"approved": "Duyệt",
|
||||
"rejected": "Từ chối",
|
||||
"commented": "Bình luận",
|
||||
"progress": "Tiến độ",
|
||||
"updated": "Cập nhật",
|
||||
"stale": "Quá hạn"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Cài đặt nhóm",
|
||||
"name": "Tên",
|
||||
@@ -212,6 +233,9 @@
|
||||
"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.",
|
||||
"blockerEscalation": "Escalation khi bị chặn",
|
||||
"blockerEscalationEnabled": "Bật escalation khi bị chặn",
|
||||
"blockerEscalationHint": "Khi member báo blocker, task tự fail và leader được thông báo để retry với hướng dẫn mới.",
|
||||
"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",
|
||||
@@ -259,8 +283,8 @@
|
||||
"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",
|
||||
"escalationPolicy": "Escalation khi bị chặn",
|
||||
"escalationPolicyDesc": "Member có thể báo blocker — tự fail task và thông báo leader để retry",
|
||||
"comingSoon": "Sắp ra mắt",
|
||||
"downgradeNote": "Hạ cấp an toàn — dữ liệu V2 được giữ lại nhưng ẩn đi.",
|
||||
"betaNote": "Một số tính năng đang ở giai đoạn Beta.",
|
||||
|
||||
@@ -160,6 +160,27 @@
|
||||
"empty": "文件为空或为二进制文件"
|
||||
}
|
||||
},
|
||||
"auditLogs": {
|
||||
"title": "审计日志",
|
||||
"empty": "暂无事件",
|
||||
"loadMore": "加载更多",
|
||||
"event": {
|
||||
"created": "创建",
|
||||
"claimed": "领取",
|
||||
"assigned": "分配",
|
||||
"dispatched": "派发",
|
||||
"completed": "完成",
|
||||
"failed": "失败",
|
||||
"cancelled": "取消",
|
||||
"reviewed": "审核",
|
||||
"approved": "批准",
|
||||
"rejected": "拒绝",
|
||||
"commented": "评论",
|
||||
"progress": "进度",
|
||||
"updated": "更新",
|
||||
"stale": "超时"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Team设置",
|
||||
"name": "名称",
|
||||
@@ -212,6 +233,9 @@
|
||||
"escalationPolicy": "升级策略",
|
||||
"escalationMode": "升级模式",
|
||||
"escalationModeHint": "控制Agent请求执行敏感工作空间操作(置顶、标记、删除他人文件、设置模板)时的处理方式。",
|
||||
"blockerEscalation": "阻塞升级",
|
||||
"blockerEscalationEnabled": "启用阻塞升级",
|
||||
"blockerEscalationHint": "当成员标记阻塞时,任务自动失败并通知领导重试。",
|
||||
"escalationModeAuto": "自动(默认)",
|
||||
"escalationModeAutoDesc": "LLM负责人评估请求——拒绝或创建审核任务。从不直接执行。",
|
||||
"escalationModeReview": "审核",
|
||||
@@ -259,8 +283,8 @@
|
||||
"commentsAuditDesc": "任务评论和完整事件历史",
|
||||
"autoRecovery": "自动恢复",
|
||||
"autoRecoveryDesc": "卡住的任务在超时后自动重置",
|
||||
"escalationPolicy": "升级策略",
|
||||
"escalationPolicyDesc": "控制代理可以自主执行的操作",
|
||||
"escalationPolicy": "阻塞升级",
|
||||
"escalationPolicyDesc": "成员可以标记阻塞 — 自动失败任务并通知领导重试",
|
||||
"comingSoon": "即将推出",
|
||||
"downgradeNote": "降级是安全的 — V2数据会保留但隐藏。",
|
||||
"betaNote": "部分功能目前处于Beta阶段。",
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ArrowLeft, Settings, Trash2, Users } from "lucide-react";
|
||||
import { ArrowLeft, Clock, Settings, Trash2, Users } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TeamData, TeamMemberData } from "@/types/team";
|
||||
import { TeamFeaturesModal } from "../team-features-modal";
|
||||
import { TeamAuditLogsModal } from "../team-audit-logs-modal";
|
||||
|
||||
interface BoardHeaderProps {
|
||||
team: TeamData;
|
||||
@@ -18,6 +19,7 @@ interface BoardHeaderProps {
|
||||
export function BoardHeader({ team, members, onBack, onDelete, onSettings, onMembers }: BoardHeaderProps) {
|
||||
const { t } = useTranslation("teams");
|
||||
const [featuresOpen, setFeaturesOpen] = useState(false);
|
||||
const [auditLogsOpen, setAuditLogsOpen] = useState(false);
|
||||
const leadMember = members.find((m) => m.role === "lead");
|
||||
const leadName = leadMember?.display_name || leadMember?.agent_key
|
||||
|| team.lead_display_name || team.lead_agent_key;
|
||||
@@ -59,6 +61,10 @@ export function BoardHeader({ team, members, onBack, onDelete, onSettings, onMem
|
||||
<Users className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{t("members.title")}</span>
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setAuditLogsOpen(true)} className="shrink-0 gap-1.5">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{t("auditLogs.title")}</span>
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={onSettings} className="shrink-0 gap-1.5">
|
||||
<Settings className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{t("detail.tabs.settings")}</span>
|
||||
@@ -74,6 +80,7 @@ export function BoardHeader({ team, members, onBack, onDelete, onSettings, onMem
|
||||
</Button>
|
||||
|
||||
<TeamFeaturesModal open={featuresOpen} onOpenChange={setFeaturesOpen} />
|
||||
<TeamAuditLogsModal open={auditLogsOpen} onOpenChange={setAuditLogsOpen} teamId={team.id} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useWs } from "@/hooks/use-ws";
|
||||
import { Methods } from "@/api/protocol";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { formatRelativeTime } from "@/lib/format";
|
||||
import type { TeamTaskEvent } from "@/types/team";
|
||||
|
||||
const LIMIT = 50;
|
||||
|
||||
const EVENT_BADGE_CLASSES: Record<string, string> = {
|
||||
created: "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300",
|
||||
updated: "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300",
|
||||
claimed: "bg-indigo-100 text-indigo-700 dark:bg-indigo-900 dark:text-indigo-300",
|
||||
assigned: "bg-indigo-100 text-indigo-700 dark:bg-indigo-900 dark:text-indigo-300",
|
||||
dispatched: "bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300",
|
||||
completed: "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300",
|
||||
approved: "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300",
|
||||
failed: "bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300",
|
||||
rejected: "bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300",
|
||||
cancelled: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400",
|
||||
commented: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300",
|
||||
progress: "bg-cyan-100 text-cyan-700 dark:bg-cyan-900 dark:text-cyan-300",
|
||||
reviewed: "bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300",
|
||||
stale: "bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300",
|
||||
};
|
||||
|
||||
interface EventDataDetails {
|
||||
reason?: string;
|
||||
comment_text?: string;
|
||||
progress_percent?: number;
|
||||
progress_step?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
function EventDataRow({ data }: { data: Record<string, unknown> }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const d = data as EventDataDetails;
|
||||
const summary = d.reason || d.comment_text
|
||||
|| (d.progress_percent != null ? `${d.progress_percent}%${d.progress_step ? ` — ${d.progress_step}` : ""}` : null);
|
||||
|
||||
if (!summary && Object.keys(data).length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="text-[10px] text-muted-foreground underline-offset-2 hover:underline"
|
||||
>
|
||||
{expanded ? "hide" : "details"}
|
||||
</button>
|
||||
{expanded && (
|
||||
<pre className="mt-1 max-h-20 overflow-auto rounded bg-muted px-2 py-1 text-[10px] text-muted-foreground">
|
||||
{JSON.stringify(data, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
{!expanded && summary && (
|
||||
<span className="ml-1 text-[10px] text-muted-foreground">{summary}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface TeamAuditLogsModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
teamId: string;
|
||||
}
|
||||
|
||||
export function TeamAuditLogsModal({ open, onOpenChange, teamId }: TeamAuditLogsModalProps) {
|
||||
const { t } = useTranslation("teams");
|
||||
const ws = useWs();
|
||||
|
||||
const [events, setEvents] = useState<TeamTaskEvent[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchEvents = useCallback(async (offset: number, append: boolean) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await ws.call<{ events: TeamTaskEvent[]; count: number }>(
|
||||
Methods.TEAMS_EVENTS_LIST,
|
||||
{ team_id: teamId, limit: LIMIT, offset },
|
||||
);
|
||||
const incoming = res.events ?? [];
|
||||
setEvents((prev) => append ? [...prev, ...incoming] : incoming);
|
||||
setHasMore(incoming.length === LIMIT);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load events");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [ws, teamId]);
|
||||
|
||||
// Load on open
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setEvents([]);
|
||||
fetchEvents(0, false);
|
||||
}
|
||||
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
fetchEvents(events.length, true);
|
||||
}, [events.length, fetchEvents]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-[95vw] sm:max-w-2xl max-h-[90dvh] flex flex-col p-0">
|
||||
<DialogHeader className="px-4 pt-4 pb-2">
|
||||
<DialogTitle>{t("auditLogs.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto overscroll-contain px-4 pb-4">
|
||||
{error && (
|
||||
<p className="py-8 text-center text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
{events.length === 0 && !loading && !error && (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">{t("auditLogs.empty")}</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-0">
|
||||
{events.map((e, i) => (
|
||||
<div key={e.id} className="relative flex gap-3 pb-3 last:pb-0">
|
||||
{/* Dot + vertical line */}
|
||||
<div className="flex flex-col items-center pt-1.5">
|
||||
<div className="h-2 w-2 shrink-0 rounded-full bg-primary" />
|
||||
{i < events.length - 1 && <div className="mt-0.5 flex-1 w-px bg-border" />}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0 pb-1">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-[10px] border-0 font-medium ${EVENT_BADGE_CLASSES[e.event_type] ?? "bg-muted text-muted-foreground"}`}
|
||||
>
|
||||
{t(`auditLogs.event.${e.event_type}`, { defaultValue: e.event_type })}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground truncate max-w-[120px]">
|
||||
{e.actor_type === "human" ? "Human" : (e.actor_id?.slice(0, 8) ?? "—")}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground ml-auto shrink-0">
|
||||
{formatRelativeTime(e.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
{e.task_id && (
|
||||
<div className="text-[10px] text-muted-foreground mt-0.5">
|
||||
task: {e.task_id.slice(0, 8)}
|
||||
</div>
|
||||
)}
|
||||
{e.data && Object.keys(e.data).length > 0 && (
|
||||
<EventDataRow data={e.data} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<p className="py-4 text-center text-xs text-muted-foreground">Loading...</p>
|
||||
)}
|
||||
|
||||
{hasMore && !loading && (
|
||||
<div className="pt-2 text-center">
|
||||
<Button variant="outline" size="sm" onClick={handleLoadMore}>
|
||||
{t("auditLogs.loadMore")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Check, Minus,
|
||||
Check,
|
||||
ListTodo, FolderOpen, Lock, Bell,
|
||||
ClipboardCheck, BarChart3, MessageSquare, RotateCcw, ShieldCheck,
|
||||
} from "lucide-react";
|
||||
@@ -26,9 +26,9 @@ const FEATURES = [
|
||||
{ key: "followupReminders", icon: Bell },
|
||||
{ key: "progressTracking", icon: BarChart3 },
|
||||
{ key: "autoRecovery", icon: RotateCcw },
|
||||
{ key: "commentsAudit", icon: MessageSquare, comingSoon: true },
|
||||
{ key: "escalationPolicy", icon: ShieldCheck, comingSoon: true },
|
||||
{ key: "reviewWorkflow", icon: ClipboardCheck, comingSoon: true },
|
||||
{ key: "commentsAudit", icon: MessageSquare },
|
||||
{ key: "escalationPolicy", icon: ShieldCheck },
|
||||
{ key: "reviewWorkflow", icon: ClipboardCheck },
|
||||
] as const;
|
||||
|
||||
export function TeamFeaturesModal({ open, onOpenChange }: TeamFeaturesModalProps) {
|
||||
@@ -48,45 +48,33 @@ export function TeamFeaturesModal({ open, onOpenChange }: TeamFeaturesModalProps
|
||||
</DialogHeader>
|
||||
|
||||
<div className="overflow-y-auto min-h-0 space-y-4 -mx-4 px-4 sm:-mx-6 sm:px-6">
|
||||
{/* Feature list */}
|
||||
<div className="space-y-1">
|
||||
{FEATURES.map((f) => {
|
||||
const Icon = f.icon;
|
||||
const dimmed = "comingSoon" in f && f.comingSoon;
|
||||
return (
|
||||
<div
|
||||
key={f.key}
|
||||
className={`group grid grid-cols-[1fr_40px] items-center gap-2 rounded-lg px-3 py-2.5 hover:bg-muted/30 ${dimmed ? "opacity-40" : ""}`}
|
||||
className="group grid grid-cols-[1fr_40px] items-center gap-2 rounded-lg px-3 py-2.5 hover:bg-muted/30"
|
||||
>
|
||||
<div className="flex items-start gap-3 min-w-0">
|
||||
<Icon className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium flex items-center gap-2">
|
||||
{t(`settings.versionModal.${f.key}`)}
|
||||
{dimmed && (
|
||||
<Badge variant="outline" className="text-[9px] px-1.5 py-0 font-normal">
|
||||
{t("settings.versionModal.comingSoon")}
|
||||
</Badge>
|
||||
)}
|
||||
<p className="text-sm font-medium">
|
||||
{String(t(`settings.versionModal.${f.key}`))}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
{t(`settings.versionModal.${f.key}Desc`)}
|
||||
{String(t(`settings.versionModal.${f.key}Desc`))}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
{dimmed ? (
|
||||
<Minus className="h-4 w-4 text-muted-foreground/30" />
|
||||
) : (
|
||||
<Check className="h-4 w-4 text-green-600 dark:text-green-400" />
|
||||
)}
|
||||
<Check className="h-4 w-4 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div className="space-y-1 rounded-lg border bg-muted/20 px-4 py-3 text-xs text-muted-foreground">
|
||||
<p>{t("settings.versionModal.betaNote")}</p>
|
||||
</div>
|
||||
|
||||
@@ -83,6 +83,8 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps)
|
||||
const [memberRequestsAutoDispatch, setMemberRequestsAutoDispatch] = useState(initMemberRequests.auto_dispatch ?? false);
|
||||
const [escalationMode, setEscalationMode] = useState<EscalationMode | "">(initial.escalation_mode ?? "");
|
||||
const [escalationActions, setEscalationActions] = useState<EscalationAction[]>(initial.escalation_actions ?? []);
|
||||
const initBlockerEscalation = initial.blocker_escalation ?? {};
|
||||
const [blockerEscalationEnabled, setBlockerEscalationEnabled] = useState(initBlockerEscalation.enabled ?? true);
|
||||
const [followupInterval, setFollowupInterval] = useState<number>(initial.followup_interval_minutes ?? 30);
|
||||
const [followupMaxReminders, setFollowupMaxReminders] = useState<number>(initial.followup_max_reminders ?? 0);
|
||||
const [workspaceScope, setWorkspaceScope] = useState<string>(initial.workspace_scope ?? "isolated");
|
||||
@@ -105,9 +107,9 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps)
|
||||
setNotifyDispatched(sn.dispatched ?? true);
|
||||
setNotifyProgress(sn.progress ?? true);
|
||||
setNotifyFailed(sn.failed ?? true);
|
||||
setNotifyCompleted(sn.completed ?? false);
|
||||
setNotifyCommented(sn.commented ?? false);
|
||||
setNotifyNewTask(sn.new_task ?? false);
|
||||
setNotifyCompleted(sn.completed ?? true);
|
||||
setNotifyCommented(sn.commented ?? true);
|
||||
setNotifyNewTask(sn.new_task ?? true);
|
||||
setNotifySlowTool(sn.slow_tool ?? true);
|
||||
setNotifyMode(sn.mode ?? "direct");
|
||||
const smr = s.member_requests ?? {};
|
||||
@@ -115,6 +117,8 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps)
|
||||
setMemberRequestsAutoDispatch(smr.auto_dispatch ?? false);
|
||||
setEscalationMode(s.escalation_mode ?? "");
|
||||
setEscalationActions(s.escalation_actions ?? []);
|
||||
const sbe = s.blocker_escalation ?? {};
|
||||
setBlockerEscalationEnabled(sbe.enabled ?? true);
|
||||
setFollowupInterval(s.followup_interval_minutes ?? 30);
|
||||
setFollowupMaxReminders(s.followup_max_reminders ?? 0);
|
||||
setWorkspaceScope(s.workspace_scope ?? "isolated");
|
||||
@@ -149,6 +153,7 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps)
|
||||
settings.escalation_mode = escalationMode;
|
||||
if (escalationActions.length > 0) settings.escalation_actions = escalationActions;
|
||||
}
|
||||
settings.blocker_escalation = { enabled: blockerEscalationEnabled };
|
||||
if (followupInterval !== 30) settings.followup_interval_minutes = followupInterval;
|
||||
if (followupMaxReminders !== 0) settings.followup_max_reminders = followupMaxReminders;
|
||||
settings.workspace_scope = workspaceScope || "isolated";
|
||||
@@ -158,7 +163,7 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps)
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [teamId, allowUserIds, denyUserIds, allowChannels, denyChannels, notifyDispatched, notifyProgress, notifyFailed, notifyCompleted, notifyCommented, notifyNewTask, notifySlowTool, notifyMode, memberRequestsEnabled, memberRequestsAutoDispatch, escalationMode, escalationActions, followupInterval, followupMaxReminders, workspaceScope, updateTeamSettings, onSaved]);
|
||||
}, [teamId, allowUserIds, denyUserIds, allowChannels, denyChannels, notifyDispatched, notifyProgress, notifyFailed, notifyCompleted, notifyCommented, notifyNewTask, notifySlowTool, notifyMode, memberRequestsEnabled, memberRequestsAutoDispatch, escalationMode, escalationActions, blockerEscalationEnabled, followupInterval, followupMaxReminders, workspaceScope, updateTeamSettings, onSaved]);
|
||||
|
||||
const userOptions = knownUsers.map((u) => ({ value: u, label: u }));
|
||||
const channelOptions = CHANNEL_TYPES.map((c) => ({ value: c.value, label: c.label }));
|
||||
@@ -328,22 +333,24 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Escalation Policy (coming soon) */}
|
||||
<div className="space-y-4 opacity-40 pointer-events-none select-none">
|
||||
<h3 className="text-sm font-medium flex items-center gap-2">
|
||||
{t("settings.escalationPolicy")}
|
||||
<span className="rounded border px-1.5 py-0.5 text-[9px] font-normal text-muted-foreground">{t("settings.versionModal.comingSoon")}</span>
|
||||
</h3>
|
||||
{/* Blocker Escalation */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">{t("settings.blockerEscalation")}</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-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 className="flex-1 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<span className="text-sm font-semibold">{t("settings.blockerEscalationEnabled")}</span>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
{t("settings.blockerEscalationHint")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={blockerEscalationEnabled} onCheckedChange={setBlockerEscalationEnabled} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -33,6 +33,9 @@ export interface TeamAccessSettings {
|
||||
enabled?: boolean;
|
||||
auto_dispatch?: boolean;
|
||||
};
|
||||
blocker_escalation?: {
|
||||
enabled?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TeamData {
|
||||
|
||||
Reference in new issue
Block a user