chore(repo): reinitialize repository

This commit is contained in:
2026-03-18 11:29:54 +08:00
commit 24871e213a
288 changed files with 44369 additions and 0 deletions
@@ -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
}
+263
View File
@@ -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
}
+49
View File
@@ -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
}
+270
View File
@@ -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
}