chore(repo): reinitialize repository
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
package dashboard
|
||||
|
||||
import (
|
||||
"inbox/internal/domain/humantask"
|
||||
"inbox/internal/domain/lane"
|
||||
"inbox/internal/domain/message"
|
||||
"inbox/internal/domain/topic"
|
||||
"inbox/internal/domain/workflow"
|
||||
)
|
||||
|
||||
func filterMessagesByKnownTopics(items []message.Record, known map[string]topic.Record) map[string][]message.Record {
|
||||
out := make(map[string][]message.Record)
|
||||
for _, item := range items {
|
||||
if _, ok := known[item.TopicID]; !ok {
|
||||
continue
|
||||
}
|
||||
out[item.TopicID] = append(out[item.TopicID], item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func filterRunsByKnownTopics(items []workflow.Run, known map[string]topic.Record) map[string][]workflow.Run {
|
||||
out := make(map[string][]workflow.Run)
|
||||
for _, item := range items {
|
||||
if _, ok := known[item.TopicID]; !ok {
|
||||
continue
|
||||
}
|
||||
out[item.TopicID] = append(out[item.TopicID], item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func groupLanesByKnownTopics(items []lane.Record, known map[string]topic.Record) map[string][]lane.Record {
|
||||
out := make(map[string][]lane.Record)
|
||||
for _, item := range items {
|
||||
if _, ok := known[item.TopicID]; !ok {
|
||||
continue
|
||||
}
|
||||
out[item.TopicID] = append(out[item.TopicID], item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func groupHumanTasksByKnownTopics(items []humantask.Record, known map[string]topic.Record) map[string][]humantask.Record {
|
||||
out := make(map[string][]humantask.Record)
|
||||
for _, item := range items {
|
||||
if _, ok := known[item.TopicID]; !ok {
|
||||
continue
|
||||
}
|
||||
out[item.TopicID] = append(out[item.TopicID], item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func groupPendingDeliveriesByTopicRole(items []message.PendingDelivery, known map[string]topic.Record) map[string]map[string]int {
|
||||
out := make(map[string]map[string]int)
|
||||
for _, item := range items {
|
||||
if _, ok := known[item.TopicID]; !ok {
|
||||
continue
|
||||
}
|
||||
if out[item.TopicID] == nil {
|
||||
out[item.TopicID] = make(map[string]int)
|
||||
}
|
||||
out[item.TopicID][item.RoleName] += item.Count
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func groupPendingDeliveriesByRole(items []message.PendingDelivery, known map[string]topic.Record) map[string]int {
|
||||
out := make(map[string]int)
|
||||
for _, item := range items {
|
||||
if _, ok := known[item.TopicID]; !ok {
|
||||
continue
|
||||
}
|
||||
out[item.RoleName] += item.Count
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func groupPendingHumanTasksByTopicRole(items []humantask.Record, known map[string]topic.Record) map[string]map[string]int {
|
||||
out := make(map[string]map[string]int)
|
||||
for _, item := range items {
|
||||
if _, ok := known[item.TopicID]; !ok {
|
||||
continue
|
||||
}
|
||||
if out[item.TopicID] == nil {
|
||||
out[item.TopicID] = make(map[string]int)
|
||||
}
|
||||
out[item.TopicID][item.RoleName]++
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func groupPendingHumanTasksByRole(items []humantask.Record, known map[string]topic.Record) map[string]int {
|
||||
out := make(map[string]int)
|
||||
for _, item := range items {
|
||||
if _, ok := known[item.TopicID]; !ok {
|
||||
continue
|
||||
}
|
||||
out[item.RoleName]++
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func latestRunTime(item *workflow.Run) string {
|
||||
if item == nil {
|
||||
return ""
|
||||
}
|
||||
return latestString(item.CompletedAt, item.StartedAt)
|
||||
}
|
||||
|
||||
func latestString(values ...string) string {
|
||||
var best string
|
||||
for _, value := range values {
|
||||
if value > best {
|
||||
best = value
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func coalesce(value, fallback string) string {
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package dashboard
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"inbox/internal/domain/role"
|
||||
)
|
||||
|
||||
func TestPendingRolesForTopicReturnsEmptySliceWhenNoItems(t *testing.T) {
|
||||
got := pendingRolesForTopic(nil, []role.Definition{
|
||||
{Name: "leader", IsEnabled: true},
|
||||
{Name: "worker", IsEnabled: true},
|
||||
})
|
||||
if got == nil {
|
||||
t.Fatalf("expected empty slice, got nil")
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("expected no pending roles, got %v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package dashboard
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"inbox/internal/domain/message"
|
||||
"inbox/internal/domain/topic"
|
||||
)
|
||||
|
||||
func previewText(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
lines := strings.Split(value, "\n")
|
||||
if len(lines) == 0 {
|
||||
return value
|
||||
}
|
||||
return strings.TrimSpace(lines[0])
|
||||
}
|
||||
|
||||
func splitRecipients(value string) []string {
|
||||
parts := strings.Split(value, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
seen := make(map[string]struct{}, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[part]; ok {
|
||||
continue
|
||||
}
|
||||
seen[part] = struct{}{}
|
||||
out = append(out, part)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func messageTargetsRole(expr, roleName string) bool {
|
||||
for _, item := range splitRecipients(expr) {
|
||||
if item == roleName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func dashboardMessageItemFor(item message.Record, record topic.Record) dashboardMessageItem {
|
||||
out := dashboardMessageItem{
|
||||
MessageID: item.ID,
|
||||
From: item.FromRoleName,
|
||||
To: item.ToExpr,
|
||||
Type: string(item.Type),
|
||||
Topic: record.Slug,
|
||||
Stage: item.Stage,
|
||||
BodyMarkdown: item.BodyMarkdown,
|
||||
}
|
||||
if item.ReplyToMessageID != "" {
|
||||
out.ReplyTo = item.ReplyToMessageID
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
package dashboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"inbox/internal/domain/message"
|
||||
"inbox/internal/domain/topic"
|
||||
"inbox/internal/domain/workflow"
|
||||
"inbox/internal/domain/workspace"
|
||||
)
|
||||
|
||||
func (s *Service) Messages(ctx context.Context, ws workspace.Workspace) (dashboardMessagesResponse, error) {
|
||||
topics, err := s.repo.ListTopics(ctx, ws.ID)
|
||||
if err != nil {
|
||||
return dashboardMessagesResponse{}, err
|
||||
}
|
||||
topicByID := make(map[string]topic.Record, len(topics))
|
||||
for _, item := range topics {
|
||||
topicByID[item.ID] = item
|
||||
}
|
||||
messages, err := s.repo.ListMessagesByWorkspace(ctx, ws.ID)
|
||||
if err != nil {
|
||||
return dashboardMessagesResponse{}, err
|
||||
}
|
||||
items := make([]dashboardMessageItem, 0, len(messages))
|
||||
for _, item := range messages {
|
||||
items = append(items, dashboardMessageItemFor(item, topicByID[item.TopicID]))
|
||||
}
|
||||
return dashboardMessagesResponse{Messages: items}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Topics(ctx context.Context, ws workspace.Workspace) (dashboardTopicsResponse, error) {
|
||||
snapshot, err := s.loadWorkspaceTopicSnapshot(ctx, ws.ID, topic.SpaceWorkflow)
|
||||
if err != nil {
|
||||
return dashboardTopicsResponse{}, err
|
||||
}
|
||||
items := make([]dashboardTopicInfo, 0, len(snapshot.topics))
|
||||
for _, item := range snapshot.topics {
|
||||
stages := topicStages(item, snapshot.messagesByTopic[item.ID], snapshot.runsByTopic[item.ID])
|
||||
items = append(items, dashboardTopicInfo{
|
||||
Name: item.Slug,
|
||||
MessageCount: len(snapshot.messagesByTopic[item.ID]),
|
||||
Stages: stages,
|
||||
LatestStage: latestTopicStage(item, snapshot.messagesByTopic[item.ID], snapshot.runsByTopic[item.ID]),
|
||||
})
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
return items[i].Name < items[j].Name
|
||||
})
|
||||
return dashboardTopicsResponse{Topics: items}, nil
|
||||
}
|
||||
|
||||
func (s *Service) TopicRecords(ctx context.Context, ws workspace.Workspace, spaceFilter string) (dashboardTopicRecordsResponse, error) {
|
||||
items, err := s.repo.ListTopics(ctx, ws.ID)
|
||||
if err != nil {
|
||||
return dashboardTopicRecordsResponse{}, err
|
||||
}
|
||||
records := make([]dashboardTopicRecord, 0, len(items))
|
||||
for _, item := range items {
|
||||
if spaceFilter != "" && string(item.Space) != spaceFilter {
|
||||
continue
|
||||
}
|
||||
records = append(records, dashboardTopicRecord{
|
||||
Name: item.Slug,
|
||||
Space: string(item.Space),
|
||||
Status: item.Status,
|
||||
CreatedAt: item.CreatedAt,
|
||||
UpdatedAt: item.UpdatedAt,
|
||||
Description: item.Summary,
|
||||
})
|
||||
}
|
||||
return dashboardTopicRecordsResponse{Records: records}, nil
|
||||
}
|
||||
|
||||
func (s *Service) SpaceTopics(ctx context.Context, ws workspace.Workspace, space topic.Space) (dashboardSpaceTopicsResponse, error) {
|
||||
snapshot, err := s.loadWorkspaceTopicSnapshot(ctx, ws.ID, space)
|
||||
if err != nil {
|
||||
return dashboardSpaceTopicsResponse{}, err
|
||||
}
|
||||
latestMessageByTopic := make(map[string]string, len(snapshot.topics))
|
||||
items := make([]dashboardSpaceTopic, 0, len(snapshot.topics))
|
||||
for _, item := range snapshot.topics {
|
||||
topicMessages := snapshot.messagesByTopic[item.ID]
|
||||
latestMessageByTopic[item.Slug] = latestMessageTime(topicMessages)
|
||||
items = append(items, dashboardSpaceTopic{
|
||||
Topic: item.Slug,
|
||||
MessageCount: len(topicMessages),
|
||||
LastFile: latestMessageID(topicMessages),
|
||||
Status: item.Status,
|
||||
Description: item.Summary,
|
||||
})
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
leftTime := latestMessageByTopic[items[i].Topic]
|
||||
rightTime := latestMessageByTopic[items[j].Topic]
|
||||
if leftTime == rightTime {
|
||||
return items[i].Topic < items[j].Topic
|
||||
}
|
||||
return leftTime > rightTime
|
||||
})
|
||||
return dashboardSpaceTopicsResponse{Topics: items}, nil
|
||||
}
|
||||
|
||||
func (s *Service) SpaceMessages(ctx context.Context, ws workspace.Workspace, space topic.Space, topicSlug string) (dashboardMessagesResponse, error) {
|
||||
record, err := s.repo.GetTopicBySlugOrTitle(ctx, ws.ID, topicSlug, space)
|
||||
if err != nil {
|
||||
return dashboardMessagesResponse{}, err
|
||||
}
|
||||
messages, err := s.repo.ListMessagesByTopic(ctx, record.ID)
|
||||
if err != nil {
|
||||
return dashboardMessagesResponse{}, err
|
||||
}
|
||||
items := make([]dashboardMessageItem, 0, len(messages))
|
||||
for _, item := range messages {
|
||||
items = append(items, dashboardMessageItemFor(item, record))
|
||||
}
|
||||
return dashboardMessagesResponse{Messages: items}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Dispatch(ctx context.Context, ws workspace.Workspace) (dashboardDispatchLogsResponse, error) {
|
||||
topics, err := s.repo.ListTopics(ctx, ws.ID)
|
||||
if err != nil {
|
||||
return dashboardDispatchLogsResponse{}, err
|
||||
}
|
||||
topicByID := make(map[string]topic.Record, len(topics))
|
||||
for _, item := range topics {
|
||||
topicByID[item.ID] = item
|
||||
}
|
||||
messages, err := s.repo.ListMessagesByWorkspace(ctx, ws.ID)
|
||||
if err != nil {
|
||||
return dashboardDispatchLogsResponse{}, err
|
||||
}
|
||||
messageByID := make(map[string]message.Record, len(messages))
|
||||
for _, item := range messages {
|
||||
messageByID[item.ID] = item
|
||||
}
|
||||
runs, err := s.repo.ListWorkflowRunsByWorkspace(ctx, ws.ID)
|
||||
if err != nil {
|
||||
return dashboardDispatchLogsResponse{}, err
|
||||
}
|
||||
items := make([]dashboardDispatchLog, 0, len(runs))
|
||||
for _, run := range runs {
|
||||
topicSlug := topicByID[run.TopicID].Slug
|
||||
reply := ""
|
||||
if run.ReplyMessageID != "" {
|
||||
reply = strings.TrimSpace(messageByID[run.ReplyMessageID].BodyMarkdown)
|
||||
}
|
||||
items = append(items, dashboardDispatchLog{
|
||||
Role: run.RoleName,
|
||||
InboxFile: coalesce(run.RequestMessageID, run.ID),
|
||||
Stage: string(run.Stage),
|
||||
Topic: topicSlug,
|
||||
Mode: run.Mode,
|
||||
StartedAt: run.StartedAt,
|
||||
CompletedAt: run.CompletedAt,
|
||||
ExitCode: run.ExitCode,
|
||||
Reply: reply,
|
||||
ErrorMessage: run.ErrorMessage,
|
||||
Running: run.Status == workflow.RunStatusRunning,
|
||||
})
|
||||
}
|
||||
return dashboardDispatchLogsResponse{Logs: items}, nil
|
||||
}
|
||||
|
||||
func (s *Service) DispatchLive(ctx context.Context, ws workspace.Workspace, topicSlug, roleName string, afterSeq int) (dashboardDispatchLiveResponse, error) {
|
||||
record, err := s.repo.GetTopicBySlugOrTitle(ctx, ws.ID, topicSlug)
|
||||
if err != nil {
|
||||
return dashboardDispatchLiveResponse{}, err
|
||||
}
|
||||
runs, err := s.repo.ListWorkflowRunsByTopic(ctx, record.ID)
|
||||
if err != nil {
|
||||
return dashboardDispatchLiveResponse{}, err
|
||||
}
|
||||
var selected *workflow.Run
|
||||
for _, run := range runs {
|
||||
if run.RoleName != roleName {
|
||||
continue
|
||||
}
|
||||
runCopy := run
|
||||
selected = &runCopy
|
||||
break
|
||||
}
|
||||
if selected == nil {
|
||||
return dashboardDispatchLiveResponse{
|
||||
Entries: []dashboardDispatchLiveEntry{},
|
||||
Offset: afterSeq,
|
||||
}, nil
|
||||
}
|
||||
logs, err := s.repo.ListWorkflowRunLogs(ctx, selected.ID, afterSeq)
|
||||
if err != nil {
|
||||
return dashboardDispatchLiveResponse{}, err
|
||||
}
|
||||
entries := make([]dashboardDispatchLiveEntry, 0, len(logs))
|
||||
offset := afterSeq
|
||||
for _, item := range logs {
|
||||
entries = append(entries, dashboardDispatchLiveEntry{
|
||||
Text: item.Content,
|
||||
Timestamp: item.CreatedAt,
|
||||
})
|
||||
offset = item.Seq
|
||||
}
|
||||
return dashboardDispatchLiveResponse{
|
||||
Entries: entries,
|
||||
Offset: offset,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Roles(ctx context.Context, ws *workspace.Workspace) (dashboardRolesResponse, error) {
|
||||
roles, err := s.repo.ListRoles(ctx)
|
||||
if err != nil {
|
||||
return dashboardRolesResponse{}, err
|
||||
}
|
||||
var (
|
||||
messages []message.Record
|
||||
runs []workflow.Run
|
||||
)
|
||||
pendingByRole := make(map[string]int)
|
||||
if ws != nil {
|
||||
messages, err = s.repo.ListMessagesByWorkspace(ctx, ws.ID)
|
||||
if err != nil {
|
||||
return dashboardRolesResponse{}, err
|
||||
}
|
||||
runs, err = s.repo.ListWorkflowRunsByWorkspace(ctx, ws.ID)
|
||||
if err != nil {
|
||||
return dashboardRolesResponse{}, err
|
||||
}
|
||||
pending, err := s.repo.ListPendingDeliveriesByWorkspace(ctx, ws.ID)
|
||||
if err != nil {
|
||||
return dashboardRolesResponse{}, err
|
||||
}
|
||||
for _, item := range pending {
|
||||
pendingByRole[item.RoleName] += item.Count
|
||||
}
|
||||
}
|
||||
|
||||
items := make([]dashboardRoleInfo, 0, len(roles))
|
||||
for _, item := range roles {
|
||||
if !item.IsEnabled {
|
||||
continue
|
||||
}
|
||||
lastRun := latestRunForRole(runs, item.Name)
|
||||
lastMessage := latestMessageForRole(messages, item.Name)
|
||||
var session *dashboardSessionInfo
|
||||
if lastRun != nil {
|
||||
session = &dashboardSessionInfo{
|
||||
Role: item.Name,
|
||||
CreatedAt: lastRun.StartedAt,
|
||||
LastUsedAt: latestString(lastRun.CompletedAt, lastRun.StartedAt, lastMessage.CreatedAt),
|
||||
LastMessage: previewText(lastMessage.BodyMarkdown),
|
||||
}
|
||||
}
|
||||
items = append(items, dashboardRoleInfo{
|
||||
Name: item.Name,
|
||||
Description: item.Description,
|
||||
SortOrder: item.SortOrder,
|
||||
Pending: pendingByRole[item.Name],
|
||||
Session: session,
|
||||
})
|
||||
}
|
||||
return dashboardRolesResponse{Roles: items}, nil
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package dashboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"inbox/internal/domain/humantask"
|
||||
"inbox/internal/domain/lane"
|
||||
"inbox/internal/domain/lanesync"
|
||||
"inbox/internal/domain/message"
|
||||
"inbox/internal/domain/role"
|
||||
"inbox/internal/domain/task"
|
||||
"inbox/internal/domain/taskgraph"
|
||||
"inbox/internal/domain/topic"
|
||||
"inbox/internal/domain/workflow"
|
||||
"inbox/internal/domain/workspace"
|
||||
)
|
||||
|
||||
type OverviewRepository interface {
|
||||
GetProject(ctx context.Context, projectID string) (workspace.Project, error)
|
||||
GetTopicBySlugOrTitle(ctx context.Context, workspaceID, value string, spaces ...topic.Space) (topic.Record, error)
|
||||
ListTopics(ctx context.Context, workspaceID string) ([]topic.Record, error)
|
||||
ListTopicsBySpace(ctx context.Context, workspaceID string, space topic.Space) ([]topic.Record, error)
|
||||
ListMessagesByWorkspace(ctx context.Context, workspaceID string) ([]message.Record, error)
|
||||
ListMessagesByTopic(ctx context.Context, topicID string) ([]message.Record, error)
|
||||
ListRoles(ctx context.Context) ([]role.Definition, error)
|
||||
ListLanesByWorkspace(ctx context.Context, workspaceID string) ([]lane.Record, error)
|
||||
ListLanesByTopic(ctx context.Context, topicID string) ([]lane.Record, error)
|
||||
ListLaneSyncsByTopic(ctx context.Context, topicID string) ([]lanesync.Record, error)
|
||||
ListTasksByTopic(ctx context.Context, topicID string) ([]task.Record, error)
|
||||
ListTaskDependencies(ctx context.Context, taskID string) ([]task.Dependency, error)
|
||||
GetLatestTaskGraphVersionByTopic(ctx context.Context, topicID string) (taskgraph.Record, error)
|
||||
ListWorkflowRunsByWorkspace(ctx context.Context, workspaceID string) ([]workflow.Run, error)
|
||||
ListWorkflowRunsByTopic(ctx context.Context, topicID string) ([]workflow.Run, error)
|
||||
ListPendingHumanTasksByWorkspace(ctx context.Context, workspaceID string) ([]humantask.Record, error)
|
||||
ListPendingDeliveriesByWorkspace(ctx context.Context, workspaceID string) ([]message.PendingDelivery, error)
|
||||
ListWorkflowRunLogs(ctx context.Context, runID string, afterSeq int) ([]workflow.RunLog, error)
|
||||
}
|
||||
|
||||
type Repository interface {
|
||||
OverviewRepository
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repo Repository
|
||||
}
|
||||
|
||||
func NewService(repo Repository) *Service {
|
||||
return &Service{repo: repo}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
package dashboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"inbox/internal/base/timeutil"
|
||||
"inbox/internal/domain/humantask"
|
||||
"inbox/internal/domain/message"
|
||||
"inbox/internal/domain/role"
|
||||
"inbox/internal/domain/taskgraph"
|
||||
"inbox/internal/domain/topic"
|
||||
"inbox/internal/domain/workflow"
|
||||
"inbox/internal/domain/workspace"
|
||||
sqlitestore "inbox/internal/store/sqlite"
|
||||
)
|
||||
|
||||
func TestTopicsReturnsWorkflowTopicSummariesOnly(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
clock := timeutil.FixedClock{Time: time.Date(2026, 3, 16, 12, 0, 0, 0, time.UTC)}
|
||||
store := openDashboardTestStore(t, clock)
|
||||
ensureDashboardTestRole(t, ctx, store, role.Definition{
|
||||
Name: "product",
|
||||
Title: "Product",
|
||||
ExecutorKind: role.ExecutorKindCodex,
|
||||
IsEnabled: true,
|
||||
})
|
||||
ensureDashboardTestRole(t, ctx, store, role.Definition{
|
||||
Name: "backend",
|
||||
Title: "Backend",
|
||||
ExecutorKind: role.ExecutorKindCodex,
|
||||
IsEnabled: true,
|
||||
})
|
||||
|
||||
ws := createDashboardTestWorkspace(t, ctx, store)
|
||||
alpha := createDashboardTestTopic(t, ctx, store, ws.ID, "alpha", topic.SpaceWorkflow)
|
||||
beta := createDashboardTestTopic(t, ctx, store, ws.ID, "beta", topic.SpaceWorkflow)
|
||||
createDashboardTestTopic(t, ctx, store, ws.ID, "clarify", topic.SpaceClarify)
|
||||
|
||||
if _, err := store.CreateMessage(ctx, message.Record{
|
||||
WorkspaceID: ws.ID,
|
||||
TopicID: beta.ID,
|
||||
FromRoleName: "product",
|
||||
ToExpr: "backend",
|
||||
Type: message.TypeChat,
|
||||
Stage: "review",
|
||||
BodyMarkdown: "Please review the beta flow.",
|
||||
CreatedAt: "2026-03-16T12:10:00Z",
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateMessage(beta) error = %v", err)
|
||||
}
|
||||
if _, err := store.CreateWorkflowRun(ctx, workflow.Run{
|
||||
WorkspaceID: ws.ID,
|
||||
TopicID: alpha.ID,
|
||||
RoleName: "backend",
|
||||
Stage: workflow.StageExecution,
|
||||
Mode: "once",
|
||||
Status: workflow.RunStatusSucceeded,
|
||||
StartedAt: "2026-03-16T12:20:00Z",
|
||||
CompletedAt: "2026-03-16T12:30:00Z",
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateWorkflowRun(alpha) error = %v", err)
|
||||
}
|
||||
|
||||
service := NewService(store)
|
||||
payload, err := service.Topics(ctx, ws)
|
||||
if err != nil {
|
||||
t.Fatalf("Topics() error = %v", err)
|
||||
}
|
||||
|
||||
if len(payload.Topics) != 2 {
|
||||
t.Fatalf("expected 2 workflow topics, got %#v", payload.Topics)
|
||||
}
|
||||
if payload.Topics[0].Name != "alpha" || payload.Topics[0].LatestStage != string(workflow.StageExecution) {
|
||||
t.Fatalf("unexpected alpha topic payload: %#v", payload.Topics[0])
|
||||
}
|
||||
if payload.Topics[1].Name != "beta" || payload.Topics[1].LatestStage != "review" || payload.Topics[1].MessageCount != 1 {
|
||||
t.Fatalf("unexpected beta topic payload: %#v", payload.Topics[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkflowBoardAggregatesOnlyWorkflowTopicState(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
clock := timeutil.FixedClock{Time: time.Date(2026, 3, 16, 12, 0, 0, 0, time.UTC)}
|
||||
store := openDashboardTestStore(t, clock)
|
||||
|
||||
ensureDashboardTestRole(t, ctx, store, role.Definition{
|
||||
Name: "product",
|
||||
Title: "Product",
|
||||
ExecutorKind: role.ExecutorKindCodex,
|
||||
IsEnabled: true,
|
||||
})
|
||||
ensureDashboardTestRole(t, ctx, store, role.Definition{
|
||||
Name: "backend",
|
||||
Title: "Backend",
|
||||
ExecutorKind: role.ExecutorKindCodex,
|
||||
IsEnabled: true,
|
||||
})
|
||||
ensureDashboardTestRole(t, ctx, store, role.Definition{
|
||||
Name: "approver",
|
||||
Title: "Approver",
|
||||
ExecutorKind: role.ExecutorKindHuman,
|
||||
IsEnabled: true,
|
||||
})
|
||||
|
||||
ws := createDashboardTestWorkspace(t, ctx, store)
|
||||
workflowTopic := createDashboardTestTopic(t, ctx, store, ws.ID, "signup", topic.SpaceWorkflow)
|
||||
clarifyTopic := createDashboardTestTopic(t, ctx, store, ws.ID, "signup-q", topic.SpaceClarify)
|
||||
|
||||
workflowDelivery := createDashboardTestMessage(t, ctx, store, message.Record{
|
||||
WorkspaceID: ws.ID,
|
||||
TopicID: workflowTopic.ID,
|
||||
FromRoleName: "product",
|
||||
ToExpr: "backend",
|
||||
Type: message.TypeChat,
|
||||
Stage: "execution",
|
||||
BodyMarkdown: "Build the signup workflow.",
|
||||
CreatedAt: "2026-03-16T12:05:00Z",
|
||||
})
|
||||
createDashboardTestMessage(t, ctx, store, message.Record{
|
||||
WorkspaceID: ws.ID,
|
||||
TopicID: clarifyTopic.ID,
|
||||
FromRoleName: "product",
|
||||
ToExpr: "backend",
|
||||
Type: message.TypeQuestion,
|
||||
Stage: "clarification",
|
||||
BodyMarkdown: "Clarify the signup workflow.",
|
||||
CreatedAt: "2026-03-16T12:06:00Z",
|
||||
})
|
||||
createDashboardTestMessage(t, ctx, store, message.Record{
|
||||
WorkspaceID: ws.ID,
|
||||
TopicID: workflowTopic.ID,
|
||||
FromRoleName: "product",
|
||||
ToExpr: "approver",
|
||||
Type: message.TypeQuestion,
|
||||
Stage: "review",
|
||||
BodyMarkdown: "Need human approval for signup.",
|
||||
CreatedAt: "2026-03-16T12:07:00Z",
|
||||
})
|
||||
createDashboardTestMessage(t, ctx, store, message.Record{
|
||||
WorkspaceID: ws.ID,
|
||||
TopicID: clarifyTopic.ID,
|
||||
FromRoleName: "product",
|
||||
ToExpr: "approver",
|
||||
Type: message.TypeQuestion,
|
||||
Stage: "clarification",
|
||||
BodyMarkdown: "Need human clarification.",
|
||||
CreatedAt: "2026-03-16T12:08:00Z",
|
||||
})
|
||||
if _, err := store.CreateTaskGraphVersion(ctx, taskgraph.Record{
|
||||
TopicID: workflowTopic.ID,
|
||||
Version: 1,
|
||||
Status: taskgraph.StatusDraft,
|
||||
PlanJSON: `{"tasks":[]}`,
|
||||
PlanSummaryMarkdown: "Plan the signup delivery in two steps.\n\n1. Build the form.\n2. Verify the flow.",
|
||||
CreatedByRoleName: "leader",
|
||||
CreatedAt: "2026-03-16T12:09:00Z",
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateTaskGraphVersion() error = %v", err)
|
||||
}
|
||||
|
||||
service := NewService(store)
|
||||
payload, err := service.WorkflowBoard(ctx, ws, workflowTopic.Slug)
|
||||
if err != nil {
|
||||
t.Fatalf("WorkflowBoard() error = %v", err)
|
||||
}
|
||||
|
||||
if len(payload.Topics) != 1 || payload.Topics[0].Name != workflowTopic.Slug {
|
||||
t.Fatalf("expected only workflow topic in board summary, got %#v", payload.Topics)
|
||||
}
|
||||
if payload.ActiveTopic != workflowTopic.Slug || payload.Board == nil {
|
||||
t.Fatalf("expected active workflow board, got %#v", payload)
|
||||
}
|
||||
|
||||
waitingRoles := payload.Topics[0].WaitingRoles
|
||||
if !slices.Equal(waitingRoles, []string{"approver", "backend"}) {
|
||||
t.Fatalf("unexpected waiting roles: %#v", waitingRoles)
|
||||
}
|
||||
if payload.Board.Topic.MessageCount != 2 {
|
||||
t.Fatalf("expected only workflow-topic messages on the board, got %#v", payload.Board.Topic)
|
||||
}
|
||||
if len(payload.Board.PendingHumanTasks) != 1 {
|
||||
t.Fatalf("expected only workflow-topic human tasks, got %#v", payload.Board.PendingHumanTasks)
|
||||
}
|
||||
if payload.Board.PendingHumanTasks[0].PromptBody != "Need human approval for signup." {
|
||||
t.Fatalf("unexpected human task payload: %#v", payload.Board.PendingHumanTasks[0])
|
||||
}
|
||||
if payload.Board.Plan == nil || payload.Board.Plan.Status != string(taskgraph.StatusDraft) {
|
||||
t.Fatalf("expected workflow board plan payload, got %#v", payload.Board.Plan)
|
||||
}
|
||||
if payload.Board.Plan.SummaryMarkdown == "" {
|
||||
t.Fatalf("expected non-empty plan summary, got %#v", payload.Board.Plan)
|
||||
}
|
||||
if len(payload.Board.Links) != 2 {
|
||||
t.Fatalf("expected only workflow-topic links, got %#v", payload.Board.Links)
|
||||
}
|
||||
if payload.Board.Links[0].LastMessageAt < workflowDelivery.CreatedAt {
|
||||
t.Fatalf("unexpected link ordering: %#v", payload.Board.Links)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpaceTopicsSortsByLatestMessageTime(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
clock := timeutil.FixedClock{Time: time.Date(2026, 3, 16, 12, 0, 0, 0, time.UTC)}
|
||||
store := openDashboardTestStore(t, clock)
|
||||
ensureDashboardTestRole(t, ctx, store, role.Definition{
|
||||
Name: "product",
|
||||
Title: "Product",
|
||||
ExecutorKind: role.ExecutorKindCodex,
|
||||
IsEnabled: true,
|
||||
})
|
||||
ensureDashboardTestRole(t, ctx, store, role.Definition{
|
||||
Name: "backend",
|
||||
Title: "Backend",
|
||||
ExecutorKind: role.ExecutorKindCodex,
|
||||
IsEnabled: true,
|
||||
})
|
||||
|
||||
ws := createDashboardTestWorkspace(t, ctx, store)
|
||||
olderTopic := createDashboardTestTopic(t, ctx, store, ws.ID, "older", topic.SpaceWorkflow)
|
||||
newerTopic := createDashboardTestTopic(t, ctx, store, ws.ID, "newer", topic.SpaceWorkflow)
|
||||
|
||||
createDashboardTestMessage(t, ctx, store, message.Record{
|
||||
ID: "z-older",
|
||||
WorkspaceID: ws.ID,
|
||||
TopicID: olderTopic.ID,
|
||||
FromRoleName: "product",
|
||||
ToExpr: "backend",
|
||||
Type: message.TypeChat,
|
||||
Stage: "execution",
|
||||
BodyMarkdown: "Older topic message.",
|
||||
CreatedAt: "2026-03-16T12:01:00Z",
|
||||
})
|
||||
createDashboardTestMessage(t, ctx, store, message.Record{
|
||||
ID: "a-newer",
|
||||
WorkspaceID: ws.ID,
|
||||
TopicID: newerTopic.ID,
|
||||
FromRoleName: "product",
|
||||
ToExpr: "backend",
|
||||
Type: message.TypeChat,
|
||||
Stage: "execution",
|
||||
BodyMarkdown: "Newer topic message.",
|
||||
CreatedAt: "2026-03-16T12:02:00Z",
|
||||
})
|
||||
|
||||
service := NewService(store)
|
||||
payload, err := service.SpaceTopics(ctx, ws, topic.SpaceWorkflow)
|
||||
if err != nil {
|
||||
t.Fatalf("SpaceTopics() error = %v", err)
|
||||
}
|
||||
|
||||
if len(payload.Topics) != 2 {
|
||||
t.Fatalf("expected 2 topics, got %#v", payload.Topics)
|
||||
}
|
||||
if payload.Topics[0].Topic != "newer" || payload.Topics[1].Topic != "older" {
|
||||
t.Fatalf("expected topics sorted by latest message time, got %#v", payload.Topics)
|
||||
}
|
||||
}
|
||||
|
||||
func openDashboardTestStore(t *testing.T, clock timeutil.FixedClock) *sqlitestore.Store {
|
||||
t.Helper()
|
||||
|
||||
store, err := sqlitestore.OpenInMemory(clock)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenInMemory() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() { store.Close() })
|
||||
return store
|
||||
}
|
||||
|
||||
func createDashboardTestWorkspace(t *testing.T, ctx context.Context, store *sqlitestore.Store) workspace.Workspace {
|
||||
t.Helper()
|
||||
|
||||
project, err := store.CreateProject(ctx, workspace.Project{
|
||||
Slug: "demo",
|
||||
Name: "Demo",
|
||||
RootPath: t.TempDir(),
|
||||
DefaultBranch: "main",
|
||||
Status: "active",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProject() error = %v", err)
|
||||
}
|
||||
ws, err := store.CreateWorkspace(ctx, workspace.Workspace{
|
||||
ProjectID: project.ID,
|
||||
Slug: "main",
|
||||
Name: "Main",
|
||||
RootPath: t.TempDir(),
|
||||
BaseBranch: "main",
|
||||
WorktreeBranch: "worktree/main",
|
||||
RuntimeBackend: "host",
|
||||
Status: "active",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWorkspace() error = %v", err)
|
||||
}
|
||||
return ws
|
||||
}
|
||||
|
||||
func createDashboardTestTopic(t *testing.T, ctx context.Context, store *sqlitestore.Store, workspaceID, slug string, space topic.Space) topic.Record {
|
||||
t.Helper()
|
||||
|
||||
record, err := store.CreateTopic(ctx, topic.Record{
|
||||
WorkspaceID: workspaceID,
|
||||
Slug: slug,
|
||||
Title: slug,
|
||||
Space: space,
|
||||
Status: "execution",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTopic(%s) error = %v", slug, err)
|
||||
}
|
||||
return record
|
||||
}
|
||||
|
||||
func createDashboardTestMessage(t *testing.T, ctx context.Context, store *sqlitestore.Store, value message.Record) message.Record {
|
||||
t.Helper()
|
||||
|
||||
item, err := store.CreateMessage(ctx, value)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateMessage(%s) error = %v", value.BodyMarkdown, err)
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
func ensureDashboardTestRole(t *testing.T, ctx context.Context, store *sqlitestore.Store, definition role.Definition) {
|
||||
t.Helper()
|
||||
|
||||
if _, err := store.UpsertRole(ctx, definition, "test"); err != nil {
|
||||
t.Fatalf("UpsertRole(%s) error = %v", definition.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
var _ humantask.Record
|
||||
@@ -0,0 +1,87 @@
|
||||
package dashboard
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"inbox/internal/domain/message"
|
||||
"inbox/internal/domain/topic"
|
||||
"inbox/internal/domain/workflow"
|
||||
)
|
||||
|
||||
func latestMessageID(items []message.Record) string {
|
||||
var latest message.Record
|
||||
for _, item := range items {
|
||||
if item.CreatedAt > latest.CreatedAt || (item.CreatedAt == latest.CreatedAt && item.ID > latest.ID) {
|
||||
latest = item
|
||||
}
|
||||
}
|
||||
return latest.ID
|
||||
}
|
||||
|
||||
func latestMessageTime(items []message.Record) string {
|
||||
var latest message.Record
|
||||
for _, item := range items {
|
||||
if item.CreatedAt > latest.CreatedAt || (item.CreatedAt == latest.CreatedAt && item.ID > latest.ID) {
|
||||
latest = item
|
||||
}
|
||||
}
|
||||
return latest.CreatedAt
|
||||
}
|
||||
|
||||
func latestTopicTime(record topic.Record, messages []message.Record, runs []workflow.Run) string {
|
||||
value := record.UpdatedAt
|
||||
for _, item := range messages {
|
||||
value = latestString(value, item.CreatedAt)
|
||||
}
|
||||
for _, item := range runs {
|
||||
value = latestString(value, latestRunTime(&item))
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func latestTopicStage(record topic.Record, messages []message.Record, runs []workflow.Run) string {
|
||||
stage := record.Status
|
||||
timestamp := record.UpdatedAt
|
||||
for _, item := range messages {
|
||||
if item.Stage != "" && item.CreatedAt >= timestamp {
|
||||
stage = item.Stage
|
||||
timestamp = item.CreatedAt
|
||||
}
|
||||
}
|
||||
for _, item := range runs {
|
||||
runTime := latestRunTime(&item)
|
||||
if runTime >= timestamp {
|
||||
stage = string(item.Stage)
|
||||
timestamp = runTime
|
||||
}
|
||||
}
|
||||
return stage
|
||||
}
|
||||
|
||||
func topicStages(record topic.Record, messages []message.Record, runs []workflow.Run) []string {
|
||||
seen := make(map[string]struct{})
|
||||
add := func(value string) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return
|
||||
}
|
||||
if _, ok := seen[value]; ok {
|
||||
return
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
}
|
||||
add(record.Status)
|
||||
for _, item := range messages {
|
||||
add(item.Stage)
|
||||
}
|
||||
for _, item := range runs {
|
||||
add(string(item.Stage))
|
||||
}
|
||||
out := make([]string, 0, len(seen))
|
||||
for value := range seen {
|
||||
out = append(out, value)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
package dashboard
|
||||
|
||||
import "inbox/internal/domain/humantask"
|
||||
|
||||
type dashboardMessageItem struct {
|
||||
MessageID string `json:"message_id"`
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
Type string `json:"type"`
|
||||
Topic string `json:"topic"`
|
||||
Stage string `json:"stage"`
|
||||
BodyMarkdown string `json:"body_markdown"`
|
||||
ReplyTo string `json:"reply_to,omitempty"`
|
||||
}
|
||||
|
||||
type dashboardMessagesResponse struct {
|
||||
Messages []dashboardMessageItem `json:"messages"`
|
||||
}
|
||||
|
||||
type dashboardTopicInfo struct {
|
||||
Name string `json:"name"`
|
||||
MessageCount int `json:"message_count"`
|
||||
Stages []string `json:"stages"`
|
||||
LatestStage string `json:"latest_stage"`
|
||||
}
|
||||
|
||||
type dashboardTopicsResponse struct {
|
||||
Topics []dashboardTopicInfo `json:"topics"`
|
||||
}
|
||||
|
||||
type dashboardTopicRecord struct {
|
||||
Name string `json:"name"`
|
||||
Space string `json:"space"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type dashboardTopicRecordsResponse struct {
|
||||
Records []dashboardTopicRecord `json:"records"`
|
||||
}
|
||||
|
||||
type dashboardSpaceTopic struct {
|
||||
Topic string `json:"topic"`
|
||||
MessageCount int `json:"message_count"`
|
||||
LastFile string `json:"last_file"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type dashboardSpaceTopicsResponse struct {
|
||||
Topics []dashboardSpaceTopic `json:"topics"`
|
||||
}
|
||||
|
||||
type dashboardDispatchLog struct {
|
||||
Role string `json:"role"`
|
||||
InboxFile string `json:"inbox_file"`
|
||||
Stage string `json:"stage"`
|
||||
Topic string `json:"topic"`
|
||||
Mode string `json:"mode"`
|
||||
StartedAt string `json:"started_at"`
|
||||
CompletedAt string `json:"completed_at,omitempty"`
|
||||
ExitCode int `json:"exit_code"`
|
||||
Reply string `json:"reply,omitempty"`
|
||||
ErrorMessage string `json:"error_message,omitempty"`
|
||||
Running bool `json:"running"`
|
||||
}
|
||||
|
||||
type dashboardDispatchLogsResponse struct {
|
||||
Logs []dashboardDispatchLog `json:"logs"`
|
||||
}
|
||||
|
||||
type dashboardDispatchLiveEntry struct {
|
||||
Text string `json:"text"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
}
|
||||
|
||||
type dashboardDispatchLiveResponse struct {
|
||||
Entries []dashboardDispatchLiveEntry `json:"entries"`
|
||||
Offset int `json:"offset"`
|
||||
}
|
||||
|
||||
type dashboardSessionInfo struct {
|
||||
Role string `json:"role"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
LastUsedAt string `json:"last_used_at"`
|
||||
LastMessage string `json:"last_message"`
|
||||
}
|
||||
|
||||
type dashboardRoleInfo struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
Pending int `json:"pending"`
|
||||
Session *dashboardSessionInfo `json:"session"`
|
||||
}
|
||||
|
||||
type dashboardRolesResponse struct {
|
||||
Roles []dashboardRoleInfo `json:"roles"`
|
||||
}
|
||||
|
||||
type dashboardWorkflowTopicSummary struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
MessageCount int `json:"message_count"`
|
||||
LatestStage string `json:"latest_stage"`
|
||||
LatestTime string `json:"latest_time"`
|
||||
RunningRoles []string `json:"running_roles"`
|
||||
WaitingRoles []string `json:"waiting_roles"`
|
||||
}
|
||||
|
||||
type dashboardWorkflowDispatchSummary struct {
|
||||
Stage string `json:"stage"`
|
||||
Mode string `json:"mode"`
|
||||
StartedAt string `json:"started_at"`
|
||||
CompletedAt string `json:"completed_at,omitempty"`
|
||||
Running bool `json:"running"`
|
||||
ExitCode int `json:"exit_code"`
|
||||
}
|
||||
|
||||
type dashboardWorkflowAgent struct {
|
||||
Name string `json:"name"`
|
||||
Category string `json:"category"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
Description string `json:"description"`
|
||||
PendingGlobal int `json:"pending_global"`
|
||||
SessionLastUsedAt string `json:"session_last_used_at"`
|
||||
State string `json:"state"`
|
||||
LatestInboundAt string `json:"latest_inbound_at"`
|
||||
LatestOutboundAt string `json:"latest_outbound_at"`
|
||||
LatestInboundPreview string `json:"latest_inbound_preview"`
|
||||
LatestOutboundPreview string `json:"latest_outbound_preview"`
|
||||
CurrentDispatch *dashboardWorkflowDispatchSummary `json:"current_dispatch,omitempty"`
|
||||
}
|
||||
|
||||
type dashboardWorkflowLink struct {
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
Count int `json:"count"`
|
||||
LastMessageAt string `json:"last_message_at"`
|
||||
LastStage string `json:"last_stage"`
|
||||
LastType string `json:"last_type"`
|
||||
IsHot bool `json:"is_hot"`
|
||||
}
|
||||
|
||||
type dashboardWorkflowTopicDetail struct {
|
||||
Name string `json:"name"`
|
||||
LatestStage string `json:"latest_stage"`
|
||||
MessageCount int `json:"message_count"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type dashboardWorkflowPlan struct {
|
||||
Version int `json:"version"`
|
||||
Status string `json:"status"`
|
||||
SummaryMarkdown string `json:"summary_markdown"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
ConfirmedAt string `json:"confirmed_at,omitempty"`
|
||||
CreatedByRoleName string `json:"created_by_role_name"`
|
||||
SupersedesVersionID string `json:"supersedes_version_id,omitempty"`
|
||||
}
|
||||
|
||||
type dashboardWorkflowHumanTask struct {
|
||||
ID string `json:"id"`
|
||||
RoleName string `json:"role_name"`
|
||||
Status humantask.Status `json:"status"`
|
||||
PromptMessageID string `json:"prompt_message_id"`
|
||||
PromptFrom string `json:"prompt_from"`
|
||||
PromptStage string `json:"prompt_stage"`
|
||||
PromptBody string `json:"prompt_body"`
|
||||
AnsweredMessageID string `json:"answered_message_id,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type dashboardWorkflowSummary struct {
|
||||
RunningCount int `json:"running_count"`
|
||||
WaitingCount int `json:"waiting_count"`
|
||||
ActiveRoles []string `json:"active_roles"`
|
||||
LastEventAt string `json:"last_event_at"`
|
||||
}
|
||||
|
||||
type dashboardWorkflowLane struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Purpose string `json:"purpose,omitempty"`
|
||||
Status string `json:"status"`
|
||||
BranchName string `json:"branch_name"`
|
||||
HeadCommit string `json:"head_commit,omitempty"`
|
||||
WorktreePath string `json:"worktree_path"`
|
||||
ContainerName string `json:"container_name"`
|
||||
RuntimeEndpoint string `json:"runtime_endpoint"`
|
||||
StartedAt string `json:"started_at,omitempty"`
|
||||
CompletedAt string `json:"completed_at,omitempty"`
|
||||
ErrorMessage string `json:"error_message,omitempty"`
|
||||
LastSync *dashboardWorkflowLaneSync `json:"last_sync,omitempty"`
|
||||
SyncHistory []dashboardWorkflowLaneSync `json:"sync_history,omitempty"`
|
||||
}
|
||||
|
||||
type dashboardWorkflowLaneSync struct {
|
||||
UpstreamLaneID string `json:"upstream_lane_id"`
|
||||
TaskID string `json:"task_id"`
|
||||
UpstreamCommit string `json:"upstream_commit"`
|
||||
MergeCommit string `json:"merge_commit,omitempty"`
|
||||
Status string `json:"status"`
|
||||
ErrorMessage string `json:"error_message,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type dashboardWorkflowTaskDependency struct {
|
||||
DependsOnTaskID string `json:"depends_on_task_id"`
|
||||
}
|
||||
|
||||
type dashboardWorkflowTask struct {
|
||||
ID string `json:"id"`
|
||||
LaneID string `json:"lane_id"`
|
||||
Title string `json:"title"`
|
||||
Kind string `json:"kind"`
|
||||
Deliverables []string `json:"deliverables"`
|
||||
BatchKey string `json:"batch_key"`
|
||||
Status string `json:"status"`
|
||||
Priority int `json:"priority"`
|
||||
TaskOrder int `json:"task_order"`
|
||||
AcceptanceMarkdown string `json:"acceptance_markdown,omitempty"`
|
||||
BlockingReasonMarkdown string `json:"blocking_reason_markdown,omitempty"`
|
||||
ResultSummaryMarkdown string `json:"result_summary_markdown,omitempty"`
|
||||
Dependencies []dashboardWorkflowTaskDependency `json:"dependencies"`
|
||||
}
|
||||
|
||||
type dashboardWorkflowEvent struct {
|
||||
Kind string `json:"kind"`
|
||||
ID string `json:"id"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
Role string `json:"role,omitempty"`
|
||||
Stage string `json:"stage"`
|
||||
Type string `json:"type"`
|
||||
Body string `json:"body,omitempty"`
|
||||
ReplyTo string `json:"reply_to,omitempty"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
Running bool `json:"running"`
|
||||
StartedAt string `json:"started_at,omitempty"`
|
||||
CompletedAt string `json:"completed_at,omitempty"`
|
||||
ExitCode int `json:"exit_code"`
|
||||
Reply string `json:"reply,omitempty"`
|
||||
ErrorMessage string `json:"error_message,omitempty"`
|
||||
}
|
||||
|
||||
type dashboardWorkflowBoard struct {
|
||||
Topic dashboardWorkflowTopicDetail `json:"topic"`
|
||||
Plan *dashboardWorkflowPlan `json:"plan,omitempty"`
|
||||
Summary dashboardWorkflowSummary `json:"summary"`
|
||||
Agents []dashboardWorkflowAgent `json:"agents"`
|
||||
Lanes []dashboardWorkflowLane `json:"lanes"`
|
||||
Tasks []dashboardWorkflowTask `json:"tasks"`
|
||||
Links []dashboardWorkflowLink `json:"links"`
|
||||
Events []dashboardWorkflowEvent `json:"events"`
|
||||
PendingHumanTasks []dashboardWorkflowHumanTask `json:"pending_human_tasks"`
|
||||
}
|
||||
|
||||
type dashboardWorkflowBoardResponse struct {
|
||||
Topics []dashboardWorkflowTopicSummary `json:"topics"`
|
||||
ActiveTopic string `json:"active_topic,omitempty"`
|
||||
Board *dashboardWorkflowBoard `json:"board"`
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
package dashboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"inbox/internal/domain/humantask"
|
||||
"inbox/internal/domain/lane"
|
||||
"inbox/internal/domain/lanesync"
|
||||
"inbox/internal/domain/message"
|
||||
"inbox/internal/domain/role"
|
||||
"inbox/internal/domain/task"
|
||||
"inbox/internal/domain/topic"
|
||||
"inbox/internal/domain/workflow"
|
||||
"inbox/internal/domain/workspace"
|
||||
)
|
||||
|
||||
func (s *Service) WorkflowBoard(ctx context.Context, ws workspace.Workspace, activeSlug string) (dashboardWorkflowBoardResponse, error) {
|
||||
snapshot, err := s.loadWorkflowBoardSnapshot(ctx, ws.ID)
|
||||
if err != nil {
|
||||
return dashboardWorkflowBoardResponse{}, err
|
||||
}
|
||||
summaries := make([]dashboardWorkflowTopicSummary, 0, len(snapshot.topics))
|
||||
for _, item := range snapshot.topics {
|
||||
topicMessages := snapshot.messagesByTopic[item.ID]
|
||||
topicRuns := snapshot.runsByTopic[item.ID]
|
||||
summaries = append(summaries, dashboardWorkflowTopicSummary{
|
||||
Name: item.Slug,
|
||||
Status: item.Status,
|
||||
MessageCount: len(topicMessages),
|
||||
LatestStage: latestTopicStage(item, topicMessages, topicRuns),
|
||||
LatestTime: latestTopicTimeWithLanes(item, topicMessages, topicRuns, snapshot.lanesByTopic[item.ID]),
|
||||
RunningRoles: runningRolesForRuns(topicRuns),
|
||||
WaitingRoles: pendingRolesForTopic(
|
||||
mergePendingRoleCounts(snapshot.pendingByTopicRole[item.ID], snapshot.pendingHumanByTopicRole[item.ID]),
|
||||
snapshot.roles,
|
||||
),
|
||||
})
|
||||
}
|
||||
sort.Slice(summaries, func(i, j int) bool {
|
||||
if summaries[i].LatestTime == summaries[j].LatestTime {
|
||||
return summaries[i].Name < summaries[j].Name
|
||||
}
|
||||
return summaries[i].LatestTime > summaries[j].LatestTime
|
||||
})
|
||||
|
||||
if activeSlug == "" && len(summaries) > 0 {
|
||||
activeSlug = summaries[0].Name
|
||||
}
|
||||
|
||||
response := dashboardWorkflowBoardResponse{
|
||||
Topics: summaries,
|
||||
Board: nil,
|
||||
}
|
||||
if activeSlug == "" {
|
||||
return response, nil
|
||||
}
|
||||
record, err := s.repo.GetTopicBySlugOrTitle(ctx, ws.ID, activeSlug, topic.SpaceWorkflow)
|
||||
if err != nil {
|
||||
return dashboardWorkflowBoardResponse{}, err
|
||||
}
|
||||
response.ActiveTopic = record.Slug
|
||||
board, err := s.buildWorkflowBoard(
|
||||
ctx,
|
||||
record,
|
||||
snapshot.roles,
|
||||
snapshot.lanesByTopic[record.ID],
|
||||
snapshot.messagesByTopic[record.ID],
|
||||
snapshot.runsByTopic[record.ID],
|
||||
snapshot.pendingByTopicRole[record.ID],
|
||||
snapshot.pendingByRole,
|
||||
snapshot.pendingHumanByTopicRole[record.ID],
|
||||
snapshot.pendingHumanByRole,
|
||||
snapshot.pendingHumanByTopic[record.ID],
|
||||
)
|
||||
if err != nil {
|
||||
return dashboardWorkflowBoardResponse{}, err
|
||||
}
|
||||
response.Board = board
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *Service) buildWorkflowBoard(
|
||||
ctx context.Context,
|
||||
record topic.Record,
|
||||
roles []role.Definition,
|
||||
lanes []lane.Record,
|
||||
messages []message.Record,
|
||||
runs []workflow.Run,
|
||||
pendingByRole map[string]int,
|
||||
pendingGlobal map[string]int,
|
||||
pendingHumanByRole map[string]int,
|
||||
pendingHumanGlobal map[string]int,
|
||||
humanTasks []humantask.Record,
|
||||
) (*dashboardWorkflowBoard, error) {
|
||||
tasks, err := s.repo.ListTasksByTopic(ctx, record.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
laneSyncs, err := s.repo.ListLaneSyncsByTopic(ctx, record.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
latestPlan, planErr := s.repo.GetLatestTaskGraphVersionByTopic(ctx, record.ID)
|
||||
if planErr != nil && planErr != sql.ErrNoRows {
|
||||
return nil, planErr
|
||||
}
|
||||
events := make([]workflowEventEnvelope, 0, len(messages)+len(runs))
|
||||
messageByID := make(map[string]message.Record, len(messages))
|
||||
for _, item := range messages {
|
||||
messageByID[item.ID] = item
|
||||
events = append(events, workflowEventEnvelope{
|
||||
Key: item.ID,
|
||||
Timestamp: item.CreatedAt,
|
||||
Event: dashboardWorkflowEvent{
|
||||
Kind: "message",
|
||||
ID: item.ID,
|
||||
Timestamp: item.CreatedAt,
|
||||
From: item.FromRoleName,
|
||||
To: item.ToExpr,
|
||||
Stage: item.Stage,
|
||||
Type: string(item.Type),
|
||||
Body: item.BodyMarkdown,
|
||||
ReplyTo: item.ReplyToMessageID,
|
||||
},
|
||||
})
|
||||
}
|
||||
for _, item := range runs {
|
||||
timestamp := latestString(item.CompletedAt, item.StartedAt)
|
||||
reply := ""
|
||||
if item.ReplyMessageID != "" {
|
||||
reply = strings.TrimSpace(messageByID[item.ReplyMessageID].BodyMarkdown)
|
||||
}
|
||||
events = append(events, workflowEventEnvelope{
|
||||
Key: item.ID,
|
||||
Timestamp: timestamp,
|
||||
Event: dashboardWorkflowEvent{
|
||||
Kind: "dispatch",
|
||||
ID: item.ID,
|
||||
Timestamp: timestamp,
|
||||
Role: item.RoleName,
|
||||
Stage: string(item.Stage),
|
||||
Mode: item.Mode,
|
||||
Running: item.Status == workflow.RunStatusRunning,
|
||||
StartedAt: item.StartedAt,
|
||||
CompletedAt: item.CompletedAt,
|
||||
ExitCode: item.ExitCode,
|
||||
Reply: reply,
|
||||
ErrorMessage: item.ErrorMessage,
|
||||
},
|
||||
})
|
||||
}
|
||||
sort.Slice(events, func(i, j int) bool {
|
||||
if events[i].Timestamp == events[j].Timestamp {
|
||||
return events[i].Key < events[j].Key
|
||||
}
|
||||
return events[i].Timestamp < events[j].Timestamp
|
||||
})
|
||||
|
||||
combinedPendingByRole := mergePendingRoleCounts(pendingByRole, pendingHumanByRole)
|
||||
combinedPendingGlobal := mergePendingRoleCounts(pendingGlobal, pendingHumanGlobal)
|
||||
links := buildWorkflowLinks(messages, combinedPendingByRole, runs)
|
||||
agents := buildWorkflowAgents(roles, messages, runs, combinedPendingByRole, combinedPendingGlobal)
|
||||
summary := dashboardWorkflowSummary{ActiveRoles: []string{}}
|
||||
for _, agent := range agents {
|
||||
switch agent.State {
|
||||
case "running":
|
||||
summary.RunningCount++
|
||||
summary.ActiveRoles = append(summary.ActiveRoles, agent.Name)
|
||||
case "queued", "recent":
|
||||
if agent.State == "queued" {
|
||||
summary.WaitingCount++
|
||||
}
|
||||
summary.ActiveRoles = append(summary.ActiveRoles, agent.Name)
|
||||
}
|
||||
summary.LastEventAt = latestString(summary.LastEventAt, agent.SessionLastUsedAt)
|
||||
}
|
||||
summary.LastEventAt = latestString(summary.LastEventAt, record.UpdatedAt)
|
||||
summary.LastEventAt = latestString(summary.LastEventAt, latestLaneTime(lanes))
|
||||
taskItems, err := s.buildWorkflowTasks(ctx, tasks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
laneItems := buildWorkflowLanes(lanes, laneSyncs)
|
||||
for _, item := range laneItems {
|
||||
switch item.Status {
|
||||
case string(lane.StatusRunning):
|
||||
summary.RunningCount++
|
||||
case string(lane.StatusReady), string(lane.StatusBlocked):
|
||||
summary.WaitingCount++
|
||||
}
|
||||
}
|
||||
|
||||
payloadEvents := make([]dashboardWorkflowEvent, 0, len(events))
|
||||
for _, item := range events {
|
||||
payloadEvents = append(payloadEvents, item.Event)
|
||||
}
|
||||
payloadHumanTasks := buildWorkflowHumanTasks(humanTasks, messageByID)
|
||||
var planPayload *dashboardWorkflowPlan
|
||||
if planErr == nil {
|
||||
planPayload = &dashboardWorkflowPlan{
|
||||
Version: latestPlan.Version,
|
||||
Status: string(latestPlan.Status),
|
||||
SummaryMarkdown: latestPlan.PlanSummaryMarkdown,
|
||||
CreatedAt: latestPlan.CreatedAt,
|
||||
ConfirmedAt: latestPlan.ConfirmedAt,
|
||||
CreatedByRoleName: latestPlan.CreatedByRoleName,
|
||||
SupersedesVersionID: latestPlan.SupersedesGraphVersionID,
|
||||
}
|
||||
}
|
||||
|
||||
return &dashboardWorkflowBoard{
|
||||
Topic: dashboardWorkflowTopicDetail{
|
||||
Name: record.Slug,
|
||||
LatestStage: latestTopicStage(record, messages, runs),
|
||||
MessageCount: len(messages),
|
||||
CreatedAt: record.CreatedAt,
|
||||
UpdatedAt: record.UpdatedAt,
|
||||
Status: record.Status,
|
||||
},
|
||||
Plan: planPayload,
|
||||
Summary: summary,
|
||||
Agents: agents,
|
||||
Lanes: laneItems,
|
||||
Tasks: taskItems,
|
||||
Links: links,
|
||||
Events: payloadEvents,
|
||||
PendingHumanTasks: payloadHumanTasks,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type workflowEventEnvelope struct {
|
||||
Key string
|
||||
Timestamp string
|
||||
Event dashboardWorkflowEvent
|
||||
}
|
||||
|
||||
func buildWorkflowAgents(
|
||||
roles []role.Definition,
|
||||
messages []message.Record,
|
||||
runs []workflow.Run,
|
||||
pendingByRole map[string]int,
|
||||
pendingGlobal map[string]int,
|
||||
) []dashboardWorkflowAgent {
|
||||
items := make([]dashboardWorkflowAgent, 0, len(roles)+1)
|
||||
for _, item := range roles {
|
||||
inbound := latestInboundMessage(messages, item.Name)
|
||||
outbound := latestOutboundMessage(messages, item.Name)
|
||||
run := latestRunForRole(runs, item.Name)
|
||||
isHuman := item.ExecutorKind == role.ExecutorKindHuman
|
||||
if !item.IsEnabled && !isHuman {
|
||||
continue
|
||||
}
|
||||
if isHuman && inbound.ID == "" && outbound.ID == "" && pendingByRole[item.Name] == 0 {
|
||||
continue
|
||||
}
|
||||
state := "idle"
|
||||
if !isHuman && run != nil && run.Status == workflow.RunStatusRunning {
|
||||
state = "running"
|
||||
} else if pendingByRole[item.Name] > 0 {
|
||||
state = "queued"
|
||||
} else if run != nil || inbound.ID != "" || outbound.ID != "" {
|
||||
state = "recent"
|
||||
}
|
||||
agent := dashboardWorkflowAgent{
|
||||
Name: item.Name,
|
||||
Category: "workflow",
|
||||
SortOrder: item.SortOrder,
|
||||
Description: item.Description,
|
||||
PendingGlobal: pendingGlobal[item.Name],
|
||||
SessionLastUsedAt: latestString(latestRunTime(run), inbound.CreatedAt, outbound.CreatedAt),
|
||||
State: state,
|
||||
LatestInboundAt: inbound.CreatedAt,
|
||||
LatestOutboundAt: outbound.CreatedAt,
|
||||
LatestInboundPreview: previewText(inbound.BodyMarkdown),
|
||||
LatestOutboundPreview: previewText(outbound.BodyMarkdown),
|
||||
}
|
||||
if isHuman {
|
||||
agent.Category = "user"
|
||||
} else if run != nil {
|
||||
agent.CurrentDispatch = &dashboardWorkflowDispatchSummary{
|
||||
Stage: string(run.Stage),
|
||||
Mode: run.Mode,
|
||||
StartedAt: run.StartedAt,
|
||||
CompletedAt: run.CompletedAt,
|
||||
Running: run.Status == workflow.RunStatusRunning,
|
||||
ExitCode: run.ExitCode,
|
||||
}
|
||||
}
|
||||
items = append(items, agent)
|
||||
}
|
||||
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
if items[i].SortOrder == items[j].SortOrder {
|
||||
return items[i].Name < items[j].Name
|
||||
}
|
||||
return items[i].SortOrder < items[j].SortOrder
|
||||
})
|
||||
return items
|
||||
}
|
||||
|
||||
func buildWorkflowLinks(messages []message.Record, pendingByRole map[string]int, runs []workflow.Run) []dashboardWorkflowLink {
|
||||
type key struct {
|
||||
from string
|
||||
to string
|
||||
}
|
||||
grouped := make(map[key]dashboardWorkflowLink)
|
||||
runningByRole := make(map[string]bool)
|
||||
for _, item := range runs {
|
||||
if item.Status == workflow.RunStatusRunning {
|
||||
runningByRole[item.RoleName] = true
|
||||
}
|
||||
}
|
||||
for _, item := range messages {
|
||||
for _, recipient := range splitRecipients(item.ToExpr) {
|
||||
k := key{from: item.FromRoleName, to: recipient}
|
||||
link := grouped[k]
|
||||
link.From = item.FromRoleName
|
||||
link.To = recipient
|
||||
link.Count++
|
||||
link.LastMessageAt = latestString(link.LastMessageAt, item.CreatedAt)
|
||||
if item.CreatedAt >= link.LastMessageAt {
|
||||
link.LastStage = item.Stage
|
||||
link.LastType = string(item.Type)
|
||||
}
|
||||
link.IsHot = pendingByRole[recipient] > 0 || runningByRole[recipient]
|
||||
grouped[k] = link
|
||||
}
|
||||
}
|
||||
items := make([]dashboardWorkflowLink, 0, len(grouped))
|
||||
for _, item := range grouped {
|
||||
items = append(items, item)
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
if items[i].LastMessageAt == items[j].LastMessageAt {
|
||||
if items[i].From == items[j].From {
|
||||
return items[i].To < items[j].To
|
||||
}
|
||||
return items[i].From < items[j].From
|
||||
}
|
||||
return items[i].LastMessageAt > items[j].LastMessageAt
|
||||
})
|
||||
return items
|
||||
}
|
||||
|
||||
func buildWorkflowHumanTasks(tasks []humantask.Record, messageByID map[string]message.Record) []dashboardWorkflowHumanTask {
|
||||
if len(tasks) == 0 {
|
||||
return []dashboardWorkflowHumanTask{}
|
||||
}
|
||||
items := make([]dashboardWorkflowHumanTask, 0, len(tasks))
|
||||
for _, item := range tasks {
|
||||
prompt := messageByID[item.PromptMessageID]
|
||||
items = append(items, dashboardWorkflowHumanTask{
|
||||
ID: item.ID,
|
||||
RoleName: item.RoleName,
|
||||
Status: item.Status,
|
||||
PromptMessageID: item.PromptMessageID,
|
||||
PromptFrom: prompt.FromRoleName,
|
||||
PromptStage: prompt.Stage,
|
||||
PromptBody: prompt.BodyMarkdown,
|
||||
AnsweredMessageID: item.AnsweredMessageID,
|
||||
CreatedAt: item.CreatedAt,
|
||||
UpdatedAt: item.UpdatedAt,
|
||||
})
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
if items[i].UpdatedAt == items[j].UpdatedAt {
|
||||
return items[i].ID > items[j].ID
|
||||
}
|
||||
return items[i].UpdatedAt > items[j].UpdatedAt
|
||||
})
|
||||
return items
|
||||
}
|
||||
|
||||
func latestTopicTimeWithLanes(record topic.Record, messages []message.Record, runs []workflow.Run, lanes []lane.Record) string {
|
||||
value := latestTopicTime(record, messages, runs)
|
||||
return latestString(value, latestLaneTime(lanes))
|
||||
}
|
||||
|
||||
func latestLaneTime(items []lane.Record) string {
|
||||
latest := ""
|
||||
for _, item := range items {
|
||||
latest = latestString(latest, item.CompletedAt, item.StartedAt, item.UpdatedAt, item.CreatedAt)
|
||||
}
|
||||
return latest
|
||||
}
|
||||
|
||||
func buildWorkflowLanes(items []lane.Record, laneSyncs []lanesync.Record) []dashboardWorkflowLane {
|
||||
syncsByLane := make(map[string][]dashboardWorkflowLaneSync)
|
||||
for _, item := range laneSyncs {
|
||||
entry := dashboardWorkflowLaneSync{
|
||||
UpstreamLaneID: item.UpstreamLaneID,
|
||||
TaskID: item.TaskID,
|
||||
UpstreamCommit: item.UpstreamCommit,
|
||||
MergeCommit: item.MergeCommit,
|
||||
Status: string(item.Status),
|
||||
ErrorMessage: item.ErrorMessage,
|
||||
CreatedAt: item.CreatedAt,
|
||||
}
|
||||
syncsByLane[item.DownstreamLaneID] = append(syncsByLane[item.DownstreamLaneID], entry)
|
||||
}
|
||||
out := make([]dashboardWorkflowLane, 0, len(items))
|
||||
for _, item := range items {
|
||||
syncHistory := syncsByLane[item.ID]
|
||||
var lastSync *dashboardWorkflowLaneSync
|
||||
if len(syncHistory) > 0 {
|
||||
lastSync = &syncHistory[0]
|
||||
}
|
||||
out = append(out, dashboardWorkflowLane{
|
||||
ID: item.ID,
|
||||
Name: item.Name,
|
||||
Slug: item.Slug,
|
||||
Purpose: item.Purpose,
|
||||
Status: string(item.Status),
|
||||
BranchName: item.BranchName,
|
||||
HeadCommit: item.HeadCommit,
|
||||
WorktreePath: item.WorktreePath,
|
||||
ContainerName: item.ContainerName,
|
||||
RuntimeEndpoint: item.RuntimeEndpoint,
|
||||
StartedAt: item.StartedAt,
|
||||
CompletedAt: item.CompletedAt,
|
||||
ErrorMessage: item.ErrorMessage,
|
||||
LastSync: lastSync,
|
||||
SyncHistory: syncHistory,
|
||||
})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
return out[i].Name < out[j].Name
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Service) buildWorkflowTasks(ctx context.Context, items []task.Record) ([]dashboardWorkflowTask, error) {
|
||||
out := make([]dashboardWorkflowTask, 0, len(items))
|
||||
for _, item := range items {
|
||||
deps, err := s.repo.ListTaskDependencies(ctx, item.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dependencies := make([]dashboardWorkflowTaskDependency, 0, len(deps))
|
||||
for _, dep := range deps {
|
||||
dependencies = append(dependencies, dashboardWorkflowTaskDependency{
|
||||
DependsOnTaskID: dep.DependsOnTaskID,
|
||||
})
|
||||
}
|
||||
out = append(out, dashboardWorkflowTask{
|
||||
ID: item.ID,
|
||||
LaneID: item.LaneID,
|
||||
Title: item.Title,
|
||||
Kind: string(item.Kind),
|
||||
Deliverables: append([]string(nil), item.Deliverables...),
|
||||
BatchKey: item.BatchKey,
|
||||
Status: string(item.Status),
|
||||
Priority: item.Priority,
|
||||
TaskOrder: item.TaskOrder,
|
||||
AcceptanceMarkdown: item.AcceptanceMarkdown,
|
||||
BlockingReasonMarkdown: item.BlockingReasonMarkdown,
|
||||
ResultSummaryMarkdown: item.ResultSummaryMarkdown,
|
||||
Dependencies: dependencies,
|
||||
})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].LaneID == out[j].LaneID {
|
||||
if out[i].TaskOrder == out[j].TaskOrder {
|
||||
return out[i].Title < out[j].Title
|
||||
}
|
||||
return out[i].TaskOrder < out[j].TaskOrder
|
||||
}
|
||||
return out[i].LaneID < out[j].LaneID
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func mergePendingRoleCounts(left, right map[string]int) map[string]int {
|
||||
if len(left) == 0 && len(right) == 0 {
|
||||
return map[string]int{}
|
||||
}
|
||||
out := make(map[string]int, len(left)+len(right))
|
||||
for key, value := range left {
|
||||
out[key] += value
|
||||
}
|
||||
for key, value := range right {
|
||||
out[key] += value
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package dashboard
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"inbox/internal/domain/message"
|
||||
"inbox/internal/domain/role"
|
||||
"inbox/internal/domain/workflow"
|
||||
)
|
||||
|
||||
func latestRunForRole(items []workflow.Run, roleName string) *workflow.Run {
|
||||
var selected *workflow.Run
|
||||
for _, item := range items {
|
||||
if item.RoleName != roleName {
|
||||
continue
|
||||
}
|
||||
if selected == nil || item.StartedAt > selected.StartedAt || (item.StartedAt == selected.StartedAt && item.ID > selected.ID) {
|
||||
runCopy := item
|
||||
selected = &runCopy
|
||||
}
|
||||
}
|
||||
return selected
|
||||
}
|
||||
|
||||
func latestInboundMessage(items []message.Record, roleName string) message.Record {
|
||||
var selected message.Record
|
||||
for _, item := range items {
|
||||
if !messageTargetsRole(item.ToExpr, roleName) {
|
||||
continue
|
||||
}
|
||||
if item.CreatedAt > selected.CreatedAt || (item.CreatedAt == selected.CreatedAt && item.ID > selected.ID) {
|
||||
selected = item
|
||||
}
|
||||
}
|
||||
return selected
|
||||
}
|
||||
|
||||
func latestOutboundMessage(items []message.Record, roleName string) message.Record {
|
||||
var selected message.Record
|
||||
for _, item := range items {
|
||||
if item.FromRoleName != roleName {
|
||||
continue
|
||||
}
|
||||
if item.CreatedAt > selected.CreatedAt || (item.CreatedAt == selected.CreatedAt && item.ID > selected.ID) {
|
||||
selected = item
|
||||
}
|
||||
}
|
||||
return selected
|
||||
}
|
||||
|
||||
func latestMessageForRole(items []message.Record, roleName string) message.Record {
|
||||
inbound := latestInboundMessage(items, roleName)
|
||||
outbound := latestOutboundMessage(items, roleName)
|
||||
if outbound.CreatedAt > inbound.CreatedAt || (outbound.CreatedAt == inbound.CreatedAt && outbound.ID > inbound.ID) {
|
||||
return outbound
|
||||
}
|
||||
return inbound
|
||||
}
|
||||
|
||||
func runningRolesForRuns(items []workflow.Run) []string {
|
||||
seen := make(map[string]struct{})
|
||||
out := make([]string, 0)
|
||||
for _, item := range items {
|
||||
if item.Status != workflow.RunStatusRunning {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[item.RoleName]; ok {
|
||||
continue
|
||||
}
|
||||
seen[item.RoleName] = struct{}{}
|
||||
out = append(out, item.RoleName)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func pendingRolesForTopic(items map[string]int, roles []role.Definition) []string {
|
||||
if len(items) == 0 {
|
||||
return []string{}
|
||||
}
|
||||
valid := make(map[string]bool)
|
||||
for _, item := range roles {
|
||||
if item.IsEnabled || item.ExecutorKind == role.ExecutorKindHuman {
|
||||
valid[item.Name] = true
|
||||
}
|
||||
}
|
||||
out := make([]string, 0, len(items))
|
||||
for roleName, count := range items {
|
||||
if count <= 0 || !valid[roleName] {
|
||||
continue
|
||||
}
|
||||
out = append(out, roleName)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package dashboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"inbox/internal/domain/humantask"
|
||||
"inbox/internal/domain/lane"
|
||||
"inbox/internal/domain/message"
|
||||
"inbox/internal/domain/role"
|
||||
"inbox/internal/domain/topic"
|
||||
"inbox/internal/domain/workflow"
|
||||
)
|
||||
|
||||
type workspaceTopicSnapshot struct {
|
||||
topics []topic.Record
|
||||
topicByID map[string]topic.Record
|
||||
messagesByTopic map[string][]message.Record
|
||||
runsByTopic map[string][]workflow.Run
|
||||
}
|
||||
|
||||
type workflowBoardSnapshot struct {
|
||||
workspaceTopicSnapshot
|
||||
roles []role.Definition
|
||||
lanesByTopic map[string][]lane.Record
|
||||
pendingByTopicRole map[string]map[string]int
|
||||
pendingByRole map[string]int
|
||||
pendingHumanByTopic map[string][]humantask.Record
|
||||
pendingHumanByTopicRole map[string]map[string]int
|
||||
pendingHumanByRole map[string]int
|
||||
}
|
||||
|
||||
func (s *Service) loadWorkspaceTopicSnapshot(ctx context.Context, workspaceID string, space topic.Space) (workspaceTopicSnapshot, error) {
|
||||
topics, err := s.repo.ListTopicsBySpace(ctx, workspaceID, space)
|
||||
if err != nil {
|
||||
return workspaceTopicSnapshot{}, err
|
||||
}
|
||||
messages, err := s.repo.ListMessagesByWorkspace(ctx, workspaceID)
|
||||
if err != nil {
|
||||
return workspaceTopicSnapshot{}, err
|
||||
}
|
||||
runs, err := s.repo.ListWorkflowRunsByWorkspace(ctx, workspaceID)
|
||||
if err != nil {
|
||||
return workspaceTopicSnapshot{}, err
|
||||
}
|
||||
|
||||
topicByID := make(map[string]topic.Record, len(topics))
|
||||
for _, item := range topics {
|
||||
topicByID[item.ID] = item
|
||||
}
|
||||
return workspaceTopicSnapshot{
|
||||
topics: topics,
|
||||
topicByID: topicByID,
|
||||
messagesByTopic: filterMessagesByKnownTopics(messages, topicByID),
|
||||
runsByTopic: filterRunsByKnownTopics(runs, topicByID),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) loadWorkflowBoardSnapshot(ctx context.Context, workspaceID string) (workflowBoardSnapshot, error) {
|
||||
topicSnapshot, err := s.loadWorkspaceTopicSnapshot(ctx, workspaceID, topic.SpaceWorkflow)
|
||||
if err != nil {
|
||||
return workflowBoardSnapshot{}, err
|
||||
}
|
||||
roles, err := s.repo.ListRoles(ctx)
|
||||
if err != nil {
|
||||
return workflowBoardSnapshot{}, err
|
||||
}
|
||||
lanes, err := s.repo.ListLanesByWorkspace(ctx, workspaceID)
|
||||
if err != nil {
|
||||
return workflowBoardSnapshot{}, err
|
||||
}
|
||||
pending, err := s.repo.ListPendingDeliveriesByWorkspace(ctx, workspaceID)
|
||||
if err != nil {
|
||||
return workflowBoardSnapshot{}, err
|
||||
}
|
||||
pendingHumanTasks, err := s.repo.ListPendingHumanTasksByWorkspace(ctx, workspaceID)
|
||||
if err != nil {
|
||||
return workflowBoardSnapshot{}, err
|
||||
}
|
||||
|
||||
return workflowBoardSnapshot{
|
||||
workspaceTopicSnapshot: topicSnapshot,
|
||||
roles: roles,
|
||||
lanesByTopic: groupLanesByKnownTopics(lanes, topicSnapshot.topicByID),
|
||||
pendingByTopicRole: groupPendingDeliveriesByTopicRole(pending, topicSnapshot.topicByID),
|
||||
pendingByRole: groupPendingDeliveriesByRole(pending, topicSnapshot.topicByID),
|
||||
pendingHumanByTopic: groupHumanTasksByKnownTopics(pendingHumanTasks, topicSnapshot.topicByID),
|
||||
pendingHumanByTopicRole: groupPendingHumanTasksByTopicRole(
|
||||
pendingHumanTasks,
|
||||
topicSnapshot.topicByID,
|
||||
),
|
||||
pendingHumanByRole: groupPendingHumanTasksByRole(pendingHumanTasks, topicSnapshot.topicByID),
|
||||
}, nil
|
||||
}
|
||||
Reference in New Issue
Block a user