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
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package humantasks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"inbox/internal/base/timeutil"
|
||||
"inbox/internal/domain/humantask"
|
||||
"inbox/internal/domain/message"
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
GetHumanTask(ctx context.Context, taskID string) (humantask.Record, error)
|
||||
ListHumanTasksByTopic(ctx context.Context, topicID string) ([]humantask.Record, error)
|
||||
ListPendingHumanTasksByWorkspace(ctx context.Context, workspaceID string) ([]humantask.Record, error)
|
||||
GetMessage(ctx context.Context, messageID string) (message.Record, error)
|
||||
AnswerHumanTask(ctx context.Context, taskID string, reply message.Record) (humantask.Record, message.Record, error)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repo Repository
|
||||
clock timeutil.Clock
|
||||
}
|
||||
|
||||
type AnswerResult struct {
|
||||
Task humantask.Record `json:"task"`
|
||||
Message message.Record `json:"message"`
|
||||
}
|
||||
|
||||
func NewService(repo Repository, clock timeutil.Clock) *Service {
|
||||
if clock == nil {
|
||||
clock = timeutil.SystemClock{}
|
||||
}
|
||||
return &Service{repo: repo, clock: clock}
|
||||
}
|
||||
|
||||
func (s *Service) ListByTopic(ctx context.Context, topicID string) ([]humantask.Record, error) {
|
||||
return s.repo.ListHumanTasksByTopic(ctx, topicID)
|
||||
}
|
||||
|
||||
func (s *Service) ListPendingByWorkspace(ctx context.Context, workspaceID string) ([]humantask.Record, error) {
|
||||
return s.repo.ListPendingHumanTasksByWorkspace(ctx, workspaceID)
|
||||
}
|
||||
|
||||
func (s *Service) Answer(ctx context.Context, taskID, bodyMarkdown string) (AnswerResult, error) {
|
||||
bodyMarkdown = strings.TrimSpace(bodyMarkdown)
|
||||
if bodyMarkdown == "" {
|
||||
return AnswerResult{}, fmt.Errorf("body markdown is required")
|
||||
}
|
||||
task, err := s.repo.GetHumanTask(ctx, taskID)
|
||||
if err != nil {
|
||||
return AnswerResult{}, err
|
||||
}
|
||||
prompt, err := s.repo.GetMessage(ctx, task.PromptMessageID)
|
||||
if err != nil {
|
||||
return AnswerResult{}, err
|
||||
}
|
||||
updatedTask, reply, err := s.repo.AnswerHumanTask(ctx, task.ID, message.Record{
|
||||
WorkspaceID: task.WorkspaceID,
|
||||
TopicID: task.TopicID,
|
||||
FromRoleName: task.RoleName,
|
||||
ToExpr: prompt.FromRoleName,
|
||||
Type: message.TypeChat,
|
||||
Stage: prompt.Stage,
|
||||
ReplyToMessageID: prompt.ID,
|
||||
BodyMarkdown: bodyMarkdown,
|
||||
CreatedAt: timeutil.FormatRFC3339(s.clock.Now()),
|
||||
})
|
||||
if err != nil {
|
||||
return AnswerResult{}, err
|
||||
}
|
||||
return AnswerResult{Task: updatedTask, Message: reply}, nil
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package lanegit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Runner interface {
|
||||
Run(ctx context.Context, workdir string, env map[string]string, name string, args ...string) (string, error)
|
||||
}
|
||||
|
||||
type ExecRunner struct{}
|
||||
|
||||
func (ExecRunner) Run(ctx context.Context, workdir string, env map[string]string, name string, args ...string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
if strings.TrimSpace(workdir) != "" {
|
||||
cmd.Dir = workdir
|
||||
}
|
||||
cmd.Env = os.Environ()
|
||||
for key, value := range env {
|
||||
cmd.Env = append(cmd.Env, key+"="+value)
|
||||
}
|
||||
out, err := cmd.CombinedOutput()
|
||||
return strings.TrimSpace(string(out)), err
|
||||
}
|
||||
|
||||
func Run(ctx context.Context, runner Runner, worktree string, env map[string]string, args ...string) (string, error) {
|
||||
out, err := runner.Run(ctx, worktree, env, "git", args...)
|
||||
if err == nil {
|
||||
return out, nil
|
||||
}
|
||||
out = strings.TrimSpace(out)
|
||||
if out == "" {
|
||||
return "", fmt.Errorf("git %s: %w", strings.Join(args, " "), err)
|
||||
}
|
||||
return "", fmt.Errorf("git %s: %s: %w", strings.Join(args, " "), out, err)
|
||||
}
|
||||
|
||||
func IsExitCode(err error, code int) bool {
|
||||
var exitErr *exec.ExitError
|
||||
return errors.As(err, &exitErr) && exitErr.ExitCode() == code
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package lanematerialize
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"inbox/internal/app/lanegit"
|
||||
"inbox/internal/base/timeutil"
|
||||
"inbox/internal/domain/lane"
|
||||
"inbox/internal/domain/lanesync"
|
||||
)
|
||||
|
||||
type SyncRecorder interface {
|
||||
CreateLaneSync(ctx context.Context, value lanesync.Record) (lanesync.Record, error)
|
||||
}
|
||||
|
||||
type Upstream struct {
|
||||
TaskID string
|
||||
Lane lane.Record
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
recorder SyncRecorder
|
||||
runner lanegit.Runner
|
||||
clock timeutil.Clock
|
||||
}
|
||||
|
||||
func NewService(recorder SyncRecorder, runner lanegit.Runner, clock timeutil.Clock) *Service {
|
||||
if runner == nil {
|
||||
runner = lanegit.ExecRunner{}
|
||||
}
|
||||
if clock == nil {
|
||||
clock = timeutil.SystemClock{}
|
||||
}
|
||||
return &Service{recorder: recorder, runner: runner, clock: clock}
|
||||
}
|
||||
|
||||
func (s *Service) Materialize(ctx context.Context, downstream lane.Record, taskID string, upstreams []Upstream) error {
|
||||
if len(upstreams) == 0 {
|
||||
return nil
|
||||
}
|
||||
worktree := strings.TrimSpace(downstream.WorktreePath)
|
||||
if worktree == "" {
|
||||
return fmt.Errorf("lane %s has empty worktree path", downstream.ID)
|
||||
}
|
||||
clean, err := s.isClean(ctx, worktree)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !clean {
|
||||
return fmt.Errorf("lane %s worktree has uncommitted changes before materialization", downstream.ID)
|
||||
}
|
||||
|
||||
sort.SliceStable(upstreams, func(i, j int) bool {
|
||||
if upstreams[i].Lane.ID == upstreams[j].Lane.ID {
|
||||
return upstreams[i].TaskID < upstreams[j].TaskID
|
||||
}
|
||||
return upstreams[i].Lane.ID < upstreams[j].Lane.ID
|
||||
})
|
||||
|
||||
for _, upstream := range upstreams {
|
||||
if upstream.Lane.ID == downstream.ID {
|
||||
continue
|
||||
}
|
||||
commit := strings.TrimSpace(upstream.Lane.HeadCommit)
|
||||
if commit == "" {
|
||||
err := fmt.Errorf("upstream lane %s has no head_commit", upstream.Lane.ID)
|
||||
s.record(ctx, downstream, upstream, "", lanesync.StatusFailed, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
alreadyMerged, err := s.isAncestor(ctx, worktree, commit)
|
||||
if err != nil {
|
||||
s.record(ctx, downstream, upstream, "", lanesync.StatusFailed, err.Error())
|
||||
return err
|
||||
}
|
||||
if alreadyMerged {
|
||||
s.record(ctx, downstream, upstream, commit, lanesync.StatusSkipped, "")
|
||||
continue
|
||||
}
|
||||
|
||||
if _, err := lanegit.Run(ctx, s.runner, worktree, nil, "merge", "--no-ff", "--no-edit", commit); err != nil {
|
||||
_ = s.abortMerge(ctx, worktree)
|
||||
s.record(ctx, downstream, upstream, "", lanesync.StatusFailed, err.Error())
|
||||
return err
|
||||
}
|
||||
mergedHead, err := s.headCommit(ctx, worktree)
|
||||
if err != nil {
|
||||
s.record(ctx, downstream, upstream, "", lanesync.StatusFailed, err.Error())
|
||||
return err
|
||||
}
|
||||
s.record(ctx, downstream, upstream, mergedHead, lanesync.StatusApplied, "")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) record(ctx context.Context, downstream lane.Record, upstream Upstream, mergeCommit string, status lanesync.Status, errorMessage string) {
|
||||
if s.recorder == nil {
|
||||
return
|
||||
}
|
||||
_, _ = s.recorder.CreateLaneSync(ctx, lanesync.Record{
|
||||
WorkspaceID: downstream.WorkspaceID,
|
||||
TopicID: downstream.TopicID,
|
||||
DownstreamLaneID: downstream.ID,
|
||||
UpstreamLaneID: upstream.Lane.ID,
|
||||
TaskID: upstream.TaskID,
|
||||
UpstreamCommit: strings.TrimSpace(upstream.Lane.HeadCommit),
|
||||
MergeCommit: strings.TrimSpace(mergeCommit),
|
||||
Status: status,
|
||||
ErrorMessage: strings.TrimSpace(errorMessage),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) abortMerge(ctx context.Context, worktree string) error {
|
||||
_, err := lanegit.Run(ctx, s.runner, worktree, nil, "merge", "--abort")
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) headCommit(ctx context.Context, worktree string) (string, error) {
|
||||
out, err := lanegit.Run(ctx, s.runner, worktree, nil, "rev-parse", "HEAD")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(out), nil
|
||||
}
|
||||
|
||||
func (s *Service) isClean(ctx context.Context, worktree string) (bool, error) {
|
||||
out, err := lanegit.Run(ctx, s.runner, worktree, nil, "status", "--porcelain")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return strings.TrimSpace(out) == "", nil
|
||||
}
|
||||
|
||||
func (s *Service) isAncestor(ctx context.Context, worktree, commit string) (bool, error) {
|
||||
head, err := s.headCommit(ctx, worktree)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
_, err = s.runner.Run(ctx, worktree, nil, "git", "merge-base", "--is-ancestor", commit, head)
|
||||
if err == nil {
|
||||
return true, nil
|
||||
}
|
||||
if lanegit.IsExitCode(err, 1) {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("git merge-base --is-ancestor %s %s: %w", commit, head, err)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package lanes
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"inbox/internal/base/slug"
|
||||
"inbox/internal/base/timeutil"
|
||||
"inbox/internal/domain/lane"
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
CreateLane(ctx context.Context, value lane.Record) (lane.Record, error)
|
||||
GetLane(ctx context.Context, laneID string) (lane.Record, error)
|
||||
ListLanesByTopic(ctx context.Context, topicID string) ([]lane.Record, error)
|
||||
ListLanesByWorkspace(ctx context.Context, workspaceID string) ([]lane.Record, error)
|
||||
UpdateLane(ctx context.Context, value lane.Record) (lane.Record, error)
|
||||
}
|
||||
|
||||
type RuntimeManager interface {
|
||||
EnsureLane(ctx context.Context, laneID string) (lane.Record, error)
|
||||
StopLane(ctx context.Context, laneID string) (lane.Record, error)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repo Repository
|
||||
runtime RuntimeManager
|
||||
clock timeutil.Clock
|
||||
}
|
||||
|
||||
type Patch struct {
|
||||
Name *string
|
||||
Status *lane.Status
|
||||
ResultSummaryMarkdown *string
|
||||
ErrorMessage *string
|
||||
StartedAt *string
|
||||
CompletedAt *string
|
||||
}
|
||||
|
||||
func NewService(repo Repository, runtime RuntimeManager, clock timeutil.Clock) *Service {
|
||||
if clock == nil {
|
||||
clock = timeutil.SystemClock{}
|
||||
}
|
||||
return &Service{repo: repo, runtime: runtime, clock: clock}
|
||||
}
|
||||
|
||||
func (s *Service) Create(ctx context.Context, value lane.Record) (lane.Record, error) {
|
||||
if value.Slug == "" {
|
||||
value.Slug = slug.Normalize(value.Name)
|
||||
}
|
||||
if value.Status == "" {
|
||||
value.Status = lane.StatusDraft
|
||||
}
|
||||
return s.repo.CreateLane(ctx, value)
|
||||
}
|
||||
|
||||
func (s *Service) Get(ctx context.Context, laneID string) (lane.Record, error) {
|
||||
return s.repo.GetLane(ctx, laneID)
|
||||
}
|
||||
|
||||
func (s *Service) ListByTopic(ctx context.Context, topicID string) ([]lane.Record, error) {
|
||||
return s.repo.ListLanesByTopic(ctx, topicID)
|
||||
}
|
||||
|
||||
func (s *Service) ListByWorkspace(ctx context.Context, workspaceID string) ([]lane.Record, error) {
|
||||
return s.repo.ListLanesByWorkspace(ctx, workspaceID)
|
||||
}
|
||||
|
||||
func (s *Service) Patch(ctx context.Context, laneID string, patch Patch) (lane.Record, error) {
|
||||
current, err := s.repo.GetLane(ctx, laneID)
|
||||
if err != nil {
|
||||
return lane.Record{}, err
|
||||
}
|
||||
if patch.Name != nil {
|
||||
current.Name = *patch.Name
|
||||
current.Slug = slug.Normalize(current.Name)
|
||||
}
|
||||
if patch.Status != nil {
|
||||
current.Status = *patch.Status
|
||||
if current.Status == lane.StatusRunning && current.StartedAt == "" {
|
||||
current.StartedAt = timeutil.FormatRFC3339(s.clock.Now())
|
||||
}
|
||||
if (current.Status == lane.StatusSucceeded || current.Status == lane.StatusFailed || current.Status == lane.StatusCancelled) && current.CompletedAt == "" {
|
||||
current.CompletedAt = timeutil.FormatRFC3339(s.clock.Now())
|
||||
}
|
||||
}
|
||||
if patch.ResultSummaryMarkdown != nil {
|
||||
current.ResultSummaryMarkdown = *patch.ResultSummaryMarkdown
|
||||
}
|
||||
if patch.ErrorMessage != nil {
|
||||
current.ErrorMessage = *patch.ErrorMessage
|
||||
}
|
||||
if patch.StartedAt != nil {
|
||||
current.StartedAt = *patch.StartedAt
|
||||
}
|
||||
if patch.CompletedAt != nil {
|
||||
current.CompletedAt = *patch.CompletedAt
|
||||
}
|
||||
return s.repo.UpdateLane(ctx, current)
|
||||
}
|
||||
|
||||
func (s *Service) Start(ctx context.Context, laneID string) (lane.Record, error) {
|
||||
if s.runtime == nil {
|
||||
return lane.Record{}, nil
|
||||
}
|
||||
return s.runtime.EnsureLane(ctx, laneID)
|
||||
}
|
||||
|
||||
func (s *Service) Stop(ctx context.Context, laneID string) (lane.Record, error) {
|
||||
if s.runtime == nil {
|
||||
return lane.Record{}, nil
|
||||
}
|
||||
return s.runtime.StopLane(ctx, laneID)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package lanesnapshot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"inbox/internal/app/lanegit"
|
||||
"inbox/internal/base/timeutil"
|
||||
"inbox/internal/domain/lane"
|
||||
"inbox/internal/domain/task"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
runner lanegit.Runner
|
||||
clock timeutil.Clock
|
||||
}
|
||||
|
||||
func NewService(runner lanegit.Runner, clock timeutil.Clock) *Service {
|
||||
if runner == nil {
|
||||
runner = lanegit.ExecRunner{}
|
||||
}
|
||||
if clock == nil {
|
||||
clock = timeutil.SystemClock{}
|
||||
}
|
||||
return &Service{runner: runner, clock: clock}
|
||||
}
|
||||
|
||||
func (s *Service) Capture(ctx context.Context, item lane.Record, taskRecord task.Record) (string, error) {
|
||||
worktree := strings.TrimSpace(item.WorktreePath)
|
||||
if worktree == "" {
|
||||
return "", fmt.Errorf("lane %s has empty worktree path", item.ID)
|
||||
}
|
||||
|
||||
headBefore, err := s.headCommit(ctx, worktree)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
changed, err := s.hasChanges(ctx, worktree)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !changed {
|
||||
return headBefore, nil
|
||||
}
|
||||
if _, err := lanegit.Run(ctx, s.runner, worktree, nil, "add", "-A"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
staged, err := s.hasStagedChanges(ctx, worktree)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !staged {
|
||||
return headBefore, nil
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("lane(%s): complete task %s - %s", strings.TrimSpace(item.Slug), taskRecord.ID, strings.TrimSpace(taskRecord.Title))
|
||||
env := map[string]string{
|
||||
"GIT_AUTHOR_NAME": "Inbox",
|
||||
"GIT_AUTHOR_EMAIL": "inbox@local",
|
||||
"GIT_COMMITTER_NAME": "Inbox",
|
||||
"GIT_COMMITTER_EMAIL": "inbox@local",
|
||||
"GIT_AUTHOR_DATE": timeutil.FormatRFC3339(s.clock.Now()),
|
||||
"GIT_COMMITTER_DATE": timeutil.FormatRFC3339(s.clock.Now()),
|
||||
}
|
||||
if _, err := lanegit.Run(ctx, s.runner, worktree, env, "commit", "-m", msg); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return s.headCommit(ctx, worktree)
|
||||
}
|
||||
|
||||
func (s *Service) headCommit(ctx context.Context, worktree string) (string, error) {
|
||||
out, err := lanegit.Run(ctx, s.runner, worktree, nil, "rev-parse", "HEAD")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(out), nil
|
||||
}
|
||||
|
||||
func (s *Service) hasChanges(ctx context.Context, worktree string) (bool, error) {
|
||||
out, err := lanegit.Run(ctx, s.runner, worktree, nil, "status", "--porcelain")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return strings.TrimSpace(out) != "", nil
|
||||
}
|
||||
|
||||
func (s *Service) hasStagedChanges(ctx context.Context, worktree string) (bool, error) {
|
||||
_, err := s.runner.Run(ctx, worktree, nil, "git", "diff", "--cached", "--quiet", "--exit-code")
|
||||
if err == nil {
|
||||
return false, nil
|
||||
}
|
||||
if lanegit.IsExitCode(err, 1) {
|
||||
return true, nil
|
||||
}
|
||||
return false, fmt.Errorf("git diff --cached --quiet --exit-code: %w", err)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,997 @@
|
||||
package leaderloop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"inbox/internal/app/runtimecodex"
|
||||
"inbox/internal/app/runtimeconfig"
|
||||
"inbox/internal/app/workspaceruntime"
|
||||
"inbox/internal/base/timeutil"
|
||||
"inbox/internal/domain/lane"
|
||||
"inbox/internal/domain/message"
|
||||
"inbox/internal/domain/role"
|
||||
"inbox/internal/domain/task"
|
||||
"inbox/internal/domain/topic"
|
||||
"inbox/internal/domain/workflow"
|
||||
"inbox/internal/domain/workspace"
|
||||
sqlitestore "inbox/internal/store/sqlite"
|
||||
)
|
||||
|
||||
func TestProcessOnceConsumesLeaderMessageAndCreatesLaneTasks(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
clock := timeutil.FixedClock{Time: time.Date(2026, 3, 17, 4, 0, 0, 0, time.UTC)}
|
||||
store, err := sqlitestore.OpenInMemory(clock)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenInMemory() error = %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
ws, topicRecord := seedLeaderLoopWorkspace(t, ctx, store, clock)
|
||||
if _, err := store.CreateMessage(ctx, message.Record{
|
||||
WorkspaceID: ws.ID,
|
||||
TopicID: topicRecord.ID,
|
||||
FromRoleName: "user",
|
||||
ToExpr: "leader",
|
||||
Type: message.TypeChat,
|
||||
Stage: "plan",
|
||||
BodyMarkdown: "Build a todo app.",
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateMessage() error = %v", err)
|
||||
}
|
||||
|
||||
fakeRuntime := &fakeWorkspaceRuntime{workspace: ws}
|
||||
runner := &fakeRunner{result: RunResult{
|
||||
Status: workflow.RunStatusSucceeded,
|
||||
ResultJSON: `{
|
||||
"plan_summary_markdown":"Leader created the first execution lane.",
|
||||
"plan_mode":"initial",
|
||||
"execution_mode":"plan_only",
|
||||
"leader_reply":{"markdown":"I split the work into a UI graph and will wait for confirmation.","type":"chat"},
|
||||
"tasks":[
|
||||
{"key":"design","title":"Design the UI","body_markdown":"Create the React UI.","acceptance_markdown":"UI is polished.","kind":"execution","deliverables":["apps/web/src"],"priority":10,"task_order":1,"depends_on":[]},
|
||||
{"key":"verify","title":"Verify the UI","body_markdown":"Verify the UI flow.","acceptance_markdown":"Verification complete.","kind":"verification","deliverables":["reports/ui-check.md"],"priority":5,"task_order":2,"depends_on":["design"]}
|
||||
]
|
||||
}`,
|
||||
}}
|
||||
service := NewService(
|
||||
store,
|
||||
runtimeconfig.NewService(store, store, clock),
|
||||
fakeRuntime,
|
||||
runner,
|
||||
clock,
|
||||
"",
|
||||
)
|
||||
|
||||
result, err := service.ProcessOnce(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessOnce() error = %v", err)
|
||||
}
|
||||
if result == nil {
|
||||
t.Fatal("expected leader result")
|
||||
}
|
||||
if len(result.Lanes) != 1 || result.Lanes[0].Name != "Design the UI" {
|
||||
t.Fatalf("unexpected lanes: %#v", result.Lanes)
|
||||
}
|
||||
if len(result.Tasks) != 2 {
|
||||
t.Fatalf("unexpected tasks: %#v", result.Tasks)
|
||||
}
|
||||
if fakeRuntime.startedLaneID != "" {
|
||||
t.Fatalf("expected initial graph to wait for confirmation, got started lane %q", fakeRuntime.startedLaneID)
|
||||
}
|
||||
if result.Reply == nil || result.Reply.ToExpr != "user" {
|
||||
t.Fatalf("expected reply to user, got %#v", result.Reply)
|
||||
}
|
||||
if !strings.Contains(runner.prompt, "## Skills") || !strings.Contains(runner.prompt, "Use Inbox V2") {
|
||||
t.Fatalf("expected leader prompt to include bound skills, got %q", runner.prompt)
|
||||
}
|
||||
if !strings.Contains(result.Run.CommandJSON, `"planning"`) || !strings.Contains(result.Run.CommandJSON, `"execution_mode":"plan_only"`) {
|
||||
t.Fatalf("expected planning payload in command_json, got %s", result.Run.CommandJSON)
|
||||
}
|
||||
|
||||
tasks, err := store.ListTasksByTopic(ctx, topicRecord.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListTasksByTopic() error = %v", err)
|
||||
}
|
||||
if len(tasks) != 2 {
|
||||
t.Fatalf("expected 2 tasks in store, got %d", len(tasks))
|
||||
}
|
||||
if tasks[0].Status != task.StatusReady || tasks[1].Status != task.StatusDraft {
|
||||
t.Fatalf("unexpected task statuses: %#v", tasks)
|
||||
}
|
||||
updatedTopic, err := store.GetTopic(ctx, topicRecord.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetTopic() error = %v", err)
|
||||
}
|
||||
if updatedTopic.Status != "awaiting_confirmation" {
|
||||
t.Fatalf("expected awaiting_confirmation topic, got %#v", updatedTopic)
|
||||
}
|
||||
graphVersion, err := store.GetLatestTaskGraphVersionByTopic(ctx, topicRecord.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLatestTaskGraphVersionByTopic() error = %v", err)
|
||||
}
|
||||
if graphVersion.Status != "draft" {
|
||||
t.Fatalf("expected draft graph version, got %#v", graphVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLeaderOutputRejectsInitialFreezeStartNodes(t *testing.T) {
|
||||
_, err := parseLeaderOutput(`{
|
||||
"plan_summary_markdown":"Draft the initial graph.",
|
||||
"plan_mode":"initial",
|
||||
"execution_mode":"plan_only",
|
||||
"leader_reply":{"markdown":"Please confirm the graph first.","type":"summary"},
|
||||
"tasks":[
|
||||
{"key":"build","title":"Build app","body_markdown":"Implement app.","acceptance_markdown":"App works.","kind":"execution","deliverables":["apps/web"],"priority":1,"task_order":1,"depends_on":[]}
|
||||
],
|
||||
"start_nodes":["app"]
|
||||
}`, true)
|
||||
if err == nil || !strings.Contains(err.Error(), `unknown field "start_nodes"`) {
|
||||
t.Fatalf("expected initial freeze start_nodes error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeaderOutputSchemaRequiresEveryDeclaredProperty(t *testing.T) {
|
||||
for _, initialFreeze := range []bool{true, false} {
|
||||
schemaJSON := leaderOutputSchema(initialFreeze)
|
||||
var schema map[string]any
|
||||
if err := json.Unmarshal([]byte(schemaJSON), &schema); err != nil {
|
||||
t.Fatalf("json.Unmarshal(schema) error = %v", err)
|
||||
}
|
||||
|
||||
assertSchemaRequiredMatchesProperties(t, schema)
|
||||
|
||||
properties := schema["properties"].(map[string]any)
|
||||
tasks := properties["tasks"].(map[string]any)
|
||||
items := tasks["items"].(map[string]any)
|
||||
assertSchemaRequiredMatchesProperties(t, items)
|
||||
|
||||
leaderReply := properties["leader_reply"].(map[string]any)
|
||||
assertSchemaRequiredMatchesProperties(t, leaderReply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessOnceReusesExistingLaneOnWorkerFollowUp(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
clock := timeutil.FixedClock{Time: time.Date(2026, 3, 17, 4, 30, 0, 0, time.UTC)}
|
||||
store, err := sqlitestore.OpenInMemory(clock)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenInMemory() error = %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
ws, topicRecord := seedLeaderLoopWorkspace(t, ctx, store, clock)
|
||||
_, err = store.CreateLane(ctx, lane.Record{
|
||||
WorkspaceID: ws.ID,
|
||||
TopicID: topicRecord.ID,
|
||||
Name: "UI Chain",
|
||||
Slug: "ui-chain",
|
||||
Status: lane.StatusBlocked,
|
||||
CreatedByRoleName: "leader",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateLane() error = %v", err)
|
||||
}
|
||||
if _, err := store.CreateMessage(ctx, message.Record{
|
||||
WorkspaceID: ws.ID,
|
||||
TopicID: topicRecord.ID,
|
||||
FromRoleName: "worker",
|
||||
ToExpr: "leader",
|
||||
Type: message.TypeSummary,
|
||||
Stage: "execution",
|
||||
BodyMarkdown: "UI chain failed the first task.",
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateMessage() error = %v", err)
|
||||
}
|
||||
|
||||
fakeRuntime := &fakeWorkspaceRuntime{workspace: ws}
|
||||
service := NewService(
|
||||
store,
|
||||
runtimeconfig.NewService(store, store, clock),
|
||||
fakeRuntime,
|
||||
&fakeRunner{result: RunResult{
|
||||
Status: workflow.RunStatusSucceeded,
|
||||
ResultJSON: `{
|
||||
"plan_summary_markdown":"Reused the UI chain and queued a retry task.",
|
||||
"plan_mode":"patch",
|
||||
"replan_reason":"UI chain failed and needs a focused retry.",
|
||||
"execution_mode":"plan_and_start",
|
||||
"leader_reply":{"markdown":"继续在现有图上修复。","type":"decision"},
|
||||
"tasks":[
|
||||
{"key":"retry-ui","title":"修复 UI 初始化失败","body_markdown":"在现有 UI 图上修复失败原因。","acceptance_markdown":"UI graph 恢复可执行。","kind":"execution","deliverables":["apps/web/src"],"priority":10,"task_order":3,"depends_on":[]}
|
||||
]
|
||||
}`,
|
||||
}},
|
||||
clock,
|
||||
"",
|
||||
)
|
||||
|
||||
result, err := service.ProcessOnce(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessOnce() error = %v", err)
|
||||
}
|
||||
if result == nil {
|
||||
t.Fatal("expected leader result")
|
||||
}
|
||||
if fakeRuntime.startedLaneID == "" {
|
||||
t.Fatalf("expected a derived lane to be started, got none")
|
||||
}
|
||||
lanes, err := store.ListLanesByTopic(ctx, topicRecord.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListLanesByTopic() error = %v", err)
|
||||
}
|
||||
if len(lanes) != 2 {
|
||||
t.Fatalf("expected existing blocked lane plus one derived execution lane, got %#v", lanes)
|
||||
}
|
||||
tasks, err := store.ListTasksByTopic(ctx, topicRecord.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListTasksByTopic() error = %v", err)
|
||||
}
|
||||
if len(tasks) != 1 {
|
||||
t.Fatalf("expected retry task on existing chain, got %#v", tasks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLeaderOutputRejectsInvalidPlanOnlyStartNodes(t *testing.T) {
|
||||
_, err := parseLeaderOutput(`{
|
||||
"plan_summary_markdown":"Planned only.",
|
||||
"plan_mode":"initial",
|
||||
"execution_mode":"plan_only",
|
||||
"leader_reply":{"markdown":"先只输出计划。","type":"summary"},
|
||||
"tasks":[
|
||||
{"key":"design","title":"Design the UI","body_markdown":"Create the React UI.","acceptance_markdown":"UI is polished.","kind":"execution","deliverables":["apps/web/src"],"priority":10,"task_order":1,"depends_on":[]}
|
||||
],
|
||||
"start_nodes":["ui"]
|
||||
}`, false)
|
||||
if err == nil || !strings.Contains(err.Error(), `unknown field "start_nodes"`) {
|
||||
t.Fatalf("expected invalid plan_only start_nodes error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLeaderOutputRejectsMissingDeliverables(t *testing.T) {
|
||||
_, err := parseLeaderOutput(`{
|
||||
"plan_summary_markdown":"Plan.",
|
||||
"plan_mode":"initial",
|
||||
"execution_mode":"plan_and_start",
|
||||
"leader_reply":{"markdown":"开始执行。","type":"decision"},
|
||||
"tasks":[
|
||||
{"key":"design","title":"Design the UI","body_markdown":"Create the React UI.","acceptance_markdown":"UI is polished.","kind":"execution","deliverables":[],"priority":10,"task_order":1,"depends_on":[]}
|
||||
]
|
||||
}`, false)
|
||||
if err == nil || !strings.Contains(err.Error(), "must declare deliverables") {
|
||||
t.Fatalf("expected missing deliverables error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLeaderOutputRejectsDuplicateTaskKeys(t *testing.T) {
|
||||
_, err := parseLeaderOutput(`{
|
||||
"plan_summary_markdown":"Plan.",
|
||||
"plan_mode":"initial",
|
||||
"execution_mode":"plan_only",
|
||||
"leader_reply":{"markdown":"先看计划。","type":"summary"},
|
||||
"tasks":[
|
||||
{"key":"ui","title":"Build UI","body_markdown":"Create the React UI.","acceptance_markdown":"UI is polished.","kind":"execution","deliverables":["apps/web/src"],"depends_on":[]},
|
||||
{"key":"ui","title":"Verify UI","body_markdown":"Verify the React UI.","acceptance_markdown":"Verification complete.","kind":"verification","deliverables":["reports/ui-check.md"],"depends_on":["ui"]}
|
||||
]
|
||||
}`, true)
|
||||
if err == nil || !strings.Contains(err.Error(), `must be unique`) {
|
||||
t.Fatalf("expected duplicate task key error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLeaderOutputAllowsMilestoneWithoutDeliverables(t *testing.T) {
|
||||
output, err := parseLeaderOutput(`{
|
||||
"plan_summary_markdown":"Plan with milestone.",
|
||||
"plan_mode":"initial",
|
||||
"execution_mode":"plan_only",
|
||||
"leader_reply":{"markdown":"先看计划。","type":"summary"},
|
||||
"tasks":[
|
||||
{"key":"ui","title":"Build UI","body_markdown":"Build the UI.","acceptance_markdown":"UI works.","kind":"execution","deliverables":["apps/web/src"],"priority":10,"task_order":1,"depends_on":[]},
|
||||
{"key":"ready-for-demo","title":"Ready for Demo","body_markdown":"Aggregate completion.","acceptance_markdown":"Milestone reached.","kind":"milestone","priority":5,"task_order":2,"depends_on":["ui"]}
|
||||
]
|
||||
}`, true)
|
||||
if err != nil {
|
||||
t.Fatalf("parseLeaderOutput() error = %v", err)
|
||||
}
|
||||
if len(output.Tasks) != 2 || output.Tasks[1].Kind != "milestone" {
|
||||
t.Fatalf("expected milestone task, got %#v", output.Tasks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessOnceKeepsMilestoneOnExecutionLane(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
clock := timeutil.FixedClock{Time: time.Date(2026, 3, 17, 4, 45, 0, 0, time.UTC)}
|
||||
store, err := sqlitestore.OpenInMemory(clock)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenInMemory() error = %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
ws, topicRecord := seedLeaderLoopWorkspace(t, ctx, store, clock)
|
||||
if _, err := store.CreateMessage(ctx, message.Record{
|
||||
WorkspaceID: ws.ID,
|
||||
TopicID: topicRecord.ID,
|
||||
FromRoleName: "user",
|
||||
ToExpr: "leader",
|
||||
Type: message.TypeChat,
|
||||
Stage: "plan",
|
||||
BodyMarkdown: "Build UI and mark the review milestone.",
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateMessage() error = %v", err)
|
||||
}
|
||||
|
||||
service := NewService(
|
||||
store,
|
||||
runtimeconfig.NewService(store, store, clock),
|
||||
&fakeWorkspaceRuntime{workspace: ws},
|
||||
&fakeRunner{result: RunResult{
|
||||
Status: workflow.RunStatusSucceeded,
|
||||
ResultJSON: `{
|
||||
"plan_summary_markdown":"One execution lane plus milestone.",
|
||||
"plan_mode":"initial",
|
||||
"execution_mode":"plan_only",
|
||||
"leader_reply":{"markdown":"先确认这个图。","type":"summary"},
|
||||
"tasks":[
|
||||
{"key":"ui","title":"Build UI","body_markdown":"Build the UI.","acceptance_markdown":"UI works.","kind":"execution","deliverables":["apps/web/src"],"priority":10,"task_order":1,"depends_on":[]},
|
||||
{"key":"ready-for-demo","title":"Ready for Demo","body_markdown":"Aggregate completion.","acceptance_markdown":"Milestone reached.","kind":"milestone","priority":5,"task_order":2,"depends_on":["ui"]}
|
||||
]
|
||||
}`,
|
||||
}},
|
||||
clock,
|
||||
"",
|
||||
)
|
||||
|
||||
result, err := service.ProcessOnce(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessOnce() error = %v", err)
|
||||
}
|
||||
if result == nil {
|
||||
t.Fatal("expected leader result")
|
||||
}
|
||||
lanes, err := store.ListLanesByTopic(ctx, topicRecord.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListLanesByTopic() error = %v", err)
|
||||
}
|
||||
if len(lanes) != 1 {
|
||||
t.Fatalf("expected milestone to reuse the execution lane, got %#v", lanes)
|
||||
}
|
||||
tasks, err := store.ListTasksByTopic(ctx, topicRecord.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListTasksByTopic() error = %v", err)
|
||||
}
|
||||
if len(tasks) != 2 || tasks[0].LaneID != tasks[1].LaneID {
|
||||
t.Fatalf("expected milestone and execution task to share a lane, got %#v", tasks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessOnceStartsOnlyGateLanesInitially(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
clock := timeutil.FixedClock{Time: time.Date(2026, 3, 17, 5, 0, 0, 0, time.UTC)}
|
||||
store, err := sqlitestore.OpenInMemory(clock)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenInMemory() error = %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
ws, topicRecord := seedLeaderLoopWorkspace(t, ctx, store, clock)
|
||||
if _, err := store.CreateMessage(ctx, message.Record{
|
||||
WorkspaceID: ws.ID,
|
||||
TopicID: topicRecord.ID,
|
||||
FromRoleName: "user",
|
||||
ToExpr: "leader",
|
||||
Type: message.TypeChat,
|
||||
Stage: "plan",
|
||||
BodyMarkdown: "Build a gated todo app.",
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateMessage() error = %v", err)
|
||||
}
|
||||
|
||||
fakeRuntime := &fakeWorkspaceRuntime{workspace: ws}
|
||||
service := NewService(
|
||||
store,
|
||||
runtimeconfig.NewService(store, store, clock),
|
||||
fakeRuntime,
|
||||
&fakeRunner{result: RunResult{
|
||||
Status: workflow.RunStatusSucceeded,
|
||||
ResultJSON: `{
|
||||
"plan_summary_markdown":"Start with foundation gating.",
|
||||
"plan_mode":"initial",
|
||||
"execution_mode":"plan_only",
|
||||
"leader_reply":{"markdown":"先跑基础检查。","type":"decision"},
|
||||
"tasks":[
|
||||
{"key":"gate","title":"Check workspace baseline","body_markdown":"Validate setup.","acceptance_markdown":"Setup is valid.","kind":"gate","deliverables":["reports/gate.md"],"priority":10,"task_order":1,"depends_on":[]},
|
||||
{"key":"ui","title":"Build UI","body_markdown":"Implement the UI.","acceptance_markdown":"UI works.","kind":"execution","deliverables":["apps/web/src"],"priority":10,"task_order":1,"depends_on":[]}
|
||||
]
|
||||
}`,
|
||||
}},
|
||||
clock,
|
||||
"",
|
||||
)
|
||||
|
||||
result, err := service.ProcessOnce(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessOnce() error = %v", err)
|
||||
}
|
||||
if result == nil {
|
||||
t.Fatal("expected result")
|
||||
}
|
||||
lanes, err := store.ListLanesByTopic(ctx, topicRecord.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListLanesByTopic() error = %v", err)
|
||||
}
|
||||
tasks, err := store.ListTasksByTopic(ctx, topicRecord.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListTasksByTopic() error = %v", err)
|
||||
}
|
||||
var gateTask, uiTask task.Record
|
||||
for _, item := range tasks {
|
||||
if item.Kind == task.KindGate {
|
||||
gateTask = item
|
||||
}
|
||||
if item.Title == "Build UI" {
|
||||
uiTask = item
|
||||
}
|
||||
}
|
||||
if len(lanes) != 1 {
|
||||
t.Fatalf("expected gate task to reuse the execution lane, got %#v", lanes)
|
||||
}
|
||||
if gateTask.LaneID == "" || gateTask.LaneID != uiTask.LaneID {
|
||||
t.Fatalf("expected gate and execution task to share one lane, got gate=%#v ui=%#v", gateTask, uiTask)
|
||||
}
|
||||
if gateTask.Status != task.StatusReady {
|
||||
t.Fatalf("expected gate task ready, got %#v", gateTask)
|
||||
}
|
||||
if uiTask.Status != task.StatusDraft {
|
||||
t.Fatalf("expected non-gate task blocked behind gate, got %#v", uiTask)
|
||||
}
|
||||
if len(fakeRuntime.startedLaneIDs) != 0 {
|
||||
t.Fatalf("expected initial gated plan to wait for confirmation, got %#v", fakeRuntime.startedLaneIDs)
|
||||
}
|
||||
updatedTopic, err := store.GetTopic(ctx, topicRecord.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetTopic() error = %v", err)
|
||||
}
|
||||
if updatedTopic.Status != "awaiting_confirmation" {
|
||||
t.Fatalf("expected awaiting_confirmation topic, got %#v lanes=%#v", updatedTopic, lanes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessOncePlanOnlyDoesNotAutoStartReadyLanesAfterGateSuccess(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
clock := timeutil.FixedClock{Time: time.Date(2026, 3, 17, 5, 30, 0, 0, time.UTC)}
|
||||
store, err := sqlitestore.OpenInMemory(clock)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenInMemory() error = %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
ws, topicRecord := seedLeaderLoopWorkspace(t, ctx, store, clock)
|
||||
topicRecord.Status = "execution"
|
||||
if _, err := store.UpdateTopic(ctx, topicRecord); err != nil {
|
||||
t.Fatalf("UpdateTopic() error = %v", err)
|
||||
}
|
||||
gateChain, err := store.CreateLane(ctx, lane.Record{
|
||||
WorkspaceID: ws.ID,
|
||||
TopicID: topicRecord.ID,
|
||||
Name: "Foundation",
|
||||
Slug: "foundation",
|
||||
Status: lane.StatusSucceeded,
|
||||
BranchName: "lane/todo/foundation",
|
||||
WorktreePath: filepath.Join(t.TempDir(), "foundation"),
|
||||
ContainerName: "lane-foundation-test",
|
||||
CreatedByRoleName: "leader",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateLane(gate) error = %v", err)
|
||||
}
|
||||
frontendChain, err := store.CreateLane(ctx, lane.Record{
|
||||
WorkspaceID: ws.ID,
|
||||
TopicID: topicRecord.ID,
|
||||
Name: "Frontend",
|
||||
Slug: "frontend",
|
||||
Status: lane.StatusReady,
|
||||
BranchName: "lane/todo/frontend",
|
||||
WorktreePath: filepath.Join(t.TempDir(), "frontend"),
|
||||
ContainerName: "lane-frontend-test",
|
||||
CreatedByRoleName: "leader",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateLane(frontend) error = %v", err)
|
||||
}
|
||||
if _, err := store.CreateTask(ctx, task.Record{
|
||||
WorkspaceID: ws.ID,
|
||||
TopicID: topicRecord.ID,
|
||||
LaneID: gateChain.ID,
|
||||
Title: "Check workspace baseline",
|
||||
BodyMarkdown: "Validate setup.",
|
||||
Kind: task.KindGate,
|
||||
Status: task.StatusSucceeded,
|
||||
Priority: 10,
|
||||
TaskOrder: 1,
|
||||
CreatedByRoleName: "leader",
|
||||
}, nil); err != nil {
|
||||
t.Fatalf("CreateTask(gate) error = %v", err)
|
||||
}
|
||||
if _, err := store.CreateTask(ctx, task.Record{
|
||||
WorkspaceID: ws.ID,
|
||||
TopicID: topicRecord.ID,
|
||||
LaneID: frontendChain.ID,
|
||||
Title: "Build UI",
|
||||
BodyMarkdown: "Implement UI.",
|
||||
Kind: task.KindExecution,
|
||||
Status: task.StatusReady,
|
||||
Priority: 10,
|
||||
TaskOrder: 1,
|
||||
CreatedByRoleName: "leader",
|
||||
}, nil); err != nil {
|
||||
t.Fatalf("CreateTask(frontend) error = %v", err)
|
||||
}
|
||||
if _, err := store.CreateMessage(ctx, message.Record{
|
||||
WorkspaceID: ws.ID,
|
||||
TopicID: topicRecord.ID,
|
||||
FromRoleName: "worker",
|
||||
ToExpr: "leader",
|
||||
Type: message.TypeSummary,
|
||||
Stage: "execution",
|
||||
BodyMarkdown: "Foundation gate completed successfully. Keep the next lane stopped for now.",
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateMessage() error = %v", err)
|
||||
}
|
||||
|
||||
fakeRuntime := &fakeWorkspaceRuntime{workspace: ws}
|
||||
service := NewService(
|
||||
store,
|
||||
runtimeconfig.NewService(store, store, clock),
|
||||
fakeRuntime,
|
||||
&fakeRunner{result: RunResult{
|
||||
Status: workflow.RunStatusSucceeded,
|
||||
ResultJSON: `{
|
||||
"plan_summary_markdown":"Gate passed; continue existing plan.",
|
||||
"plan_mode":"patch",
|
||||
"replan_reason":"Gate completed successfully; continue existing graph.",
|
||||
"execution_mode":"plan_only",
|
||||
"leader_reply":{"markdown":"基础检查通过,继续后续链路。","type":"decision"},
|
||||
"tasks":[]
|
||||
}`,
|
||||
}},
|
||||
clock,
|
||||
"",
|
||||
)
|
||||
|
||||
result, err := service.ProcessOnce(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessOnce() error = %v", err)
|
||||
}
|
||||
if result == nil {
|
||||
t.Fatal("expected result")
|
||||
}
|
||||
if len(fakeRuntime.startedLaneIDs) != 0 {
|
||||
t.Fatalf("expected plan_only to keep lanes stopped, got %#v", fakeRuntime.startedLaneIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessOncePlanAndStartAutoStartsReadyLanesAfterGateSuccess(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
clock := timeutil.FixedClock{Time: time.Date(2026, 3, 17, 5, 35, 0, 0, time.UTC)}
|
||||
store, err := sqlitestore.OpenInMemory(clock)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenInMemory() error = %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
ws, topicRecord := seedLeaderLoopWorkspace(t, ctx, store, clock)
|
||||
topicRecord.Status = "execution"
|
||||
if _, err := store.UpdateTopic(ctx, topicRecord); err != nil {
|
||||
t.Fatalf("UpdateTopic() error = %v", err)
|
||||
}
|
||||
gateChain, err := store.CreateLane(ctx, lane.Record{
|
||||
WorkspaceID: ws.ID,
|
||||
TopicID: topicRecord.ID,
|
||||
Name: "Foundation",
|
||||
Slug: "foundation",
|
||||
Status: lane.StatusSucceeded,
|
||||
BranchName: "lane/todo/foundation",
|
||||
WorktreePath: filepath.Join(t.TempDir(), "foundation"),
|
||||
ContainerName: "lane-foundation-test",
|
||||
CreatedByRoleName: "leader",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateLane(gate) error = %v", err)
|
||||
}
|
||||
frontendChain, err := store.CreateLane(ctx, lane.Record{
|
||||
WorkspaceID: ws.ID,
|
||||
TopicID: topicRecord.ID,
|
||||
Name: "Frontend",
|
||||
Slug: "frontend",
|
||||
Status: lane.StatusReady,
|
||||
BranchName: "lane/todo/frontend",
|
||||
WorktreePath: filepath.Join(t.TempDir(), "frontend"),
|
||||
ContainerName: "lane-frontend-test",
|
||||
CreatedByRoleName: "leader",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateLane(frontend) error = %v", err)
|
||||
}
|
||||
if _, err := store.CreateTask(ctx, task.Record{
|
||||
WorkspaceID: ws.ID,
|
||||
TopicID: topicRecord.ID,
|
||||
LaneID: gateChain.ID,
|
||||
Title: "Check workspace baseline",
|
||||
BodyMarkdown: "Validate setup.",
|
||||
Kind: task.KindGate,
|
||||
Status: task.StatusSucceeded,
|
||||
Priority: 10,
|
||||
TaskOrder: 1,
|
||||
CreatedByRoleName: "leader",
|
||||
}, nil); err != nil {
|
||||
t.Fatalf("CreateTask(gate) error = %v", err)
|
||||
}
|
||||
if _, err := store.CreateTask(ctx, task.Record{
|
||||
WorkspaceID: ws.ID,
|
||||
TopicID: topicRecord.ID,
|
||||
LaneID: frontendChain.ID,
|
||||
Title: "Build UI",
|
||||
BodyMarkdown: "Implement UI.",
|
||||
Kind: task.KindExecution,
|
||||
Status: task.StatusReady,
|
||||
Priority: 10,
|
||||
TaskOrder: 1,
|
||||
CreatedByRoleName: "leader",
|
||||
}, nil); err != nil {
|
||||
t.Fatalf("CreateTask(frontend) error = %v", err)
|
||||
}
|
||||
if _, err := store.CreateMessage(ctx, message.Record{
|
||||
WorkspaceID: ws.ID,
|
||||
TopicID: topicRecord.ID,
|
||||
FromRoleName: "worker",
|
||||
ToExpr: "leader",
|
||||
Type: message.TypeSummary,
|
||||
Stage: "execution",
|
||||
BodyMarkdown: "Foundation gate completed successfully. Start the next ready lane.",
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateMessage() error = %v", err)
|
||||
}
|
||||
|
||||
fakeRuntime := &fakeWorkspaceRuntime{workspace: ws}
|
||||
service := NewService(
|
||||
store,
|
||||
runtimeconfig.NewService(store, store, clock),
|
||||
fakeRuntime,
|
||||
&fakeRunner{result: RunResult{
|
||||
Status: workflow.RunStatusSucceeded,
|
||||
ResultJSON: `{
|
||||
"plan_summary_markdown":"Gate passed; continue existing plan.",
|
||||
"plan_mode":"patch",
|
||||
"replan_reason":"Gate completed successfully; continue existing graph.",
|
||||
"execution_mode":"plan_and_start",
|
||||
"leader_reply":{"markdown":"基础检查通过,继续后续链路。","type":"decision"},
|
||||
"tasks":[]
|
||||
}`,
|
||||
}},
|
||||
clock,
|
||||
"",
|
||||
)
|
||||
|
||||
result, err := service.ProcessOnce(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessOnce() error = %v", err)
|
||||
}
|
||||
if result == nil {
|
||||
t.Fatal("expected result")
|
||||
}
|
||||
if len(fakeRuntime.startedLaneIDs) != 1 || fakeRuntime.startedLaneIDs[0] != frontendChain.ID {
|
||||
t.Fatalf("expected auto-start of frontend lane, got %#v", fakeRuntime.startedLaneIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessOnceUsesResolvedLeaderPromptOverrides(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
clock := timeutil.FixedClock{Time: time.Date(2026, 3, 17, 6, 0, 0, 0, time.UTC)}
|
||||
store, err := sqlitestore.OpenInMemory(clock)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenInMemory() error = %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
ws, topicRecord := seedLeaderLoopWorkspace(t, ctx, store, clock)
|
||||
if _, err := store.UpsertRolePrompt(ctx, role.Prompt{
|
||||
RoleName: "leader",
|
||||
WorkspaceID: ws.ID,
|
||||
PromptKind: role.PromptSystem,
|
||||
ContentMarkdown: "你是自定义 leader。\n\n优先把范围压缩到最小可执行图。",
|
||||
}, "test"); err != nil {
|
||||
t.Fatalf("UpsertRolePrompt() error = %v", err)
|
||||
}
|
||||
if _, err := store.CreateMessage(ctx, message.Record{
|
||||
WorkspaceID: ws.ID,
|
||||
TopicID: topicRecord.ID,
|
||||
FromRoleName: "user",
|
||||
ToExpr: "leader",
|
||||
Type: message.TypeChat,
|
||||
Stage: "plan",
|
||||
BodyMarkdown: "Build a todo app.",
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateMessage() error = %v", err)
|
||||
}
|
||||
|
||||
runner := &fakeRunner{result: RunResult{
|
||||
Status: workflow.RunStatusSucceeded,
|
||||
ResultJSON: `{
|
||||
"plan_summary_markdown":"Need clarification first.",
|
||||
"plan_mode":"initial",
|
||||
"execution_mode":"clarify",
|
||||
"leader_reply":{"markdown":"先缩小范围。","type":"question"},
|
||||
"tasks":[]
|
||||
}`,
|
||||
}}
|
||||
service := NewService(
|
||||
store,
|
||||
runtimeconfig.NewService(store, store, clock),
|
||||
&fakeWorkspaceRuntime{workspace: ws},
|
||||
runner,
|
||||
clock,
|
||||
"",
|
||||
)
|
||||
|
||||
if _, err := service.ProcessOnce(ctx); err != nil {
|
||||
t.Fatalf("ProcessOnce() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(runner.prompt, "你是自定义 leader。") {
|
||||
t.Fatalf("expected custom leader prompt in runtime instructions, got %q", runner.prompt)
|
||||
}
|
||||
if strings.Contains(runner.prompt, defaultLeaderSystemPrompt) {
|
||||
t.Fatalf("expected custom prompt to replace fallback system prompt, got %q", runner.prompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateLeaderOutputForTopicRejectsPatchOnEmptyGraph(t *testing.T) {
|
||||
err := validateLeaderOutputForTopic(leaderOutput{
|
||||
PlanSummaryMarkdown: "Patch on empty graph.",
|
||||
PlanMode: "patch",
|
||||
ReplanReason: "Need to update graph.",
|
||||
ExecutionMode: "plan_only",
|
||||
LeaderReply: leaderReplySpec{Markdown: "Patch.", Type: "summary"},
|
||||
}, nil, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "cannot use plan_mode=patch on an empty topic graph") {
|
||||
t.Fatalf("expected patch on empty graph error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateLeaderOutputForTopicRejectsInitialOnExistingGraph(t *testing.T) {
|
||||
err := validateLeaderOutputForTopic(leaderOutput{
|
||||
PlanSummaryMarkdown: "Initial on existing graph.",
|
||||
PlanMode: "initial",
|
||||
ReplanReason: "",
|
||||
ExecutionMode: "plan_only",
|
||||
LeaderReply: leaderReplySpec{Markdown: "Initial.", Type: "summary"},
|
||||
}, []lane.Record{{ID: "chain_1", Slug: "ui-chain", Name: "UI Chain"}}, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "must use plan_mode=patch") {
|
||||
t.Fatalf("expected initial on existing graph error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateLeaderOutputForTopicRejectsPatchWithoutReason(t *testing.T) {
|
||||
err := validateLeaderOutputForTopic(leaderOutput{
|
||||
PlanSummaryMarkdown: "Patch without reason.",
|
||||
PlanMode: "patch",
|
||||
ReplanReason: "",
|
||||
ExecutionMode: "plan_only",
|
||||
LeaderReply: leaderReplySpec{Markdown: "Patch.", Type: "summary"},
|
||||
}, []lane.Record{{ID: "chain_1", Slug: "ui-chain", Name: "UI Chain"}}, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "requires replan_reason") {
|
||||
t.Fatalf("expected patch without reason error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateLeaderOutputForTopicAllowsPatchDependenciesOnExistingTaskIDs(t *testing.T) {
|
||||
err := validateLeaderOutputForTopic(leaderOutput{
|
||||
PlanSummaryMarkdown: "Patch existing graph.",
|
||||
PlanMode: "patch",
|
||||
ReplanReason: "Attach a follow-up task to the completed gate.",
|
||||
ExecutionMode: "plan_only",
|
||||
LeaderReply: leaderReplySpec{Markdown: "Continue from the existing gate.", Type: "summary"},
|
||||
Tasks: []leaderTaskSpec{
|
||||
{
|
||||
Key: "follow-up",
|
||||
Title: "Continue implementation",
|
||||
BodyMarkdown: "Build the next step.",
|
||||
AcceptanceMarkdown: "Ready to proceed.",
|
||||
Kind: "execution",
|
||||
Deliverables: []string{"apps/web/src"},
|
||||
Priority: 10,
|
||||
TaskOrder: 2,
|
||||
DependsOn: []string{"task-existing"},
|
||||
},
|
||||
},
|
||||
}, []lane.Record{{ID: "chain_1", Slug: "ui-chain", Name: "UI Chain"}}, []task.Record{{ID: "task-existing", Title: "Existing Gate"}})
|
||||
if err != nil {
|
||||
t.Fatalf("expected existing task dependency to validate, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateLeaderOutputForTopicRejectsUnknownPatchDependencyKey(t *testing.T) {
|
||||
err := validateLeaderOutputForTopic(leaderOutput{
|
||||
PlanSummaryMarkdown: "Patch existing graph.",
|
||||
PlanMode: "patch",
|
||||
ReplanReason: "Attach a follow-up task to the completed gate.",
|
||||
ExecutionMode: "plan_only",
|
||||
LeaderReply: leaderReplySpec{Markdown: "Continue from the existing gate.", Type: "summary"},
|
||||
Tasks: []leaderTaskSpec{
|
||||
{
|
||||
Key: "follow-up",
|
||||
Title: "Continue implementation",
|
||||
BodyMarkdown: "Build the next step.",
|
||||
AcceptanceMarkdown: "Ready to proceed.",
|
||||
Kind: "execution",
|
||||
Deliverables: []string{"apps/web/src"},
|
||||
Priority: 10,
|
||||
TaskOrder: 2,
|
||||
DependsOn: []string{"missing-task"},
|
||||
},
|
||||
},
|
||||
}, []lane.Record{{ID: "chain_1", Slug: "ui-chain", Name: "UI Chain"}}, []task.Record{{ID: "task-existing", Title: "Existing Gate"}})
|
||||
if err == nil || !strings.Contains(err.Error(), "unknown dependency key") {
|
||||
t.Fatalf("expected unknown dependency key error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeaderCommandEnvWritesRuntimeCodexHomeUnderProjectRuntime(t *testing.T) {
|
||||
projectRoot := t.TempDir()
|
||||
env := leaderCommandEnv(projectRoot, runtimeconfig.ResolvedRole{
|
||||
WorkspaceID: "ws_1",
|
||||
Role: role.Definition{Name: "leader"},
|
||||
Config: role.Config{
|
||||
RoleName: "leader",
|
||||
ConfigTOML: strings.Join([]string{
|
||||
`model = "gpt-5.4"`,
|
||||
`model_provider = "custom"`,
|
||||
``,
|
||||
`[model_providers.custom]`,
|
||||
`base_url = "http://example.test/v1"`,
|
||||
`wire_api = "responses"`,
|
||||
}, "\n"),
|
||||
AuthJSON: `{"OPENAI_API_KEY":"token-1"}`,
|
||||
},
|
||||
})
|
||||
|
||||
expectedHome := runtimecodex.HostLeaderHomeDir(projectRoot, "ws_1", "leader")
|
||||
expectedCodexHome := runtimecodex.HostLeaderCodexDir(projectRoot, "ws_1", "leader")
|
||||
joined := strings.Join(env, "\n")
|
||||
if !strings.Contains(joined, "HOME="+expectedHome) {
|
||||
t.Fatalf("expected isolated HOME in env, env=%q", joined)
|
||||
}
|
||||
if !strings.Contains(joined, "CODEX_HOME="+expectedCodexHome) {
|
||||
t.Fatalf("expected isolated CODEX_HOME in env, env=%q", joined)
|
||||
}
|
||||
|
||||
configBytes, err := os.ReadFile(filepath.Join(expectedCodexHome, "config.toml"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(config.toml) error = %v", err)
|
||||
}
|
||||
configText := string(configBytes)
|
||||
for _, needle := range []string{
|
||||
`model = "gpt-5.4"`,
|
||||
`model_provider = "custom"`,
|
||||
`[model_providers.custom]`,
|
||||
`base_url = "http://example.test/v1"`,
|
||||
} {
|
||||
if !strings.Contains(configText, needle) {
|
||||
t.Fatalf("expected seeded config to contain %q, got:\n%s", needle, configText)
|
||||
}
|
||||
}
|
||||
authBytes, err := os.ReadFile(filepath.Join(expectedCodexHome, "auth.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(auth.json) error = %v", err)
|
||||
}
|
||||
var auth map[string]any
|
||||
if err := json.Unmarshal(authBytes, &auth); err != nil {
|
||||
t.Fatalf("Unmarshal(auth.json) error = %v", err)
|
||||
}
|
||||
if auth["OPENAI_API_KEY"] != "token-1" {
|
||||
t.Fatalf("unexpected copied auth.json: %s", string(authBytes))
|
||||
}
|
||||
}
|
||||
|
||||
func assertSchemaRequiredMatchesProperties(t *testing.T, schema map[string]any) {
|
||||
t.Helper()
|
||||
|
||||
properties, ok := schema["properties"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("schema properties missing or invalid: %#v", schema)
|
||||
}
|
||||
requiredRaw, ok := schema["required"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("schema required missing or invalid: %#v", schema)
|
||||
}
|
||||
|
||||
required := make(map[string]struct{}, len(requiredRaw))
|
||||
for _, item := range requiredRaw {
|
||||
key, ok := item.(string)
|
||||
if !ok {
|
||||
t.Fatalf("schema required item must be string, got %#v", item)
|
||||
}
|
||||
required[key] = struct{}{}
|
||||
}
|
||||
|
||||
for key := range properties {
|
||||
if _, ok := required[key]; !ok {
|
||||
t.Fatalf("schema required is missing property %q", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type fakeRunner struct {
|
||||
result RunResult
|
||||
prompt string
|
||||
}
|
||||
|
||||
func (f *fakeRunner) Run(_ context.Context, _ string, _ runtimeconfig.ResolvedRole, prompt, _ string) (RunResult, error) {
|
||||
f.prompt = prompt
|
||||
return f.result, nil
|
||||
}
|
||||
|
||||
type fakeWorkspaceRuntime struct {
|
||||
workspace workspace.Workspace
|
||||
startedLaneID string
|
||||
startedLaneIDs []string
|
||||
}
|
||||
|
||||
func (f *fakeWorkspaceRuntime) Ensure(_ context.Context, _ string) (workspace.Workspace, workspaceruntime.Runtime, error) {
|
||||
return f.workspace, workspaceruntime.Runtime{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeWorkspaceRuntime) EnsureLane(_ context.Context, laneID string) (lane.Record, error) {
|
||||
f.startedLaneID = laneID
|
||||
f.startedLaneIDs = append(f.startedLaneIDs, laneID)
|
||||
return lane.Record{ID: laneID, Name: "UI Chain", Status: lane.StatusRunning}, nil
|
||||
}
|
||||
|
||||
func seedLeaderLoopWorkspace(t *testing.T, ctx context.Context, store *sqlitestore.Store, clock timeutil.Clock) (workspace.Workspace, topic.Record) {
|
||||
t.Helper()
|
||||
now := timeutil.FormatRFC3339(clock.Now())
|
||||
project, err := store.CreateProject(ctx, workspace.Project{
|
||||
Slug: "demo",
|
||||
Name: "Demo",
|
||||
RootPath: t.TempDir(),
|
||||
DefaultBranch: "main",
|
||||
Status: "active",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProject() error = %v", err)
|
||||
}
|
||||
ws, err := store.CreateWorkspace(ctx, workspace.Workspace{
|
||||
ProjectID: project.ID,
|
||||
Slug: "todo",
|
||||
Name: "todo",
|
||||
RootPath: t.TempDir(),
|
||||
BaseBranch: "main",
|
||||
WorktreeBranch: "worktree/todo",
|
||||
RuntimeBackend: "host",
|
||||
Status: "active",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWorkspace() error = %v", err)
|
||||
}
|
||||
record, err := store.CreateTopic(ctx, topic.Record{
|
||||
WorkspaceID: ws.ID,
|
||||
Slug: "v1",
|
||||
Title: "v1",
|
||||
Space: topic.SpaceWorkflow,
|
||||
Status: "plan",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTopic() error = %v", err)
|
||||
}
|
||||
return ws, record
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package roles
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"inbox/internal/base/timeutil"
|
||||
"inbox/internal/domain/role"
|
||||
"inbox/internal/domain/skill"
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
GetRole(ctx context.Context, name string) (role.Definition, error)
|
||||
GetRoleConfig(ctx context.Context, roleName string) (role.Config, error)
|
||||
ListRoles(ctx context.Context) ([]role.Definition, error)
|
||||
UpsertRole(ctx context.Context, value role.Definition, changedBy string) (role.Definition, error)
|
||||
UpsertRolePrompt(ctx context.Context, value role.Prompt, changedBy string) (role.Prompt, error)
|
||||
ListRolePrompts(ctx context.Context, roleName string) ([]role.Prompt, error)
|
||||
UpsertRoleConfig(ctx context.Context, value role.Config, changedBy string) (role.Config, error)
|
||||
UpsertRoleSkillBinding(ctx context.Context, value role.SkillBinding, changedBy string) (role.SkillBinding, error)
|
||||
ListRoleSkillBindings(ctx context.Context, roleName string) ([]role.SkillBinding, error)
|
||||
}
|
||||
|
||||
type SkillRepository interface {
|
||||
GetSkillByKey(ctx context.Context, skillKey string) (skill.Definition, error)
|
||||
ListSkillsByIDs(ctx context.Context, ids []string) (map[string]skill.Definition, error)
|
||||
}
|
||||
|
||||
type Detail struct {
|
||||
Role role.Definition `json:"role"`
|
||||
Prompts []role.Prompt `json:"prompts"`
|
||||
Config role.Config `json:"config"`
|
||||
Bindings []role.SkillBinding `json:"bindings"`
|
||||
Workspace string `json:"workspace"`
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repo Repository
|
||||
skills SkillRepository
|
||||
clock timeutil.Clock
|
||||
}
|
||||
|
||||
func NewService(repo Repository, skills SkillRepository, clock timeutil.Clock) *Service {
|
||||
if clock == nil {
|
||||
clock = timeutil.SystemClock{}
|
||||
}
|
||||
return &Service{
|
||||
repo: repo,
|
||||
skills: skills,
|
||||
clock: clock,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context) ([]role.Definition, error) {
|
||||
return s.repo.ListRoles(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) GetDetail(ctx context.Context, roleName, workspaceID string) (Detail, error) {
|
||||
definition, err := s.repo.GetRole(ctx, roleName)
|
||||
if err != nil {
|
||||
return Detail{}, err
|
||||
}
|
||||
prompts, err := s.repo.ListRolePrompts(ctx, roleName)
|
||||
if err != nil {
|
||||
return Detail{}, err
|
||||
}
|
||||
config, err := s.repo.GetRoleConfig(ctx, roleName)
|
||||
if err != nil {
|
||||
return Detail{}, err
|
||||
}
|
||||
bindings, err := s.repo.ListRoleSkillBindings(ctx, roleName)
|
||||
if err != nil {
|
||||
return Detail{}, err
|
||||
}
|
||||
return Detail{
|
||||
Role: definition,
|
||||
Prompts: prompts,
|
||||
Config: config,
|
||||
Bindings: bindings,
|
||||
Workspace: workspaceID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Upsert(ctx context.Context, value role.Definition, changedBy string) (role.Definition, error) {
|
||||
return s.repo.UpsertRole(ctx, value, changedBy)
|
||||
}
|
||||
|
||||
func (s *Service) UpsertPrompt(ctx context.Context, value role.Prompt, changedBy string) (role.Prompt, error) {
|
||||
return s.repo.UpsertRolePrompt(ctx, value, changedBy)
|
||||
}
|
||||
|
||||
func (s *Service) UpsertConfig(ctx context.Context, value role.Config, changedBy string) (role.Config, error) {
|
||||
return s.repo.UpsertRoleConfig(ctx, value, changedBy)
|
||||
}
|
||||
|
||||
func (s *Service) UpsertSkillBinding(ctx context.Context, roleName, skillKey string, value role.SkillBinding, changedBy string) (role.SkillBinding, error) {
|
||||
skillDef, err := s.skills.GetSkillByKey(ctx, skillKey)
|
||||
if err != nil {
|
||||
return role.SkillBinding{}, fmt.Errorf("get skill %q: %w", skillKey, err)
|
||||
}
|
||||
value.RoleName = roleName
|
||||
value.SkillID = skillDef.ID
|
||||
return s.repo.UpsertRoleSkillBinding(ctx, value, changedBy)
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package runtimecodex
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"inbox/internal/app/runtimeconfig"
|
||||
"inbox/internal/domain/role"
|
||||
)
|
||||
|
||||
const (
|
||||
WorkspaceDir = ".inbox/runtime-codex"
|
||||
ContainerDir = "/workspace/.inbox/runtime-codex"
|
||||
ContainerHome = "/root"
|
||||
ContainerCodex = "/root/.codex"
|
||||
configFilename = "config.toml"
|
||||
authFilename = "auth.json"
|
||||
)
|
||||
|
||||
type RoleCatalog interface {
|
||||
ListRoles(ctx context.Context) ([]role.Definition, error)
|
||||
}
|
||||
|
||||
type RoleResolver interface {
|
||||
ResolveRole(ctx context.Context, workspaceID, roleName string) (runtimeconfig.ResolvedRole, error)
|
||||
}
|
||||
|
||||
type Materializer struct {
|
||||
roles RoleCatalog
|
||||
resolver RoleResolver
|
||||
}
|
||||
|
||||
func NewMaterializer(roles RoleCatalog, resolver RoleResolver) *Materializer {
|
||||
return &Materializer{
|
||||
roles: roles,
|
||||
resolver: resolver,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Materializer) Sync(ctx context.Context, workspaceID, workspaceRoot string) error {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(workspaceRoot) == "" {
|
||||
return fmt.Errorf("workspace root is required")
|
||||
}
|
||||
items, err := m.roles.ListRoles(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list roles for runtime codex config: %w", err)
|
||||
}
|
||||
|
||||
baseDir := filepath.Join(workspaceRoot, ".inbox")
|
||||
liveDir := filepath.Join(workspaceRoot, filepath.FromSlash(WorkspaceDir))
|
||||
if err := os.MkdirAll(baseDir, 0755); err != nil {
|
||||
return fmt.Errorf("ensure runtime codex base dir: %w", err)
|
||||
}
|
||||
|
||||
tmpDir, err := os.MkdirTemp(baseDir, "runtime-codex-")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create runtime codex temp dir: %w", err)
|
||||
}
|
||||
cleanup := func() { _ = os.RemoveAll(tmpDir) }
|
||||
|
||||
for _, item := range items {
|
||||
item = role.NormalizeDefinition(item)
|
||||
if !item.IsEnabled || item.ExecutorKind != role.ExecutorKindCodex {
|
||||
continue
|
||||
}
|
||||
resolved, err := m.resolver.ResolveRole(ctx, workspaceID, item.Name)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return fmt.Errorf("resolve role %q for runtime codex config: %w", item.Name, err)
|
||||
}
|
||||
if _, err := WriteResolvedRoleHome(tmpDir, resolved); err != nil {
|
||||
cleanup()
|
||||
return fmt.Errorf("write runtime codex home for %q: %w", item.Name, err)
|
||||
}
|
||||
if err := writeResolvedRoleSkills(tmpDir, resolved); err != nil {
|
||||
cleanup()
|
||||
return fmt.Errorf("write runtime codex skills for %q: %w", item.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
_ = os.RemoveAll(liveDir)
|
||||
if err := os.Rename(tmpDir, liveDir); err != nil {
|
||||
cleanup()
|
||||
return fmt.Errorf("publish runtime codex dir: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ContainerHomeDir(roleName string) string {
|
||||
return path.Join(ContainerDir, sanitizeRolePath(roleName))
|
||||
}
|
||||
|
||||
func ContainerUserHomeDir() string {
|
||||
return ContainerHome
|
||||
}
|
||||
|
||||
func ContainerCodexDir() string {
|
||||
return ContainerCodex
|
||||
}
|
||||
|
||||
func WorkspaceHomeDir(workspaceRoot, roleName string) string {
|
||||
return filepath.Join(workspaceRoot, filepath.FromSlash(WorkspaceDir), sanitizeRolePath(roleName))
|
||||
}
|
||||
|
||||
func WorkspaceCodexDir(workspaceRoot, roleName string) string {
|
||||
return filepath.Join(WorkspaceHomeDir(workspaceRoot, roleName), ".codex")
|
||||
}
|
||||
|
||||
func WriteResolvedRoleHome(root string, resolved runtimeconfig.ResolvedRole) (string, error) {
|
||||
roleHome := filepath.Join(root, sanitizeRolePath(resolved.Role.Name))
|
||||
codexDir := filepath.Join(roleHome, ".codex")
|
||||
if err := os.MkdirAll(codexDir, 0755); err != nil {
|
||||
return "", fmt.Errorf("create runtime codex dir for %q: %w", resolved.Role.Name, err)
|
||||
}
|
||||
|
||||
configText := normalizeConfigTOML(resolved.Config.ConfigTOML)
|
||||
if err := os.WriteFile(filepath.Join(codexDir, configFilename), []byte(configText), 0644); err != nil {
|
||||
return "", fmt.Errorf("write config.toml for %q: %w", resolved.Role.Name, err)
|
||||
}
|
||||
|
||||
authBytes, err := normalizeAuthJSON(resolved.Config.AuthJSON)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("normalize auth.json for %q: %w", resolved.Role.Name, err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(codexDir, authFilename), authBytes, 0600); err != nil {
|
||||
return "", fmt.Errorf("write auth.json for %q: %w", resolved.Role.Name, err)
|
||||
}
|
||||
return roleHome, nil
|
||||
}
|
||||
|
||||
func HostContainerRuntimeRoot(projectRoot, workspaceID string) string {
|
||||
return filepath.Join(projectRoot, ".runtime", "container-codex", sanitizeRolePath(workspaceID))
|
||||
}
|
||||
|
||||
func HostContainerCodexDir(projectRoot, workspaceID, roleName string) string {
|
||||
return WorkspaceCodexDir(HostContainerRuntimeRoot(projectRoot, workspaceID), roleName)
|
||||
}
|
||||
|
||||
func HostLeaderRuntimeRoot(projectRoot, workspaceID string) string {
|
||||
return filepath.Join(projectRoot, ".runtime", "leader-codex", sanitizeRolePath(workspaceID))
|
||||
}
|
||||
|
||||
func HostLeaderHomeDir(projectRoot, workspaceID, roleName string) string {
|
||||
return filepath.Join(HostLeaderRuntimeRoot(projectRoot, workspaceID), sanitizeRolePath(roleName))
|
||||
}
|
||||
|
||||
func HostLeaderCodexDir(projectRoot, workspaceID, roleName string) string {
|
||||
return filepath.Join(HostLeaderHomeDir(projectRoot, workspaceID, roleName), ".codex")
|
||||
}
|
||||
|
||||
func sanitizeRolePath(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "role"
|
||||
}
|
||||
replacer := strings.NewReplacer(
|
||||
"/", "-",
|
||||
"\\", "-",
|
||||
":", "-",
|
||||
"*", "-",
|
||||
"?", "-",
|
||||
"\"", "-",
|
||||
"<", "-",
|
||||
">", "-",
|
||||
"|", "-",
|
||||
" ", "-",
|
||||
)
|
||||
value = replacer.Replace(value)
|
||||
value = strings.Trim(value, ".-")
|
||||
if value == "" {
|
||||
return "role"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func normalizeConfigTOML(value string) string {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
func normalizeAuthJSON(value string) ([]byte, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
value = "{}"
|
||||
}
|
||||
if !json.Valid([]byte(value)) {
|
||||
return nil, fmt.Errorf("invalid json")
|
||||
}
|
||||
|
||||
var decoded any
|
||||
if err := json.Unmarshal([]byte(value), &decoded); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.MarshalIndent(decoded, "", " ")
|
||||
}
|
||||
|
||||
func writeResolvedRoleSkills(root string, resolved runtimeconfig.ResolvedRole) error {
|
||||
if len(resolved.Skills) == 0 {
|
||||
return nil
|
||||
}
|
||||
codexDir := filepath.Join(root, sanitizeRolePath(resolved.Role.Name), ".codex")
|
||||
skillsRoot := filepath.Join(codexDir, "skills")
|
||||
for _, item := range resolved.Skills {
|
||||
skillKey := strings.TrimSpace(item.Skill.SkillKey)
|
||||
if skillKey == "" {
|
||||
skillKey = sanitizeRolePath(item.Skill.ID)
|
||||
}
|
||||
if skillKey == "" {
|
||||
return fmt.Errorf("resolved skill key is required")
|
||||
}
|
||||
targetDir := filepath.Join(skillsRoot, sanitizeRolePath(skillKey))
|
||||
if err := os.MkdirAll(targetDir, 0755); err != nil {
|
||||
return fmt.Errorf("create skill dir %s: %w", targetDir, err)
|
||||
}
|
||||
skillBody := strings.TrimSpace(item.Skill.ContentMarkdown)
|
||||
if skillBody == "" {
|
||||
return fmt.Errorf("skill %q content_markdown is required", skillKey)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(targetDir, "SKILL.md"), []byte(skillBody), 0644); err != nil {
|
||||
return fmt.Errorf("write SKILL.md for %q: %w", skillKey, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package runtimecodex
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"inbox/internal/app/runtimeconfig"
|
||||
"inbox/internal/domain/role"
|
||||
"inbox/internal/domain/skill"
|
||||
)
|
||||
|
||||
type fakeRoleCatalog struct {
|
||||
roles []role.Definition
|
||||
}
|
||||
|
||||
func (f fakeRoleCatalog) ListRoles(_ context.Context) ([]role.Definition, error) {
|
||||
return append([]role.Definition(nil), f.roles...), nil
|
||||
}
|
||||
|
||||
type fakeRoleResolver struct {
|
||||
resolved map[string]runtimeconfig.ResolvedRole
|
||||
}
|
||||
|
||||
func (f fakeRoleResolver) ResolveRole(_ context.Context, _ string, roleName string) (runtimeconfig.ResolvedRole, error) {
|
||||
return f.resolved[roleName], nil
|
||||
}
|
||||
|
||||
func TestMaterializerSyncWritesRoleConfigs(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
root := t.TempDir()
|
||||
|
||||
materializer := NewMaterializer(
|
||||
fakeRoleCatalog{
|
||||
roles: []role.Definition{
|
||||
{Name: "worker", Title: "Worker", IsEnabled: true},
|
||||
{Name: "disabled_role", Title: "Disabled", IsEnabled: false},
|
||||
},
|
||||
},
|
||||
fakeRoleResolver{
|
||||
resolved: map[string]runtimeconfig.ResolvedRole{
|
||||
"worker": {
|
||||
Role: role.Definition{Name: "worker", Title: "Worker", IsEnabled: true},
|
||||
Config: role.Config{
|
||||
RoleName: "worker",
|
||||
ConfigTOML: strings.Join([]string{
|
||||
`model = "gpt-5.2"`,
|
||||
`model_provider = "custom"`,
|
||||
``,
|
||||
`[shell_environment_policy]`,
|
||||
`inherit = "core"`,
|
||||
`exclude = [ ]`,
|
||||
`include_only = [ ]`,
|
||||
`experimental_use_profile = false`,
|
||||
``,
|
||||
`[shell_environment_policy.set]`,
|
||||
`XDG_CACHE_HOME = "/tmp/codex-xdg-cache"`,
|
||||
``,
|
||||
`[model_providers.custom]`,
|
||||
`base_url = "http://example.test/v1"`,
|
||||
`wire_api = "responses"`,
|
||||
``,
|
||||
`[mcp_servers.playwright]`,
|
||||
`enabled = false`,
|
||||
`command = "npx"`,
|
||||
`args = [ "-y", "@playwright/mcp@latest" ]`,
|
||||
`startup_timeout_sec = 90`,
|
||||
``,
|
||||
`[projects."/workspace"]`,
|
||||
`trust_level = "trusted"`,
|
||||
}, "\n"),
|
||||
AuthJSON: `{"OPENAI_API_KEY":"token-1"}`,
|
||||
},
|
||||
Skills: []runtimeconfig.ResolvedSkill{
|
||||
{
|
||||
Skill: skill.Definition{
|
||||
ID: "builtin-skill-inbox",
|
||||
SkillKey: "inbox",
|
||||
ContentMarkdown: "# Inbox\n\nUse inbox.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
if err := materializer.Sync(ctx, "ws_1", root); err != nil {
|
||||
t.Fatalf("Sync() error = %v", err)
|
||||
}
|
||||
|
||||
configPath := filepath.Join(root, filepath.FromSlash(WorkspaceDir), "worker", ".codex", configFilename)
|
||||
authPath := filepath.Join(root, filepath.FromSlash(WorkspaceDir), "worker", ".codex", authFilename)
|
||||
configBody, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(config) error = %v", err)
|
||||
}
|
||||
authBody, err := os.ReadFile(authPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(auth) error = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(WorkspaceDir), "disabled_role")); !os.IsNotExist(err) {
|
||||
t.Fatalf("disabled role should not have runtime codex home")
|
||||
}
|
||||
|
||||
configText := string(configBody)
|
||||
for _, needle := range []string{
|
||||
`model = "gpt-5.2"`,
|
||||
`model_provider = "custom"`,
|
||||
`[model_providers.custom]`,
|
||||
`base_url = "http://example.test/v1"`,
|
||||
`[mcp_servers.playwright]`,
|
||||
`[shell_environment_policy.set]`,
|
||||
`[projects."/workspace"]`,
|
||||
} {
|
||||
if !strings.Contains(configText, needle) {
|
||||
t.Fatalf("expected config to contain %q, got:\n%s", needle, configText)
|
||||
}
|
||||
}
|
||||
|
||||
var auth map[string]string
|
||||
if err := json.Unmarshal(authBody, &auth); err != nil {
|
||||
t.Fatalf("json.Unmarshal(auth) error = %v", err)
|
||||
}
|
||||
if auth["OPENAI_API_KEY"] != "token-1" {
|
||||
t.Fatalf("expected auth OPENAI_API_KEY to be written, got %#v", auth)
|
||||
}
|
||||
skillBody, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(WorkspaceDir), "worker", ".codex", "skills", "inbox", "SKILL.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(skill) error = %v", err)
|
||||
}
|
||||
if string(skillBody) != "# Inbox\n\nUse inbox." {
|
||||
t.Fatalf("unexpected skill body %q", string(skillBody))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteResolvedRoleHomeWritesOnlyResolvedConfig(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
roleHome, err := WriteResolvedRoleHome(root, runtimeconfig.ResolvedRole{
|
||||
WorkspaceID: "ws_1",
|
||||
Role: role.Definition{Name: "leader", Title: "Leader", IsEnabled: true},
|
||||
Config: role.Config{
|
||||
RoleName: "leader",
|
||||
ConfigTOML: "model = \"gpt-5.4\"",
|
||||
AuthJSON: `{"OPENAI_API_KEY":"token-2"}`,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("WriteResolvedRoleHome() error = %v", err)
|
||||
}
|
||||
authBody, err := os.ReadFile(filepath.Join(roleHome, ".codex", authFilename))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(auth) error = %v", err)
|
||||
}
|
||||
var auth map[string]string
|
||||
if err := json.Unmarshal(authBody, &auth); err != nil {
|
||||
t.Fatalf("json.Unmarshal(auth) error = %v", err)
|
||||
}
|
||||
if auth["OPENAI_API_KEY"] != "token-2" {
|
||||
t.Fatalf("expected auth OPENAI_API_KEY to be written, got %#v", auth)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package runtimeconfig
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"inbox/internal/domain/role"
|
||||
)
|
||||
|
||||
// RenderInstructions flattens the resolved system prompt and bound skills into
|
||||
// the markdown instructions that should be given to the runtime.
|
||||
func RenderInstructions(resolved ResolvedRole, fallbackSystemPrompt string) string {
|
||||
var builder strings.Builder
|
||||
|
||||
systemPrompt := strings.TrimSpace(fallbackSystemPrompt)
|
||||
if prompt, ok := resolved.Prompts[role.PromptSystem]; ok && strings.TrimSpace(prompt.ContentMarkdown) != "" {
|
||||
systemPrompt = strings.TrimSpace(prompt.ContentMarkdown)
|
||||
}
|
||||
if systemPrompt != "" {
|
||||
builder.WriteString(systemPrompt)
|
||||
}
|
||||
|
||||
if len(resolved.Skills) > 0 {
|
||||
if builder.Len() > 0 {
|
||||
builder.WriteString("\n\n")
|
||||
}
|
||||
builder.WriteString("## Skills\n")
|
||||
for _, item := range resolved.Skills {
|
||||
name := strings.TrimSpace(item.Skill.Name)
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(item.Skill.SkillKey)
|
||||
}
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(item.Skill.ID)
|
||||
}
|
||||
builder.WriteString(fmt.Sprintf("### %s\n%s\n", name, strings.TrimSpace(item.Skill.ContentMarkdown)))
|
||||
}
|
||||
}
|
||||
|
||||
return strings.TrimSpace(builder.String())
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package runtimeconfig
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"inbox/internal/base/timeutil"
|
||||
"inbox/internal/domain/role"
|
||||
"inbox/internal/domain/skill"
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
GetRole(ctx context.Context, name string) (role.Definition, error)
|
||||
GetRoleConfig(ctx context.Context, roleName string) (role.Config, error)
|
||||
ListRolePrompts(ctx context.Context, roleName string) ([]role.Prompt, error)
|
||||
ListRoleSkillBindings(ctx context.Context, roleName string) ([]role.SkillBinding, error)
|
||||
}
|
||||
|
||||
type SkillRepository interface {
|
||||
ListSkillsByIDs(ctx context.Context, ids []string) (map[string]skill.Definition, error)
|
||||
}
|
||||
|
||||
type ResolvedSkill struct {
|
||||
Binding role.SkillBinding `json:"binding"`
|
||||
Skill skill.Definition `json:"skill"`
|
||||
}
|
||||
|
||||
type ResolvedRole struct {
|
||||
Role role.Definition `json:"role"`
|
||||
WorkspaceID string `json:"workspace_id,omitempty"`
|
||||
Prompts map[role.PromptKind]role.Prompt `json:"prompts"`
|
||||
Config role.Config `json:"config"`
|
||||
Skills []ResolvedSkill `json:"skills"`
|
||||
ResolvedAt string `json:"resolved_at"`
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repo Repository
|
||||
skills SkillRepository
|
||||
clock timeutil.Clock
|
||||
}
|
||||
|
||||
func NewService(repo Repository, skills SkillRepository, clock timeutil.Clock) *Service {
|
||||
if clock == nil {
|
||||
clock = timeutil.SystemClock{}
|
||||
}
|
||||
return &Service{
|
||||
repo: repo,
|
||||
skills: skills,
|
||||
clock: clock,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) ResolveRole(ctx context.Context, workspaceID, roleName string) (ResolvedRole, error) {
|
||||
definition, err := s.repo.GetRole(ctx, roleName)
|
||||
if err != nil {
|
||||
return ResolvedRole{}, fmt.Errorf("get role %q: %w", roleName, err)
|
||||
}
|
||||
|
||||
prompts, err := s.repo.ListRolePrompts(ctx, roleName)
|
||||
if err != nil {
|
||||
return ResolvedRole{}, fmt.Errorf("list role prompts for %q: %w", roleName, err)
|
||||
}
|
||||
bindings, err := s.repo.ListRoleSkillBindings(ctx, roleName)
|
||||
if err != nil {
|
||||
return ResolvedRole{}, fmt.Errorf("list role skill bindings for %q: %w", roleName, err)
|
||||
}
|
||||
|
||||
resolved := ResolvedRole{
|
||||
Role: definition,
|
||||
WorkspaceID: workspaceID,
|
||||
Prompts: pickPrompts(prompts, workspaceID),
|
||||
ResolvedAt: timeutil.FormatRFC3339(s.clock.Now()),
|
||||
}
|
||||
config, err := s.repo.GetRoleConfig(ctx, roleName)
|
||||
if err != nil {
|
||||
return ResolvedRole{}, fmt.Errorf("get role config for %q: %w", roleName, err)
|
||||
}
|
||||
resolved.Config = config
|
||||
resolved.Config.RoleName = roleName
|
||||
|
||||
resolvedBindings := pickBindings(bindings, workspaceID)
|
||||
if len(resolvedBindings) == 0 {
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
skillIDs := make([]string, 0, len(resolvedBindings))
|
||||
for _, binding := range resolvedBindings {
|
||||
if binding.IsEnabled {
|
||||
skillIDs = append(skillIDs, binding.SkillID)
|
||||
}
|
||||
}
|
||||
skillsByID, err := s.skills.ListSkillsByIDs(ctx, skillIDs)
|
||||
if err != nil {
|
||||
return ResolvedRole{}, fmt.Errorf("list skills for %q: %w", roleName, err)
|
||||
}
|
||||
|
||||
resolved.Skills = make([]ResolvedSkill, 0, len(skillIDs))
|
||||
for _, binding := range resolvedBindings {
|
||||
if !binding.IsEnabled {
|
||||
continue
|
||||
}
|
||||
skillDef, ok := skillsByID[binding.SkillID]
|
||||
if !ok {
|
||||
return ResolvedRole{}, fmt.Errorf("missing skill %q for role %q", binding.SkillID, roleName)
|
||||
}
|
||||
resolved.Skills = append(resolved.Skills, ResolvedSkill{
|
||||
Binding: binding,
|
||||
Skill: skillDef,
|
||||
})
|
||||
}
|
||||
sort.Slice(resolved.Skills, func(i, j int) bool {
|
||||
if resolved.Skills[i].Binding.SortOrder == resolved.Skills[j].Binding.SortOrder {
|
||||
if resolved.Skills[i].Skill.Name == resolved.Skills[j].Skill.Name {
|
||||
return resolved.Skills[i].Skill.ID < resolved.Skills[j].Skill.ID
|
||||
}
|
||||
return resolved.Skills[i].Skill.Name < resolved.Skills[j].Skill.Name
|
||||
}
|
||||
return resolved.Skills[i].Binding.SortOrder < resolved.Skills[j].Binding.SortOrder
|
||||
})
|
||||
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
func (r ResolvedRole) SnapshotJSON() (string, error) {
|
||||
data, err := json.Marshal(r)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal runtime config snapshot: %w", err)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func pickPrompts(items []role.Prompt, workspaceID string) map[role.PromptKind]role.Prompt {
|
||||
out := make(map[role.PromptKind]role.Prompt)
|
||||
for _, item := range items {
|
||||
if item.WorkspaceID != "" && item.WorkspaceID != workspaceID {
|
||||
continue
|
||||
}
|
||||
current, exists := out[item.PromptKind]
|
||||
if !exists {
|
||||
out[item.PromptKind] = item
|
||||
continue
|
||||
}
|
||||
if current.WorkspaceID == "" && item.WorkspaceID == workspaceID {
|
||||
out[item.PromptKind] = item
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func pickBindings(items []role.SkillBinding, workspaceID string) []role.SkillBinding {
|
||||
out := make(map[string]role.SkillBinding, len(items))
|
||||
for _, item := range items {
|
||||
if item.WorkspaceID != "" && item.WorkspaceID != workspaceID {
|
||||
continue
|
||||
}
|
||||
current, exists := out[item.SkillID]
|
||||
if !exists {
|
||||
out[item.SkillID] = item
|
||||
continue
|
||||
}
|
||||
if current.WorkspaceID == "" && item.WorkspaceID == workspaceID {
|
||||
out[item.SkillID] = item
|
||||
}
|
||||
}
|
||||
result := make([]role.SkillBinding, 0, len(out))
|
||||
for _, item := range out {
|
||||
result = append(result, item)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"inbox/internal/domain/skill"
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
GetSkillByKey(ctx context.Context, skillKey string) (skill.Definition, error)
|
||||
ListSkills(ctx context.Context) ([]skill.Definition, error)
|
||||
UpsertSkill(ctx context.Context, value skill.Definition, changedBy string) (skill.Definition, error)
|
||||
DeleteSkillByKey(ctx context.Context, skillKey string) error
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repo Repository
|
||||
}
|
||||
|
||||
func NewService(repo Repository) *Service {
|
||||
return &Service{repo: repo}
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context) ([]skill.Definition, error) {
|
||||
return s.repo.ListSkills(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) GetByKey(ctx context.Context, skillKey string) (skill.Definition, error) {
|
||||
return s.repo.GetSkillByKey(ctx, skillKey)
|
||||
}
|
||||
|
||||
func (s *Service) Upsert(ctx context.Context, value skill.Definition, changedBy string) (skill.Definition, error) {
|
||||
return s.repo.UpsertSkill(ctx, value, changedBy)
|
||||
}
|
||||
|
||||
func (s *Service) Delete(ctx context.Context, skillKey string) error {
|
||||
return s.repo.DeleteSkillByKey(ctx, skillKey)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package systemfs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type DirectoryEntry struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
IsGit bool `json:"is_git"`
|
||||
}
|
||||
|
||||
type DirectoryListing struct {
|
||||
Current string `json:"current"`
|
||||
CurrentIsGit bool `json:"current_is_git"`
|
||||
Parent string `json:"parent"`
|
||||
Directories []DirectoryEntry `json:"dirs"`
|
||||
}
|
||||
|
||||
type Service struct{}
|
||||
|
||||
func NewService() *Service {
|
||||
return &Service{}
|
||||
}
|
||||
|
||||
func (s *Service) ListDirectories(current string) (DirectoryListing, error) {
|
||||
current = strings.TrimSpace(current)
|
||||
if current == "" {
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return DirectoryListing{}, err
|
||||
}
|
||||
current = wd
|
||||
}
|
||||
|
||||
current, err := filepath.Abs(current)
|
||||
if err != nil {
|
||||
return DirectoryListing{}, err
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(current)
|
||||
if err != nil {
|
||||
return DirectoryListing{}, err
|
||||
}
|
||||
|
||||
dirs := make([]DirectoryEntry, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
fullPath := filepath.Join(current, entry.Name())
|
||||
dirs = append(dirs, DirectoryEntry{
|
||||
Name: entry.Name(),
|
||||
Path: fullPath,
|
||||
IsGit: pathHasGitDir(fullPath),
|
||||
})
|
||||
}
|
||||
sort.Slice(dirs, func(i, j int) bool {
|
||||
return dirs[i].Name < dirs[j].Name
|
||||
})
|
||||
|
||||
return DirectoryListing{
|
||||
Current: current,
|
||||
CurrentIsGit: pathHasGitDir(current),
|
||||
Parent: filepath.Dir(current),
|
||||
Directories: dirs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateDirectory(parent, name string) (string, error) {
|
||||
parent = strings.TrimSpace(parent)
|
||||
name = strings.TrimSpace(name)
|
||||
if parent == "" || name == "" {
|
||||
return "", fmt.Errorf("parent and name are required")
|
||||
}
|
||||
|
||||
path := filepath.Join(parent, name)
|
||||
if err := os.MkdirAll(path, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func pathHasGitDir(path string) bool {
|
||||
_, err := os.Stat(filepath.Join(path, ".git"))
|
||||
return err == nil
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package systemfs
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestListDirectoriesReturnsSortedDirectoriesAndGitFlag(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(root, "b-dir"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir b-dir: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(root, "a-dir", ".git"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir a-dir/.git: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "notes.txt"), []byte("ignored"), 0o644); err != nil {
|
||||
t.Fatalf("write notes.txt: %v", err)
|
||||
}
|
||||
|
||||
service := NewService()
|
||||
listing, err := service.ListDirectories(root)
|
||||
if err != nil {
|
||||
t.Fatalf("ListDirectories() error = %v", err)
|
||||
}
|
||||
|
||||
if listing.Current != root {
|
||||
t.Fatalf("current = %q, want %q", listing.Current, root)
|
||||
}
|
||||
if len(listing.Directories) != 2 {
|
||||
t.Fatalf("expected 2 directories, got %#v", listing.Directories)
|
||||
}
|
||||
if listing.Directories[0].Name != "a-dir" || !listing.Directories[0].IsGit {
|
||||
t.Fatalf("unexpected first directory: %#v", listing.Directories[0])
|
||||
}
|
||||
if listing.Directories[1].Name != "b-dir" || listing.Directories[1].IsGit {
|
||||
t.Fatalf("unexpected second directory: %#v", listing.Directories[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateDirectoryRequiresParentAndName(t *testing.T) {
|
||||
service := NewService()
|
||||
if _, err := service.CreateDirectory("", "child"); err == nil {
|
||||
t.Fatal("expected validation error when parent is empty")
|
||||
}
|
||||
if _, err := service.CreateDirectory("/tmp", ""); err == nil {
|
||||
t.Fatal("expected validation error when name is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateDirectoryCreatesPath(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
service := NewService()
|
||||
|
||||
path, err := service.CreateDirectory(root, "nested/child")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDirectory() error = %v", err)
|
||||
}
|
||||
if path != filepath.Join(root, "nested/child") {
|
||||
t.Fatalf("path = %q", path)
|
||||
}
|
||||
if info, err := os.Stat(path); err != nil || !info.IsDir() {
|
||||
t.Fatalf("expected created directory, stat err=%v info=%#v", err, info)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,698 @@
|
||||
package taskexec
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"inbox/internal/app/lanegit"
|
||||
"inbox/internal/app/lanematerialize"
|
||||
"inbox/internal/app/lanesnapshot"
|
||||
"inbox/internal/app/runtimeconfig"
|
||||
"inbox/internal/base/timeutil"
|
||||
"inbox/internal/domain/lane"
|
||||
"inbox/internal/domain/message"
|
||||
"inbox/internal/domain/task"
|
||||
"inbox/internal/domain/topic"
|
||||
"inbox/internal/domain/workflow"
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
GetLane(ctx context.Context, laneID string) (lane.Record, error)
|
||||
UpdateLane(ctx context.Context, value lane.Record) (lane.Record, error)
|
||||
GetTask(ctx context.Context, taskID string) (task.Record, error)
|
||||
ListTasksByLane(ctx context.Context, laneID string) ([]task.Record, error)
|
||||
UpdateTask(ctx context.Context, value task.Record) (task.Record, error)
|
||||
ListTaskDependencies(ctx context.Context, taskID string) ([]task.Dependency, error)
|
||||
AppendTaskEvent(ctx context.Context, value task.Event) (task.Event, error)
|
||||
GetTopic(ctx context.Context, topicID string) (topic.Record, error)
|
||||
UpdateTopic(ctx context.Context, value topic.Record) (topic.Record, error)
|
||||
ListMessagesByTopic(ctx context.Context, topicID string) ([]message.Record, error)
|
||||
ListLanesByTopic(ctx context.Context, topicID string) ([]lane.Record, error)
|
||||
ListTasksByTopic(ctx context.Context, topicID string) ([]task.Record, error)
|
||||
CreateMessage(ctx context.Context, value message.Record) (message.Record, error)
|
||||
CreateWorkflowRun(ctx context.Context, value workflow.Run) (workflow.Run, error)
|
||||
GetWorkflowRun(ctx context.Context, runID string) (workflow.Run, error)
|
||||
ClaimTaskExecution(ctx context.Context, run workflow.Run, taskID, startedAt string) (workflow.Run, task.Record, error)
|
||||
UpdateWorkflowRun(ctx context.Context, value workflow.Run) (workflow.Run, error)
|
||||
CompleteTaskExecution(ctx context.Context, runID, taskID, laneID string, status workflow.RunStatus, exitCode int, resultMarkdown, errorMessage, completedAt string) (workflow.Run, task.Record, lane.Record, []task.Record, error)
|
||||
AppendWorkflowRunLog(ctx context.Context, value workflow.RunLog) (workflow.RunLog, error)
|
||||
}
|
||||
|
||||
type RuntimeResolver interface {
|
||||
ResolveRole(ctx context.Context, workspaceID, roleName string) (runtimeconfig.ResolvedRole, error)
|
||||
}
|
||||
|
||||
type Snapshotter interface {
|
||||
Capture(ctx context.Context, item lane.Record, taskRecord task.Record) (string, error)
|
||||
}
|
||||
|
||||
type Materializer interface {
|
||||
Materialize(ctx context.Context, downstream lane.Record, taskID string, upstreams []lanematerialize.Upstream) error
|
||||
}
|
||||
|
||||
type LaneRuntimeReleaser interface {
|
||||
ReleaseLaneRuntime(ctx context.Context, laneID string) (lane.Record, error)
|
||||
}
|
||||
|
||||
type Option func(*Service)
|
||||
|
||||
type Assignment struct {
|
||||
Run workflow.Run `json:"run"`
|
||||
Lane lane.Record `json:"lane"`
|
||||
Task task.Record `json:"task"`
|
||||
Role runtimeconfig.ResolvedRole `json:"role"`
|
||||
Prompt string `json:"prompt"`
|
||||
Model string `json:"model,omitempty"`
|
||||
OutputMode string `json:"output_mode"`
|
||||
}
|
||||
|
||||
type Completion struct {
|
||||
RunID string `json:"run_id"`
|
||||
Status workflow.RunStatus `json:"status"`
|
||||
ExitCode int `json:"exit_code"`
|
||||
ResultMarkdown string `json:"result_markdown"`
|
||||
ErrorMessage string `json:"error_message"`
|
||||
}
|
||||
|
||||
type commandMetadata struct {
|
||||
LaneID string `json:"lane_id"`
|
||||
TaskID string `json:"task_id"`
|
||||
RunnerID string `json:"runner_id,omitempty"`
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repo Repository
|
||||
resolver RuntimeResolver
|
||||
clock timeutil.Clock
|
||||
snapshotter Snapshotter
|
||||
materializer Materializer
|
||||
runtime LaneRuntimeReleaser
|
||||
}
|
||||
|
||||
func NewService(repo Repository, resolver RuntimeResolver, clock timeutil.Clock, opts ...Option) *Service {
|
||||
if clock == nil {
|
||||
clock = timeutil.SystemClock{}
|
||||
}
|
||||
svc := &Service{
|
||||
repo: repo,
|
||||
resolver: resolver,
|
||||
clock: clock,
|
||||
snapshotter: lanesnapshot.NewService(lanegit.ExecRunner{}, clock),
|
||||
}
|
||||
if recorder, ok := repo.(lanematerialize.SyncRecorder); ok {
|
||||
svc.materializer = lanematerialize.NewService(recorder, lanegit.ExecRunner{}, clock)
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(svc)
|
||||
}
|
||||
return svc
|
||||
}
|
||||
|
||||
func WithSnapshotter(snapshotter Snapshotter) Option {
|
||||
return func(s *Service) {
|
||||
s.snapshotter = snapshotter
|
||||
}
|
||||
}
|
||||
|
||||
func WithMaterializer(materializer Materializer) Option {
|
||||
return func(s *Service) {
|
||||
s.materializer = materializer
|
||||
}
|
||||
}
|
||||
|
||||
func WithLaneRuntimeReleaser(runtime LaneRuntimeReleaser) Option {
|
||||
return func(s *Service) {
|
||||
s.runtime = runtime
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) ClaimNext(ctx context.Context, laneID, runnerID string) (Assignment, error) {
|
||||
laneRecord, err := s.repo.GetLane(ctx, laneID)
|
||||
if err != nil {
|
||||
return Assignment{}, err
|
||||
}
|
||||
topicRecord, err := s.repo.GetTopic(ctx, laneRecord.TopicID)
|
||||
if err != nil {
|
||||
return Assignment{}, err
|
||||
}
|
||||
if strings.TrimSpace(topicRecord.Status) != "execution" {
|
||||
return Assignment{}, sql.ErrNoRows
|
||||
}
|
||||
tasks, err := s.repo.ListTasksByLane(ctx, laneID)
|
||||
if err != nil {
|
||||
return Assignment{}, err
|
||||
}
|
||||
if hasRunningTask(tasks) {
|
||||
return Assignment{}, sql.ErrNoRows
|
||||
}
|
||||
candidate, err := s.nextReadyTask(ctx, tasks)
|
||||
if err != nil {
|
||||
return Assignment{}, err
|
||||
}
|
||||
if candidate.ID == "" {
|
||||
return Assignment{}, sql.ErrNoRows
|
||||
}
|
||||
if err := s.materializeTaskInputs(ctx, laneRecord, candidate); err != nil {
|
||||
return Assignment{}, err
|
||||
}
|
||||
|
||||
resolved, err := s.resolver.ResolveRole(ctx, laneRecord.WorkspaceID, "worker")
|
||||
if err != nil {
|
||||
return Assignment{}, fmt.Errorf("resolve worker config: %w", err)
|
||||
}
|
||||
snapshot, err := resolved.SnapshotJSON()
|
||||
if err != nil {
|
||||
return Assignment{}, err
|
||||
}
|
||||
commandJSON, err := marshalCommandMetadata(commandMetadata{
|
||||
LaneID: laneID,
|
||||
TaskID: candidate.ID,
|
||||
RunnerID: strings.TrimSpace(runnerID),
|
||||
})
|
||||
if err != nil {
|
||||
return Assignment{}, err
|
||||
}
|
||||
runTemplate := workflow.Run{
|
||||
WorkspaceID: candidate.WorkspaceID,
|
||||
TopicID: candidate.TopicID,
|
||||
RoleName: "worker",
|
||||
Stage: workflow.StageExecution,
|
||||
Mode: "task",
|
||||
Status: workflow.RunStatusRunning,
|
||||
ConfigSnapshotJSON: snapshot,
|
||||
CommandJSON: commandJSON,
|
||||
}
|
||||
startedAt := timeutil.FormatRFC3339(s.clock.Now())
|
||||
run, candidate, err := s.repo.ClaimTaskExecution(ctx, runTemplate, candidate.ID, startedAt)
|
||||
if err != nil {
|
||||
return Assignment{}, err
|
||||
}
|
||||
if _, err := s.repo.AppendTaskEvent(ctx, task.Event{
|
||||
TaskID: candidate.ID,
|
||||
EventType: "started",
|
||||
BodyMarkdown: "Worker started execution.",
|
||||
CreatedByRoleName: "worker",
|
||||
CreatedAt: startedAt,
|
||||
}); err != nil {
|
||||
_, _ = s.repo.AppendWorkflowRunLog(ctx, workflow.RunLog{
|
||||
RunID: run.ID,
|
||||
Stream: workflow.LogStreamSystem,
|
||||
Content: "claim side effects failed after main state commit:\n- append started event for task " + candidate.ID + ": " + err.Error(),
|
||||
CreatedAt: startedAt,
|
||||
})
|
||||
}
|
||||
|
||||
topicRecord, err = s.repo.GetTopic(ctx, candidate.TopicID)
|
||||
if err != nil {
|
||||
return Assignment{}, err
|
||||
}
|
||||
messages, err := s.repo.ListMessagesByTopic(ctx, candidate.TopicID)
|
||||
if err != nil {
|
||||
return Assignment{}, err
|
||||
}
|
||||
|
||||
assignment := Assignment{
|
||||
Run: run,
|
||||
Lane: laneRecord,
|
||||
Task: candidate,
|
||||
Role: resolved,
|
||||
Prompt: buildWorkerPrompt(topicRecord, laneRecord, candidate, resolved, messages),
|
||||
OutputMode: "markdown",
|
||||
}
|
||||
return assignment, nil
|
||||
}
|
||||
|
||||
func (s *Service) Complete(ctx context.Context, value Completion) (workflow.Run, error) {
|
||||
if err := workflow.ValidateRunStatus(value.Status); err != nil {
|
||||
return workflow.Run{}, err
|
||||
}
|
||||
if value.Status == workflow.RunStatusRunning {
|
||||
return workflow.Run{}, fmt.Errorf("completion status must be terminal")
|
||||
}
|
||||
run, err := s.repo.GetWorkflowRun(ctx, value.RunID)
|
||||
if err != nil {
|
||||
return workflow.Run{}, err
|
||||
}
|
||||
command, err := parseCommandMetadata(run.CommandJSON)
|
||||
if err != nil {
|
||||
return workflow.Run{}, err
|
||||
}
|
||||
topicRecord, err := s.repo.GetTopic(ctx, run.TopicID)
|
||||
if err != nil {
|
||||
return workflow.Run{}, err
|
||||
}
|
||||
if strings.TrimSpace(topicRecord.Status) == "cancelled" {
|
||||
run.Status = workflow.RunStatusCancelled
|
||||
run.ExitCode = 130
|
||||
run.CompletedAt = timeutil.FormatRFC3339(s.clock.Now())
|
||||
run.ErrorMessage = "Topic was stopped before task completion could be applied."
|
||||
return s.repo.UpdateWorkflowRun(ctx, run)
|
||||
}
|
||||
|
||||
now := timeutil.FormatRFC3339(s.clock.Now())
|
||||
value.ResultMarkdown = strings.TrimSpace(value.ResultMarkdown)
|
||||
value.ErrorMessage = strings.TrimSpace(value.ErrorMessage)
|
||||
taskRecord, err := s.repo.GetTask(ctx, command.TaskID)
|
||||
if err != nil {
|
||||
return workflow.Run{}, err
|
||||
}
|
||||
laneRecord, err := s.repo.GetLane(ctx, command.LaneID)
|
||||
if err != nil {
|
||||
return workflow.Run{}, err
|
||||
}
|
||||
if value.Status == workflow.RunStatusSucceeded && shouldSnapshotTask(taskRecord) && s.snapshotter != nil {
|
||||
headCommit, snapshotErr := s.snapshotter.Capture(ctx, laneRecord, taskRecord)
|
||||
if snapshotErr != nil {
|
||||
value.Status = workflow.RunStatusFailed
|
||||
value.ExitCode = failedExitCode(value.ExitCode)
|
||||
value.ResultMarkdown = ""
|
||||
value.ErrorMessage = "Lane snapshot failed: " + strings.TrimSpace(snapshotErr.Error())
|
||||
} else if headCommit != "" && laneRecord.HeadCommit != headCommit {
|
||||
laneRecord.HeadCommit = headCommit
|
||||
if _, err := s.repo.UpdateLane(ctx, laneRecord); err != nil {
|
||||
value.Status = workflow.RunStatusFailed
|
||||
value.ExitCode = failedExitCode(value.ExitCode)
|
||||
value.ResultMarkdown = ""
|
||||
value.ErrorMessage = "Persist lane head commit: " + strings.TrimSpace(err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
updatedRun, taskRecord, chainRecord, promotedTasks, err := s.repo.CompleteTaskExecution(
|
||||
ctx,
|
||||
run.ID,
|
||||
command.TaskID,
|
||||
command.LaneID,
|
||||
value.Status,
|
||||
value.ExitCode,
|
||||
value.ResultMarkdown,
|
||||
value.ErrorMessage,
|
||||
now,
|
||||
)
|
||||
if err != nil {
|
||||
return workflow.Run{}, err
|
||||
}
|
||||
if err := s.syncTopicStatus(ctx, run.TopicID); err != nil {
|
||||
return workflow.Run{}, err
|
||||
}
|
||||
s.emitCompletionSideEffects(ctx, updatedRun, taskRecord, chainRecord, promotedTasks, value, now)
|
||||
return updatedRun, nil
|
||||
}
|
||||
|
||||
func (s *Service) AppendLog(ctx context.Context, runID string, stream workflow.LogStream, content string) (workflow.RunLog, error) {
|
||||
return s.repo.AppendWorkflowRunLog(ctx, workflow.RunLog{
|
||||
RunID: runID,
|
||||
Stream: stream,
|
||||
Content: strings.TrimSpace(content),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) nextReadyTask(ctx context.Context, items []task.Record) (task.Record, error) {
|
||||
for _, item := range items {
|
||||
if item.Status != task.StatusReady {
|
||||
continue
|
||||
}
|
||||
if item.Kind == task.KindMilestone {
|
||||
continue
|
||||
}
|
||||
ready, err := s.dependenciesSatisfied(ctx, item.ID)
|
||||
if err != nil {
|
||||
return task.Record{}, err
|
||||
}
|
||||
if ready {
|
||||
return item, nil
|
||||
}
|
||||
}
|
||||
return task.Record{}, nil
|
||||
}
|
||||
|
||||
func (s *Service) materializeTaskInputs(ctx context.Context, laneRecord lane.Record, taskRecord task.Record) error {
|
||||
if s.materializer == nil {
|
||||
return nil
|
||||
}
|
||||
upstreams, err := s.upstreamLanesForTask(ctx, laneRecord, taskRecord)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(upstreams) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := s.materializer.Materialize(ctx, laneRecord, taskRecord.ID, upstreams); err != nil {
|
||||
if blockErr := s.blockTaskForMaterializationFailure(ctx, laneRecord, taskRecord, err); blockErr != nil {
|
||||
return fmt.Errorf("%v; block task: %w", err, blockErr)
|
||||
}
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) upstreamLanesForTask(ctx context.Context, downstream lane.Record, taskRecord task.Record) ([]lanematerialize.Upstream, error) {
|
||||
deps, err := s.repo.ListTaskDependencies(ctx, taskRecord.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
seen := map[string]struct{}{}
|
||||
out := make([]lanematerialize.Upstream, 0, len(deps))
|
||||
for _, dep := range deps {
|
||||
upstreamTask, err := s.repo.GetTask(ctx, dep.DependsOnTaskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if upstreamTask.LaneID == downstream.ID {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[upstreamTask.LaneID]; ok {
|
||||
continue
|
||||
}
|
||||
upstreamLane, err := s.repo.GetLane(ctx, upstreamTask.LaneID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
seen[upstreamTask.LaneID] = struct{}{}
|
||||
out = append(out, lanematerialize.Upstream{TaskID: upstreamTask.ID, Lane: upstreamLane})
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
return out[i].Lane.ID < out[j].Lane.ID
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Service) blockTaskForMaterializationFailure(ctx context.Context, laneRecord lane.Record, taskRecord task.Record, cause error) error {
|
||||
now := timeutil.FormatRFC3339(s.clock.Now())
|
||||
reason := "Lane input materialization failed.\n\n" + strings.TrimSpace(cause.Error())
|
||||
|
||||
taskRecord.Status = task.StatusBlocked
|
||||
taskRecord.BlockingReasonMarkdown = reason
|
||||
taskRecord.ResultSummaryMarkdown = ""
|
||||
taskRecord.AssignedRunID = ""
|
||||
taskRecord.UpdatedAt = now
|
||||
taskRecord.StartedAt = ""
|
||||
if _, err := s.repo.UpdateTask(ctx, taskRecord); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
laneRecord.Status = lane.StatusBlocked
|
||||
laneRecord.ErrorMessage = strings.TrimSpace(cause.Error())
|
||||
laneRecord.UpdatedAt = now
|
||||
if _, err := s.repo.UpdateLane(ctx, laneRecord); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := s.repo.AppendTaskEvent(ctx, task.Event{
|
||||
TaskID: taskRecord.ID,
|
||||
EventType: "blocked",
|
||||
BodyMarkdown: reason,
|
||||
CreatedByRoleName: "worker",
|
||||
CreatedAt: now,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.repo.CreateMessage(ctx, message.Record{
|
||||
WorkspaceID: taskRecord.WorkspaceID,
|
||||
TopicID: taskRecord.TopicID,
|
||||
FromRoleName: "worker",
|
||||
ToExpr: "leader",
|
||||
Type: message.TypeSummary,
|
||||
Stage: string(workflow.StageExecution),
|
||||
BodyMarkdown: fmt.Sprintf("Lane `%s` blocked before task `%s` could start.\n\n%s", laneRecord.Name, taskRecord.Title, reason),
|
||||
CreatedAt: now,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.syncTopicStatus(ctx, taskRecord.TopicID)
|
||||
}
|
||||
|
||||
func shouldSnapshotTask(taskRecord task.Record) bool {
|
||||
switch taskRecord.Kind {
|
||||
case task.KindExecution, task.KindVerification:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func failedExitCode(exitCode int) int {
|
||||
if exitCode != 0 {
|
||||
return exitCode
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func (s *Service) dependenciesSatisfied(ctx context.Context, taskID string) (bool, error) {
|
||||
deps, err := s.repo.ListTaskDependencies(ctx, taskID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, dep := range deps {
|
||||
item, err := s.repo.GetTask(ctx, dep.DependsOnTaskID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if item.Status != task.StatusSucceeded {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func hasRunningTask(items []task.Record) bool {
|
||||
for _, item := range items {
|
||||
if item.Status == task.StatusRunning {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Service) emitCompletionSideEffects(
|
||||
ctx context.Context,
|
||||
run workflow.Run,
|
||||
taskRecord task.Record,
|
||||
chainRecord lane.Record,
|
||||
promotedTasks []task.Record,
|
||||
value Completion,
|
||||
now string,
|
||||
) {
|
||||
var failures []string
|
||||
|
||||
eventType := "completed"
|
||||
eventBody := taskRecord.ResultSummaryMarkdown
|
||||
if taskRecord.Status == task.StatusFailed {
|
||||
eventType = "failed"
|
||||
eventBody = taskRecord.BlockingReasonMarkdown
|
||||
}
|
||||
if _, err := s.repo.AppendTaskEvent(ctx, task.Event{
|
||||
TaskID: taskRecord.ID,
|
||||
EventType: eventType,
|
||||
BodyMarkdown: eventBody,
|
||||
CreatedByRoleName: "worker",
|
||||
CreatedAt: now,
|
||||
}); err != nil {
|
||||
failures = append(failures, fmt.Sprintf("append %s event for task %s: %v", eventType, taskRecord.ID, err))
|
||||
}
|
||||
|
||||
for _, item := range promotedTasks {
|
||||
if _, err := s.repo.AppendTaskEvent(ctx, task.Event{
|
||||
TaskID: item.ID,
|
||||
EventType: "ready",
|
||||
BodyMarkdown: "Dependencies satisfied. Task is ready for execution.",
|
||||
CreatedByRoleName: "leader",
|
||||
CreatedAt: now,
|
||||
}); err != nil {
|
||||
failures = append(failures, fmt.Sprintf("append ready event for task %s: %v", item.ID, err))
|
||||
}
|
||||
}
|
||||
|
||||
topicRecord, err := s.repo.GetTopic(ctx, taskRecord.TopicID)
|
||||
if err == nil && strings.TrimSpace(topicRecord.Status) == "cancelled" {
|
||||
if len(failures) == 0 {
|
||||
return
|
||||
}
|
||||
_, _ = s.repo.AppendWorkflowRunLog(ctx, workflow.RunLog{
|
||||
RunID: run.ID,
|
||||
Stream: workflow.LogStreamSystem,
|
||||
Content: "completion side effects failed after main state commit:\n- " + strings.Join(failures, "\n- "),
|
||||
CreatedAt: now,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := s.repo.CreateMessage(ctx, message.Record{
|
||||
WorkspaceID: taskRecord.WorkspaceID,
|
||||
TopicID: taskRecord.TopicID,
|
||||
FromRoleName: "worker",
|
||||
ToExpr: "leader",
|
||||
Type: message.TypeSummary,
|
||||
Stage: string(workflow.StageExecution),
|
||||
BodyMarkdown: workerSummaryMarkdown(chainRecord, taskRecord, value),
|
||||
ReplyToMessageID: "",
|
||||
CreatedAt: now,
|
||||
}); err != nil {
|
||||
failures = append(failures, fmt.Sprintf("create summary message for run %s: %v", run.ID, err))
|
||||
}
|
||||
|
||||
if len(failures) == 0 {
|
||||
return
|
||||
}
|
||||
_, _ = s.repo.AppendWorkflowRunLog(ctx, workflow.RunLog{
|
||||
RunID: run.ID,
|
||||
Stream: workflow.LogStreamSystem,
|
||||
Content: "completion side effects failed after main state commit:\n- " + strings.Join(failures, "\n- "),
|
||||
CreatedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) syncTopicStatus(ctx context.Context, topicID string) error {
|
||||
record, err := s.repo.GetTopic(ctx, topicID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch strings.TrimSpace(record.Status) {
|
||||
case "cancelled", "awaiting_confirmation":
|
||||
return nil
|
||||
}
|
||||
tasks, err := s.repo.ListTasksByTopic(ctx, topicID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
chains, err := s.repo.ListLanesByTopic(ctx, topicID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
nextStatus := "execution"
|
||||
if len(tasks) > 0 && graphCompleted(tasks, chains) {
|
||||
nextStatus = "completed"
|
||||
} else if graphBlocked(tasks, chains) {
|
||||
nextStatus = "blocked"
|
||||
}
|
||||
if record.Status == nextStatus {
|
||||
return nil
|
||||
}
|
||||
record.Status = nextStatus
|
||||
if nextStatus == "completed" && record.ClosedAt == "" {
|
||||
record.ClosedAt = timeutil.FormatRFC3339(s.clock.Now())
|
||||
}
|
||||
if _, err := s.repo.UpdateTopic(ctx, record); err != nil {
|
||||
return err
|
||||
}
|
||||
if nextStatus == "completed" && s.runtime != nil {
|
||||
s.releaseTopicLaneRuntimes(ctx, chains)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) releaseTopicLaneRuntimes(ctx context.Context, lanes []lane.Record) {
|
||||
for _, item := range lanes {
|
||||
if strings.TrimSpace(item.ContainerName) == "" && strings.TrimSpace(item.RuntimeEndpoint) == "" {
|
||||
continue
|
||||
}
|
||||
_, _ = s.runtime.ReleaseLaneRuntime(ctx, item.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func graphBlocked(tasks []task.Record, chains []lane.Record) bool {
|
||||
for _, item := range tasks {
|
||||
if item.Status == task.StatusFailed || item.Status == task.StatusBlocked {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, item := range chains {
|
||||
if item.Status == lane.StatusBlocked || item.Status == lane.StatusFailed {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func graphCompleted(tasks []task.Record, chains []lane.Record) bool {
|
||||
hasTasks := false
|
||||
for _, item := range tasks {
|
||||
hasTasks = true
|
||||
switch item.Status {
|
||||
case task.StatusSucceeded, task.StatusCancelled:
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
if !hasTasks {
|
||||
return false
|
||||
}
|
||||
for _, item := range chains {
|
||||
switch item.Status {
|
||||
case lane.StatusSucceeded, lane.StatusCancelled:
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func workerSummaryMarkdown(chainRecord lane.Record, taskRecord task.Record, value Completion) string {
|
||||
if value.Status == workflow.RunStatusSucceeded {
|
||||
body := strings.TrimSpace(value.ResultMarkdown)
|
||||
if body == "" {
|
||||
body = "Task completed without an explicit summary."
|
||||
}
|
||||
return fmt.Sprintf("Lane `%s` completed task `%s`.\n\n%s", chainRecord.Name, taskRecord.Title, body)
|
||||
}
|
||||
body := strings.TrimSpace(value.ErrorMessage)
|
||||
if body == "" {
|
||||
body = "Task failed without an explicit error message."
|
||||
}
|
||||
return fmt.Sprintf("Lane `%s` failed task `%s`.\n\n%s", chainRecord.Name, taskRecord.Title, body)
|
||||
}
|
||||
|
||||
func buildWorkerPrompt(topicRecord topic.Record, chainRecord lane.Record, taskRecord task.Record, resolved runtimeconfig.ResolvedRole, messages []message.Record) string {
|
||||
var builder strings.Builder
|
||||
builder.WriteString("## Role\n")
|
||||
builder.WriteString(runtimeconfig.RenderInstructions(resolved, ""))
|
||||
builder.WriteString("\n")
|
||||
builder.WriteString("\n## Execution Context\n")
|
||||
builder.WriteString(fmt.Sprintf("- topic: %s\n- lane: %s\n- task: %s\n- task kind: %s\n- branch: %s\n", topicRecord.Slug, chainRecord.Name, taskRecord.Title, taskRecord.Kind, chainRecord.BranchName))
|
||||
builder.WriteString("\n## Task\n")
|
||||
builder.WriteString(taskRecord.BodyMarkdown)
|
||||
if strings.TrimSpace(taskRecord.AcceptanceMarkdown) != "" {
|
||||
builder.WriteString("\n\n## Acceptance\n")
|
||||
builder.WriteString(taskRecord.AcceptanceMarkdown)
|
||||
}
|
||||
if len(messages) > 0 {
|
||||
builder.WriteString("\n## Recent Messages\n")
|
||||
start := 0
|
||||
if len(messages) > 6 {
|
||||
start = len(messages) - 6
|
||||
}
|
||||
for _, item := range messages[start:] {
|
||||
builder.WriteString(fmt.Sprintf("### %s -> %s (%s)\n%s\n", item.FromRoleName, item.ToExpr, item.Stage, item.BodyMarkdown))
|
||||
}
|
||||
}
|
||||
builder.WriteString("\n## Blockers\n")
|
||||
builder.WriteString("If you are blocked, send exactly one concrete question to the leader via the inbox skill with the current execution stage, then stop and make the final markdown summary describe the blocker and the decision you need.\n")
|
||||
builder.WriteString("\nReturn a concise markdown execution summary suitable to send back to the leader. Do not add front matter.\n")
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func marshalCommandMetadata(value commandMetadata) (string, error) {
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal task command metadata: %w", err)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func parseCommandMetadata(value string) (commandMetadata, error) {
|
||||
var item commandMetadata
|
||||
if err := json.Unmarshal([]byte(value), &item); err != nil {
|
||||
return commandMetadata{}, fmt.Errorf("decode task command metadata: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(item.TaskID) == "" {
|
||||
return commandMetadata{}, fmt.Errorf("task command metadata missing task id")
|
||||
}
|
||||
if strings.TrimSpace(item.LaneID) == "" {
|
||||
return commandMetadata{}, fmt.Errorf("task command metadata missing lane id")
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
@@ -0,0 +1,819 @@
|
||||
package taskexec
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"inbox/internal/app/lanematerialize"
|
||||
"inbox/internal/app/runtimeconfig"
|
||||
"inbox/internal/base/timeutil"
|
||||
"inbox/internal/domain/lane"
|
||||
"inbox/internal/domain/message"
|
||||
"inbox/internal/domain/task"
|
||||
"inbox/internal/domain/topic"
|
||||
"inbox/internal/domain/workflow"
|
||||
sqlitestore "inbox/internal/store/sqlite"
|
||||
)
|
||||
|
||||
func TestCompletePromotesDependentTaskAndNotifiesLeader(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
clock := timeutil.FixedClock{Time: time.Date(2026, 3, 16, 10, 0, 0, 0, time.UTC)}
|
||||
store, err := sqlitestore.OpenInMemory(clock)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenInMemory() error = %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
_, chainRecord := seedTaskExecGraph(t, ctx, store)
|
||||
svc := NewService(store, newResolvedRoleResolver(store, clock), clock, WithSnapshotter(noopSnapshotter{}), WithMaterializer(noopMaterializer{}))
|
||||
|
||||
assignment, err := svc.ClaimNext(ctx, chainRecord.ID, "runner-1")
|
||||
if err != nil {
|
||||
t.Fatalf("ClaimNext() error = %v", err)
|
||||
}
|
||||
if assignment.Task.Title != "Task A" {
|
||||
t.Fatalf("expected Task A to be claimed first, got %#v", assignment.Task)
|
||||
}
|
||||
if !strings.Contains(assignment.Prompt, "Task A") {
|
||||
t.Fatalf("expected task prompt to include task title, got %q", assignment.Prompt)
|
||||
}
|
||||
if !strings.Contains(assignment.Prompt, "## Skills") || !strings.Contains(assignment.Prompt, "Use Inbox V2") {
|
||||
t.Fatalf("expected task prompt to include bound skills, got %q", assignment.Prompt)
|
||||
}
|
||||
|
||||
updatedRun, err := svc.Complete(ctx, Completion{
|
||||
RunID: assignment.Run.ID,
|
||||
Status: workflow.RunStatusSucceeded,
|
||||
ExitCode: 0,
|
||||
ResultMarkdown: "Implemented Task A.",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Complete() error = %v", err)
|
||||
}
|
||||
if updatedRun.Status != workflow.RunStatusSucceeded {
|
||||
t.Fatalf("unexpected run status: %#v", updatedRun)
|
||||
}
|
||||
|
||||
tasks, err := store.ListTasksByLane(ctx, chainRecord.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListTasksByLane() error = %v", err)
|
||||
}
|
||||
byTitle := make(map[string]task.Record, len(tasks))
|
||||
for _, item := range tasks {
|
||||
byTitle[item.Title] = item
|
||||
}
|
||||
if byTitle["Task A"].Status != task.StatusSucceeded {
|
||||
t.Fatalf("expected Task A succeeded, got %#v", byTitle["Task A"])
|
||||
}
|
||||
if byTitle["Task B"].Status != task.StatusReady {
|
||||
t.Fatalf("expected Task B promoted to ready, got %#v", byTitle["Task B"])
|
||||
}
|
||||
|
||||
messages, err := store.ListMessagesByTopic(ctx, chainRecord.TopicID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListMessagesByTopic() error = %v", err)
|
||||
}
|
||||
var workerSummary *message.Record
|
||||
for _, item := range messages {
|
||||
if item.FromRoleName == "worker" && item.ToExpr == "leader" {
|
||||
workerSummary = &item
|
||||
}
|
||||
}
|
||||
if workerSummary == nil {
|
||||
t.Fatalf("expected worker summary message, got %#v", messages)
|
||||
}
|
||||
if !strings.Contains(workerSummary.BodyMarkdown, "Implemented Task A.") {
|
||||
t.Fatalf("unexpected worker summary body: %q", workerSummary.BodyMarkdown)
|
||||
}
|
||||
|
||||
chainState, err := store.GetLane(ctx, chainRecord.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLane() error = %v", err)
|
||||
}
|
||||
if chainState.Status != lane.StatusReady {
|
||||
t.Fatalf("expected chain to stay ready for next task, got %#v", chainState)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteAutoCompletesMilestoneAndPromotesDownstreamTask(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
clock := timeutil.FixedClock{Time: time.Date(2026, 3, 16, 11, 0, 0, 0, time.UTC)}
|
||||
store, err := sqlitestore.OpenInMemory(clock)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenInMemory() error = %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
ws, chainRecord := seedTaskExecGraph(t, ctx, store)
|
||||
items, err := store.ListTasksByLane(ctx, chainRecord.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListTasksByLane() error = %v", err)
|
||||
}
|
||||
var taskAID string
|
||||
for _, item := range items {
|
||||
if item.Title == "Task A" {
|
||||
taskAID = item.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if taskAID == "" {
|
||||
t.Fatal("expected Task A in seed graph")
|
||||
}
|
||||
milestone, err := store.CreateTask(ctx, task.Record{
|
||||
WorkspaceID: ws.ID,
|
||||
TopicID: chainRecord.TopicID,
|
||||
LaneID: chainRecord.ID,
|
||||
Title: "Ready for Verification",
|
||||
BodyMarkdown: "Milestone node.",
|
||||
Kind: task.KindMilestone,
|
||||
Status: task.StatusDraft,
|
||||
Priority: 8,
|
||||
TaskOrder: 3,
|
||||
CreatedByRoleName: "leader",
|
||||
}, []task.Dependency{{DependsOnTaskID: taskAID}})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTask(milestone) error = %v", err)
|
||||
}
|
||||
if _, err := store.CreateTask(ctx, task.Record{
|
||||
WorkspaceID: ws.ID,
|
||||
TopicID: chainRecord.TopicID,
|
||||
LaneID: chainRecord.ID,
|
||||
Title: "Task C",
|
||||
BodyMarkdown: "Run final verification.",
|
||||
Kind: task.KindVerification,
|
||||
Status: task.StatusDraft,
|
||||
Priority: 7,
|
||||
TaskOrder: 4,
|
||||
CreatedByRoleName: "leader",
|
||||
}, []task.Dependency{{DependsOnTaskID: milestone.ID}}); err != nil {
|
||||
t.Fatalf("CreateTask(task C) error = %v", err)
|
||||
}
|
||||
|
||||
svc := NewService(store, newResolvedRoleResolver(store, clock), clock, WithSnapshotter(noopSnapshotter{}), WithMaterializer(noopMaterializer{}))
|
||||
assignment, err := svc.ClaimNext(ctx, chainRecord.ID, "runner-1")
|
||||
if err != nil {
|
||||
t.Fatalf("ClaimNext() error = %v", err)
|
||||
}
|
||||
if assignment.Task.Title != "Task A" {
|
||||
t.Fatalf("expected Task A first, got %#v", assignment.Task)
|
||||
}
|
||||
|
||||
if _, err := svc.Complete(ctx, Completion{
|
||||
RunID: assignment.Run.ID,
|
||||
Status: workflow.RunStatusSucceeded,
|
||||
ExitCode: 0,
|
||||
ResultMarkdown: "Implemented Task A.",
|
||||
}); err != nil {
|
||||
t.Fatalf("Complete() error = %v", err)
|
||||
}
|
||||
|
||||
items, err = store.ListTasksByLane(ctx, chainRecord.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListTasksByLane() error = %v", err)
|
||||
}
|
||||
byTitle := make(map[string]task.Record, len(items))
|
||||
for _, item := range items {
|
||||
byTitle[item.Title] = item
|
||||
}
|
||||
if byTitle["Ready for Verification"].Status != task.StatusSucceeded {
|
||||
t.Fatalf("expected milestone auto-succeeded, got %#v", byTitle["Ready for Verification"])
|
||||
}
|
||||
if byTitle["Task C"].Status != task.StatusReady {
|
||||
t.Fatalf("expected downstream task promoted after milestone, got %#v", byTitle["Task C"])
|
||||
}
|
||||
next, err := svc.ClaimNext(ctx, chainRecord.ID, "runner-2")
|
||||
if err != nil {
|
||||
t.Fatalf("ClaimNext(second) error = %v", err)
|
||||
}
|
||||
if next.Task.Title != "Task B" {
|
||||
t.Fatalf("expected Task B next due ordering before Task C, got %#v", next.Task)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimNextCommitsMainStateWhenStartedEventFails(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
clock := timeutil.FixedClock{Time: time.Date(2026, 3, 16, 10, 0, 0, 0, time.UTC)}
|
||||
store, err := sqlitestore.OpenInMemory(clock)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenInMemory() error = %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
_, chainRecord := seedTaskExecGraph(t, ctx, store)
|
||||
repo := &flakyTaskExecRepo{
|
||||
Store: store,
|
||||
failAppendTaskEvent: true,
|
||||
}
|
||||
svc := NewService(repo, newResolvedRoleResolver(store, clock), clock, WithSnapshotter(noopSnapshotter{}), WithMaterializer(noopMaterializer{}))
|
||||
|
||||
assignment, err := svc.ClaimNext(ctx, chainRecord.ID, "runner-1")
|
||||
if err != nil {
|
||||
t.Fatalf("ClaimNext() should succeed even if started event fails, got %v", err)
|
||||
}
|
||||
if assignment.Run.Status != workflow.RunStatusRunning {
|
||||
t.Fatalf("expected running run, got %#v", assignment.Run)
|
||||
}
|
||||
if assignment.Task.Status != task.StatusRunning {
|
||||
t.Fatalf("expected running task, got %#v", assignment.Task)
|
||||
}
|
||||
|
||||
runs, err := store.ListWorkflowRunsByTopic(ctx, chainRecord.TopicID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListWorkflowRunsByTopic() error = %v", err)
|
||||
}
|
||||
if len(runs) != 1 || runs[0].ID != assignment.Run.ID || runs[0].Status != workflow.RunStatusRunning {
|
||||
t.Fatalf("unexpected workflow runs after claim: %#v", runs)
|
||||
}
|
||||
|
||||
persistedTask, err := store.GetTask(ctx, assignment.Task.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetTask() error = %v", err)
|
||||
}
|
||||
if persistedTask.Status != task.StatusRunning || persistedTask.AssignedRunID != assignment.Run.ID {
|
||||
t.Fatalf("expected persisted task running with assigned run, got %#v", persistedTask)
|
||||
}
|
||||
|
||||
events, err := store.ListTaskEvents(ctx, assignment.Task.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListTaskEvents() error = %v", err)
|
||||
}
|
||||
for _, item := range events {
|
||||
if item.EventType == "started" {
|
||||
t.Fatalf("expected no started event when side effect fails, got %#v", events)
|
||||
}
|
||||
}
|
||||
|
||||
logs, err := store.ListWorkflowRunLogs(ctx, assignment.Run.ID, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("ListWorkflowRunLogs() error = %v", err)
|
||||
}
|
||||
if len(logs) == 0 {
|
||||
t.Fatalf("expected warning log after started-event failure")
|
||||
}
|
||||
if !strings.Contains(logs[len(logs)-1].Content, "claim side effects failed after main state commit") {
|
||||
t.Fatalf("unexpected warning log: %#v", logs[len(logs)-1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteCommitsMainStateWhenNotificationSideEffectsFail(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
clock := timeutil.FixedClock{Time: time.Date(2026, 3, 16, 10, 0, 0, 0, time.UTC)}
|
||||
store, err := sqlitestore.OpenInMemory(clock)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenInMemory() error = %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
_, chainRecord := seedTaskExecGraph(t, ctx, store)
|
||||
repo := &flakyTaskExecRepo{
|
||||
Store: store,
|
||||
failCreateMessage: true,
|
||||
}
|
||||
svc := NewService(repo, newResolvedRoleResolver(store, clock), clock, WithSnapshotter(noopSnapshotter{}), WithMaterializer(noopMaterializer{}))
|
||||
|
||||
assignment, err := svc.ClaimNext(ctx, chainRecord.ID, "runner-1")
|
||||
if err != nil {
|
||||
t.Fatalf("ClaimNext() error = %v", err)
|
||||
}
|
||||
|
||||
updatedRun, err := svc.Complete(ctx, Completion{
|
||||
RunID: assignment.Run.ID,
|
||||
Status: workflow.RunStatusSucceeded,
|
||||
ExitCode: 0,
|
||||
ResultMarkdown: "Implemented Task A.",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Complete() should succeed even if side effects fail, got %v", err)
|
||||
}
|
||||
if updatedRun.Status != workflow.RunStatusSucceeded {
|
||||
t.Fatalf("unexpected run status: %#v", updatedRun)
|
||||
}
|
||||
|
||||
tasks, err := store.ListTasksByLane(ctx, chainRecord.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListTasksByLane() error = %v", err)
|
||||
}
|
||||
byTitle := make(map[string]task.Record, len(tasks))
|
||||
for _, item := range tasks {
|
||||
byTitle[item.Title] = item
|
||||
}
|
||||
if byTitle["Task A"].Status != task.StatusSucceeded {
|
||||
t.Fatalf("expected Task A succeeded, got %#v", byTitle["Task A"])
|
||||
}
|
||||
if byTitle["Task B"].Status != task.StatusReady {
|
||||
t.Fatalf("expected Task B promoted to ready, got %#v", byTitle["Task B"])
|
||||
}
|
||||
|
||||
chainState, err := store.GetLane(ctx, chainRecord.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLane() error = %v", err)
|
||||
}
|
||||
if chainState.Status != lane.StatusReady {
|
||||
t.Fatalf("expected chain ready after commit, got %#v", chainState)
|
||||
}
|
||||
|
||||
messages, err := store.ListMessagesByTopic(ctx, chainRecord.TopicID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListMessagesByTopic() error = %v", err)
|
||||
}
|
||||
for _, item := range messages {
|
||||
if item.FromRoleName == "worker" && item.ToExpr == "leader" {
|
||||
t.Fatalf("expected no worker summary message when notification fails, got %#v", item)
|
||||
}
|
||||
}
|
||||
|
||||
logs, err := store.ListWorkflowRunLogs(ctx, assignment.Run.ID, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("ListWorkflowRunLogs() error = %v", err)
|
||||
}
|
||||
if len(logs) == 0 {
|
||||
t.Fatalf("expected warning log after side-effect failure")
|
||||
}
|
||||
if !strings.Contains(logs[len(logs)-1].Content, "completion side effects failed after main state commit") {
|
||||
t.Fatalf("unexpected warning log: %#v", logs[len(logs)-1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteSnapshotsLaneHeadCommit(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
clock := timeutil.FixedClock{Time: time.Date(2026, 3, 16, 12, 0, 0, 0, time.UTC)}
|
||||
store, err := sqlitestore.OpenInMemory(clock)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenInMemory() error = %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
repoDir := createGitRepo(t)
|
||||
initialHead := gitHead(t, repoDir)
|
||||
seedTaskExecRepoGraph(t, ctx, store, repoDir, initialHead)
|
||||
|
||||
svc := NewService(store, newResolvedRoleResolver(store, clock), clock, WithMaterializer(noopMaterializer{}))
|
||||
assignment, err := svc.ClaimNext(ctx, "chain_1", "runner-1")
|
||||
if err != nil {
|
||||
t.Fatalf("ClaimNext() error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(repoDir, "todo.txt"), []byte("hello lane snapshot\n"), 0644); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
if _, err := svc.Complete(ctx, Completion{
|
||||
RunID: assignment.Run.ID,
|
||||
Status: workflow.RunStatusSucceeded,
|
||||
ExitCode: 0,
|
||||
ResultMarkdown: "Implemented Task A.",
|
||||
}); err != nil {
|
||||
t.Fatalf("Complete() error = %v", err)
|
||||
}
|
||||
|
||||
laneRecord, err := store.GetLane(ctx, "chain_1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetLane() error = %v", err)
|
||||
}
|
||||
if laneRecord.HeadCommit == "" || laneRecord.HeadCommit == initialHead {
|
||||
t.Fatalf("expected updated lane head commit, got %#v", laneRecord)
|
||||
}
|
||||
if status := gitStatusShort(t, repoDir); strings.TrimSpace(status) != "" {
|
||||
t.Fatalf("expected clean repo after snapshot, got %q", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimNextMaterializesUpstreamLaneCommit(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
clock := timeutil.FixedClock{Time: time.Date(2026, 3, 16, 13, 0, 0, 0, time.UTC)}
|
||||
store, err := sqlitestore.OpenInMemory(clock)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenInMemory() error = %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
rootRepo := createGitRepo(t)
|
||||
runGit(t, rootRepo, "checkout", "-b", "upstream")
|
||||
if err := os.WriteFile(filepath.Join(rootRepo, "backend.txt"), []byte("backend\n"), 0644); err != nil {
|
||||
t.Fatalf("WriteFile(upstream) error = %v", err)
|
||||
}
|
||||
runGit(t, rootRepo, "add", "-A")
|
||||
runGit(t, rootRepo, "commit", "-m", "upstream change")
|
||||
upstreamHead := gitHead(t, rootRepo)
|
||||
runGit(t, rootRepo, "checkout", "main")
|
||||
|
||||
downstreamDir := filepath.Join(t.TempDir(), "downstream")
|
||||
runGit(t, rootRepo, "worktree", "add", "-b", "downstream", downstreamDir, "main")
|
||||
seedMaterializationGraph(t, ctx, store, rootRepo, downstreamDir, upstreamHead)
|
||||
|
||||
svc := NewService(store, newResolvedRoleResolver(store, clock), clock, WithSnapshotter(noopSnapshotter{}))
|
||||
assignment, err := svc.ClaimNext(ctx, "lane_downstream", "runner-1")
|
||||
if err != nil {
|
||||
t.Fatalf("ClaimNext() error = %v", err)
|
||||
}
|
||||
if assignment.Task.ID != "task_downstream" {
|
||||
t.Fatalf("unexpected task claimed: %#v", assignment.Task)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(downstreamDir, "backend.txt")); err != nil {
|
||||
t.Fatalf("expected upstream file materialized into downstream lane: %v", err)
|
||||
}
|
||||
|
||||
var count int
|
||||
if err := store.DB().QueryRow(`SELECT COUNT(*) FROM lane_syncs WHERE downstream_lane_id = ? AND upstream_lane_id = ? AND status = ?`,
|
||||
"lane_downstream", "lane_upstream", "applied").Scan(&count); err != nil {
|
||||
t.Fatalf("count lane_syncs: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("expected one applied lane sync, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimNextBlocksTaskWhenUpstreamLaneHasNoHeadCommit(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
clock := timeutil.FixedClock{Time: time.Date(2026, 3, 16, 14, 0, 0, 0, time.UTC)}
|
||||
store, err := sqlitestore.OpenInMemory(clock)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenInMemory() error = %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
repoDir := createGitRepo(t)
|
||||
downstreamDir := filepath.Join(t.TempDir(), "downstream")
|
||||
runGit(t, repoDir, "worktree", "add", "-b", "downstream", downstreamDir, "main")
|
||||
seedMaterializationGraph(t, ctx, store, repoDir, downstreamDir, "")
|
||||
|
||||
svc := NewService(store, newResolvedRoleResolver(store, clock), clock, WithSnapshotter(noopSnapshotter{}))
|
||||
assignment, err := svc.ClaimNext(ctx, "lane_downstream", "runner-1")
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
t.Fatalf("expected sql.ErrNoRows after block, got assignment=%#v err=%v", assignment, err)
|
||||
}
|
||||
|
||||
taskRecord, err := store.GetTask(ctx, "task_downstream")
|
||||
if err != nil {
|
||||
t.Fatalf("GetTask() error = %v", err)
|
||||
}
|
||||
if taskRecord.Status != task.StatusBlocked {
|
||||
t.Fatalf("expected blocked task, got %#v", taskRecord)
|
||||
}
|
||||
laneRecord, err := store.GetLane(ctx, "lane_downstream")
|
||||
if err != nil {
|
||||
t.Fatalf("GetLane() error = %v", err)
|
||||
}
|
||||
if laneRecord.Status != lane.StatusBlocked {
|
||||
t.Fatalf("expected blocked lane, got %#v", laneRecord)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteReleasesLaneRuntimeWhenTopicCompletes(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
clock := timeutil.FixedClock{Time: time.Date(2026, 3, 16, 15, 0, 0, 0, time.UTC)}
|
||||
store, err := sqlitestore.OpenInMemory(clock)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenInMemory() error = %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
_, chainRecord := seedTaskExecGraph(t, ctx, store)
|
||||
releaser := &recordingLaneRuntimeReleaser{}
|
||||
svc := NewService(
|
||||
store,
|
||||
newResolvedRoleResolver(store, clock),
|
||||
clock,
|
||||
WithSnapshotter(noopSnapshotter{}),
|
||||
WithMaterializer(noopMaterializer{}),
|
||||
WithLaneRuntimeReleaser(releaser),
|
||||
)
|
||||
|
||||
first, err := svc.ClaimNext(ctx, chainRecord.ID, "runner-1")
|
||||
if err != nil {
|
||||
t.Fatalf("ClaimNext(first) error = %v", err)
|
||||
}
|
||||
if _, err := svc.Complete(ctx, Completion{
|
||||
RunID: first.Run.ID,
|
||||
Status: workflow.RunStatusSucceeded,
|
||||
ExitCode: 0,
|
||||
ResultMarkdown: "Implemented Task A.",
|
||||
}); err != nil {
|
||||
t.Fatalf("Complete(first) error = %v", err)
|
||||
}
|
||||
|
||||
second, err := svc.ClaimNext(ctx, chainRecord.ID, "runner-2")
|
||||
if err != nil {
|
||||
t.Fatalf("ClaimNext(second) error = %v", err)
|
||||
}
|
||||
if second.Task.Title != "Task B" {
|
||||
t.Fatalf("expected Task B second, got %#v", second.Task)
|
||||
}
|
||||
if _, err := svc.Complete(ctx, Completion{
|
||||
RunID: second.Run.ID,
|
||||
Status: workflow.RunStatusSucceeded,
|
||||
ExitCode: 0,
|
||||
ResultMarkdown: "Implemented Task B.",
|
||||
}); err != nil {
|
||||
t.Fatalf("Complete(second) error = %v", err)
|
||||
}
|
||||
|
||||
topicRecord, err := store.GetTopic(ctx, chainRecord.TopicID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetTopic() error = %v", err)
|
||||
}
|
||||
if topicRecord.Status != "completed" {
|
||||
t.Fatalf("expected completed topic, got %#v", topicRecord)
|
||||
}
|
||||
if len(releaser.laneIDs) != 1 || releaser.laneIDs[0] != chainRecord.ID {
|
||||
t.Fatalf("expected runtime release for completed lane, got %#v", releaser.laneIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func seedTaskExecGraph(t *testing.T, ctx context.Context, store *sqlitestore.Store) (roleWorkspace, lane.Record) {
|
||||
t.Helper()
|
||||
now := timeutil.FormatRFC3339(time.Date(2026, 3, 16, 10, 0, 0, 0, time.UTC))
|
||||
if _, err := store.DB().Exec(`
|
||||
INSERT INTO projects(id, slug, name, root_path, default_branch, status, created_at, updated_at)
|
||||
VALUES('proj_1', 'proj', 'Project', '/tmp/project', 'main', 'active', ?, ?)
|
||||
`, now, now); err != nil {
|
||||
t.Fatalf("insert project: %v", err)
|
||||
}
|
||||
if _, err := store.DB().Exec(`
|
||||
INSERT INTO workspaces(id, project_id, slug, name, root_path, base_branch, worktree_branch, runtime_backend, status, created_at, updated_at)
|
||||
VALUES('ws_1', 'proj_1', 'main', 'Main', '/tmp/workspace', 'main', 'worktree/main', 'host', 'active', ?, ?)
|
||||
`, now, now); err != nil {
|
||||
t.Fatalf("insert workspace: %v", err)
|
||||
}
|
||||
if _, err := store.DB().Exec(`
|
||||
INSERT INTO topics(id, workspace_id, slug, title, space, status, created_at, updated_at)
|
||||
VALUES('topic_1', 'ws_1', 'sample', 'Sample', ?, 'execution', ?, ?)
|
||||
`, string(topic.SpaceWorkflow), now, now); err != nil {
|
||||
t.Fatalf("insert topic: %v", err)
|
||||
}
|
||||
if _, err := store.CreateLane(ctx, lane.Record{
|
||||
ID: "chain_1",
|
||||
WorkspaceID: "ws_1",
|
||||
TopicID: "topic_1",
|
||||
Name: "Backend Chain",
|
||||
Slug: "backend-chain",
|
||||
Status: lane.StatusReady,
|
||||
BaseBranch: "main",
|
||||
BranchName: "lane/main/backend-lane",
|
||||
WorktreePath: "/tmp/workspace--backend-chain",
|
||||
ContainerName: "lane-main-backend-lane",
|
||||
CreatedByRoleName: "leader",
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateLane() error = %v", err)
|
||||
}
|
||||
taskA, err := store.CreateTask(ctx, task.Record{
|
||||
WorkspaceID: "ws_1",
|
||||
TopicID: "topic_1",
|
||||
LaneID: "chain_1",
|
||||
Title: "Task A",
|
||||
BodyMarkdown: "Implement backend changes.",
|
||||
Status: task.StatusReady,
|
||||
TaskOrder: 1,
|
||||
Priority: 10,
|
||||
CreatedByRoleName: "leader",
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTask(Task A) error = %v", err)
|
||||
}
|
||||
if _, err := store.CreateTask(ctx, task.Record{
|
||||
WorkspaceID: "ws_1",
|
||||
TopicID: "topic_1",
|
||||
LaneID: "chain_1",
|
||||
Title: "Task B",
|
||||
BodyMarkdown: "Add verification.",
|
||||
Status: task.StatusDraft,
|
||||
TaskOrder: 2,
|
||||
Priority: 5,
|
||||
CreatedByRoleName: "leader",
|
||||
}, []task.Dependency{{DependsOnTaskID: taskA.ID}}); err != nil {
|
||||
t.Fatalf("CreateTask(Task B) error = %v", err)
|
||||
}
|
||||
return roleWorkspace{ID: "ws_1"}, lane.Record{ID: "chain_1", TopicID: "topic_1"}
|
||||
}
|
||||
|
||||
func seedTaskExecRepoGraph(t *testing.T, ctx context.Context, store *sqlitestore.Store, repoDir, headCommit string) {
|
||||
t.Helper()
|
||||
now := timeutil.FormatRFC3339(time.Date(2026, 3, 16, 12, 0, 0, 0, time.UTC))
|
||||
mustExec(t, store, `
|
||||
INSERT INTO projects(id, slug, name, root_path, default_branch, status, created_at, updated_at)
|
||||
VALUES('proj_git', 'proj-git', 'Project Git', ?, 'main', 'active', ?, ?)
|
||||
`, repoDir, now, now)
|
||||
mustExec(t, store, `
|
||||
INSERT INTO workspaces(id, project_id, slug, name, root_path, base_branch, worktree_branch, runtime_backend, status, created_at, updated_at)
|
||||
VALUES('ws_git', 'proj_git', 'main', 'Main', ?, 'main', 'worktree/main', 'host', 'active', ?, ?)
|
||||
`, repoDir, now, now)
|
||||
mustExec(t, store, `
|
||||
INSERT INTO topics(id, workspace_id, slug, title, space, status, created_at, updated_at)
|
||||
VALUES('topic_git', 'ws_git', 'sample-git', 'Sample Git', ?, 'execution', ?, ?)
|
||||
`, string(topic.SpaceWorkflow), now, now)
|
||||
if _, err := store.CreateLane(ctx, lane.Record{
|
||||
ID: "chain_1",
|
||||
WorkspaceID: "ws_git",
|
||||
TopicID: "topic_git",
|
||||
Name: "Snapshot Lane",
|
||||
Slug: "snapshot-lane",
|
||||
Status: lane.StatusReady,
|
||||
BaseBranch: "main",
|
||||
BranchName: "main",
|
||||
HeadCommit: headCommit,
|
||||
WorktreePath: repoDir,
|
||||
ContainerName: "lane-snapshot",
|
||||
CreatedByRoleName: "leader",
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateLane(snapshot) error = %v", err)
|
||||
}
|
||||
if _, err := store.CreateTask(ctx, task.Record{
|
||||
ID: "task_snapshot",
|
||||
WorkspaceID: "ws_git",
|
||||
TopicID: "topic_git",
|
||||
LaneID: "chain_1",
|
||||
Title: "Task A",
|
||||
BodyMarkdown: "Implement backend changes.",
|
||||
Status: task.StatusReady,
|
||||
TaskOrder: 1,
|
||||
Priority: 10,
|
||||
CreatedByRoleName: "leader",
|
||||
}, nil); err != nil {
|
||||
t.Fatalf("CreateTask(snapshot) error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func seedMaterializationGraph(t *testing.T, ctx context.Context, store *sqlitestore.Store, projectRoot, downstreamDir, upstreamHead string) {
|
||||
t.Helper()
|
||||
now := timeutil.FormatRFC3339(time.Date(2026, 3, 16, 13, 0, 0, 0, time.UTC))
|
||||
mustExec(t, store, `
|
||||
INSERT INTO projects(id, slug, name, root_path, default_branch, status, created_at, updated_at)
|
||||
VALUES('proj_mat', 'proj-mat', 'Project Mat', ?, 'main', 'active', ?, ?)
|
||||
`, projectRoot, now, now)
|
||||
mustExec(t, store, `
|
||||
INSERT INTO workspaces(id, project_id, slug, name, root_path, base_branch, worktree_branch, runtime_backend, status, created_at, updated_at)
|
||||
VALUES('ws_mat', 'proj_mat', 'Main', 'Main', ?, 'main', 'worktree/main', 'host', 'active', ?, ?)
|
||||
`, projectRoot, now, now)
|
||||
mustExec(t, store, `
|
||||
INSERT INTO topics(id, workspace_id, slug, title, space, status, created_at, updated_at)
|
||||
VALUES('topic_mat', 'ws_mat', 'sample-mat', 'Sample Mat', ?, 'execution', ?, ?)
|
||||
`, string(topic.SpaceWorkflow), now, now)
|
||||
if _, err := store.CreateLane(ctx, lane.Record{
|
||||
ID: "lane_upstream",
|
||||
WorkspaceID: "ws_mat",
|
||||
TopicID: "topic_mat",
|
||||
Name: "Upstream Lane",
|
||||
Slug: "upstream-lane",
|
||||
Status: lane.StatusSucceeded,
|
||||
BaseBranch: "main",
|
||||
BranchName: "upstream",
|
||||
HeadCommit: upstreamHead,
|
||||
WorktreePath: projectRoot,
|
||||
ContainerName: "lane-upstream",
|
||||
CreatedByRoleName: "leader",
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateLane(upstream) error = %v", err)
|
||||
}
|
||||
if _, err := store.CreateLane(ctx, lane.Record{
|
||||
ID: "lane_downstream",
|
||||
WorkspaceID: "ws_mat",
|
||||
TopicID: "topic_mat",
|
||||
Name: "Downstream Lane",
|
||||
Slug: "downstream-lane",
|
||||
Status: lane.StatusReady,
|
||||
BaseBranch: "main",
|
||||
BranchName: "downstream",
|
||||
WorktreePath: downstreamDir,
|
||||
ContainerName: "lane-downstream",
|
||||
CreatedByRoleName: "leader",
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateLane(downstream) error = %v", err)
|
||||
}
|
||||
upstreamTask, err := store.CreateTask(ctx, task.Record{
|
||||
ID: "task_upstream",
|
||||
WorkspaceID: "ws_mat",
|
||||
TopicID: "topic_mat",
|
||||
LaneID: "lane_upstream",
|
||||
Title: "Task Upstream",
|
||||
BodyMarkdown: "Implement upstream work.",
|
||||
Status: task.StatusSucceeded,
|
||||
TaskOrder: 1,
|
||||
Priority: 10,
|
||||
CreatedByRoleName: "leader",
|
||||
ResultSummaryMarkdown: "Done.",
|
||||
BlockingReasonMarkdown: "",
|
||||
CompletedAt: now,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTask(upstream) error = %v", err)
|
||||
}
|
||||
if _, err := store.CreateTask(ctx, task.Record{
|
||||
ID: "task_downstream",
|
||||
WorkspaceID: "ws_mat",
|
||||
TopicID: "topic_mat",
|
||||
LaneID: "lane_downstream",
|
||||
Title: "Task Downstream",
|
||||
BodyMarkdown: "Integrate upstream work.",
|
||||
Status: task.StatusReady,
|
||||
TaskOrder: 1,
|
||||
Priority: 5,
|
||||
CreatedByRoleName: "leader",
|
||||
}, []task.Dependency{{DependsOnTaskID: upstreamTask.ID}}); err != nil {
|
||||
t.Fatalf("CreateTask(downstream) error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustExec(t *testing.T, store *sqlitestore.Store, query string, args ...any) {
|
||||
t.Helper()
|
||||
if _, err := store.DB().Exec(query, args...); err != nil {
|
||||
t.Fatalf("Exec(%q) error = %v", query, err)
|
||||
}
|
||||
}
|
||||
|
||||
func createGitRepo(t *testing.T) string {
|
||||
t.Helper()
|
||||
repoDir := filepath.Join(t.TempDir(), "repo")
|
||||
if err := os.MkdirAll(repoDir, 0755); err != nil {
|
||||
t.Fatalf("MkdirAll() error = %v", err)
|
||||
}
|
||||
runGit(t, repoDir, "init", "-b", "main")
|
||||
runGit(t, repoDir, "config", "user.name", "Test User")
|
||||
runGit(t, repoDir, "config", "user.email", "test@example.com")
|
||||
runGit(t, repoDir, "commit", "--allow-empty", "-m", "initial commit")
|
||||
return repoDir
|
||||
}
|
||||
|
||||
func gitHead(t *testing.T, dir string) string {
|
||||
t.Helper()
|
||||
return strings.TrimSpace(runGit(t, dir, "rev-parse", "HEAD"))
|
||||
}
|
||||
|
||||
func gitStatusShort(t *testing.T, dir string) string {
|
||||
t.Helper()
|
||||
return runGit(t, dir, "status", "--short")
|
||||
}
|
||||
|
||||
func runGit(t *testing.T, dir string, args ...string) string {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
type roleWorkspace struct {
|
||||
ID string
|
||||
}
|
||||
|
||||
type resolvedRoleResolver struct {
|
||||
store *sqlitestore.Store
|
||||
clock timeutil.Clock
|
||||
}
|
||||
|
||||
func newResolvedRoleResolver(store *sqlitestore.Store, clock timeutil.Clock) *resolvedRoleResolver {
|
||||
return &resolvedRoleResolver{store: store, clock: clock}
|
||||
}
|
||||
|
||||
func (r *resolvedRoleResolver) ResolveRole(ctx context.Context, workspaceID, roleName string) (runtimeconfig.ResolvedRole, error) {
|
||||
return runtimeconfig.NewService(r.store, r.store, r.clock).ResolveRole(ctx, workspaceID, roleName)
|
||||
}
|
||||
|
||||
type noopSnapshotter struct{}
|
||||
|
||||
func (noopSnapshotter) Capture(_ context.Context, item lane.Record, _ task.Record) (string, error) {
|
||||
return item.HeadCommit, nil
|
||||
}
|
||||
|
||||
type noopMaterializer struct{}
|
||||
|
||||
func (noopMaterializer) Materialize(_ context.Context, _ lane.Record, _ string, _ []lanematerialize.Upstream) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type recordingLaneRuntimeReleaser struct {
|
||||
laneIDs []string
|
||||
}
|
||||
|
||||
func (r *recordingLaneRuntimeReleaser) ReleaseLaneRuntime(_ context.Context, laneID string) (lane.Record, error) {
|
||||
r.laneIDs = append(r.laneIDs, laneID)
|
||||
return lane.Record{ID: laneID}, nil
|
||||
}
|
||||
|
||||
type flakyTaskExecRepo struct {
|
||||
*sqlitestore.Store
|
||||
failCreateMessage bool
|
||||
failAppendTaskEvent bool
|
||||
}
|
||||
|
||||
func (r *flakyTaskExecRepo) CreateMessage(ctx context.Context, value message.Record) (message.Record, error) {
|
||||
if r.failCreateMessage {
|
||||
return message.Record{}, errors.New("forced create message failure")
|
||||
}
|
||||
return r.Store.CreateMessage(ctx, value)
|
||||
}
|
||||
|
||||
func (r *flakyTaskExecRepo) AppendTaskEvent(ctx context.Context, value task.Event) (task.Event, error) {
|
||||
if r.failAppendTaskEvent {
|
||||
return task.Event{}, errors.New("forced append task event failure")
|
||||
}
|
||||
return r.Store.AppendTaskEvent(ctx, value)
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"inbox/internal/base/timeutil"
|
||||
"inbox/internal/domain/task"
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
CreateTask(ctx context.Context, value task.Record, dependencies []task.Dependency) (task.Record, error)
|
||||
GetTask(ctx context.Context, taskID string) (task.Record, error)
|
||||
ListTasksByTopic(ctx context.Context, topicID string) ([]task.Record, error)
|
||||
ListTasksByLane(ctx context.Context, laneID string) ([]task.Record, error)
|
||||
UpdateTaskWithDependencies(ctx context.Context, value task.Record, dependencies *[]task.Dependency) (task.Record, error)
|
||||
ListTaskDependencies(ctx context.Context, taskID string) ([]task.Dependency, error)
|
||||
AppendTaskEvent(ctx context.Context, value task.Event) (task.Event, error)
|
||||
ListTaskEvents(ctx context.Context, taskID string) ([]task.Event, error)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repo Repository
|
||||
clock timeutil.Clock
|
||||
}
|
||||
|
||||
type CreateInput struct {
|
||||
Task task.Record
|
||||
Dependencies []task.Dependency
|
||||
}
|
||||
|
||||
type Patch struct {
|
||||
Title *string
|
||||
BodyMarkdown *string
|
||||
AcceptanceMarkdown *string
|
||||
Kind *task.Kind
|
||||
Deliverables *[]string
|
||||
BatchKey *string
|
||||
Status *task.Status
|
||||
Priority *int
|
||||
TaskOrder *int
|
||||
BlockingReasonMarkdown *string
|
||||
ResultSummaryMarkdown *string
|
||||
AssignedRunID *string
|
||||
StartedAt *string
|
||||
CompletedAt *string
|
||||
Dependencies *[]task.Dependency
|
||||
}
|
||||
|
||||
func NewService(repo Repository, clock timeutil.Clock) *Service {
|
||||
if clock == nil {
|
||||
clock = timeutil.SystemClock{}
|
||||
}
|
||||
return &Service{repo: repo, clock: clock}
|
||||
}
|
||||
|
||||
func (s *Service) Create(ctx context.Context, input CreateInput) (task.Record, error) {
|
||||
input.Task = task.NormalizeRecord(input.Task)
|
||||
if input.Task.Status == "" {
|
||||
input.Task.Status = task.StatusDraft
|
||||
}
|
||||
item, err := s.repo.CreateTask(ctx, input.Task, input.Dependencies)
|
||||
if err != nil {
|
||||
return task.Record{}, err
|
||||
}
|
||||
_, _ = s.repo.AppendTaskEvent(ctx, task.Event{
|
||||
TaskID: item.ID,
|
||||
EventType: "created",
|
||||
BodyMarkdown: item.BodyMarkdown,
|
||||
CreatedByRoleName: item.CreatedByRoleName,
|
||||
CreatedAt: item.CreatedAt,
|
||||
})
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *Service) Get(ctx context.Context, taskID string) (task.Record, error) {
|
||||
return s.repo.GetTask(ctx, taskID)
|
||||
}
|
||||
|
||||
func (s *Service) ListByTopic(ctx context.Context, topicID string) ([]task.Record, error) {
|
||||
return s.repo.ListTasksByTopic(ctx, topicID)
|
||||
}
|
||||
|
||||
func (s *Service) ListByLane(ctx context.Context, laneID string) ([]task.Record, error) {
|
||||
return s.repo.ListTasksByLane(ctx, laneID)
|
||||
}
|
||||
|
||||
func (s *Service) Dependencies(ctx context.Context, taskID string) ([]task.Dependency, error) {
|
||||
return s.repo.ListTaskDependencies(ctx, taskID)
|
||||
}
|
||||
|
||||
func (s *Service) Events(ctx context.Context, taskID string) ([]task.Event, error) {
|
||||
return s.repo.ListTaskEvents(ctx, taskID)
|
||||
}
|
||||
|
||||
func (s *Service) Patch(ctx context.Context, taskID string, patch Patch, changedBy string) (task.Record, error) {
|
||||
current, err := s.repo.GetTask(ctx, taskID)
|
||||
if err != nil {
|
||||
return task.Record{}, err
|
||||
}
|
||||
current = task.NormalizeRecord(current)
|
||||
if patch.Title != nil {
|
||||
current.Title = *patch.Title
|
||||
}
|
||||
if patch.BodyMarkdown != nil {
|
||||
current.BodyMarkdown = *patch.BodyMarkdown
|
||||
}
|
||||
if patch.AcceptanceMarkdown != nil {
|
||||
current.AcceptanceMarkdown = *patch.AcceptanceMarkdown
|
||||
}
|
||||
if patch.Kind != nil {
|
||||
current.Kind = *patch.Kind
|
||||
}
|
||||
if patch.Deliverables != nil {
|
||||
current.Deliverables = append([]string(nil), (*patch.Deliverables)...)
|
||||
}
|
||||
if patch.BatchKey != nil {
|
||||
current.BatchKey = *patch.BatchKey
|
||||
}
|
||||
if patch.Status != nil {
|
||||
current.Status = *patch.Status
|
||||
if current.Status == task.StatusRunning && current.StartedAt == "" {
|
||||
current.StartedAt = timeutil.FormatRFC3339(s.clock.Now())
|
||||
}
|
||||
if (current.Status == task.StatusSucceeded || current.Status == task.StatusFailed || current.Status == task.StatusCancelled) && current.CompletedAt == "" {
|
||||
current.CompletedAt = timeutil.FormatRFC3339(s.clock.Now())
|
||||
}
|
||||
}
|
||||
if patch.Priority != nil {
|
||||
current.Priority = *patch.Priority
|
||||
}
|
||||
if patch.TaskOrder != nil {
|
||||
current.TaskOrder = *patch.TaskOrder
|
||||
}
|
||||
if patch.BlockingReasonMarkdown != nil {
|
||||
current.BlockingReasonMarkdown = *patch.BlockingReasonMarkdown
|
||||
}
|
||||
if patch.ResultSummaryMarkdown != nil {
|
||||
current.ResultSummaryMarkdown = *patch.ResultSummaryMarkdown
|
||||
}
|
||||
if patch.AssignedRunID != nil {
|
||||
current.AssignedRunID = *patch.AssignedRunID
|
||||
}
|
||||
if patch.StartedAt != nil {
|
||||
current.StartedAt = *patch.StartedAt
|
||||
}
|
||||
if patch.CompletedAt != nil {
|
||||
current.CompletedAt = *patch.CompletedAt
|
||||
}
|
||||
item, err := s.repo.UpdateTaskWithDependencies(ctx, current, patch.Dependencies)
|
||||
if err != nil {
|
||||
return task.Record{}, err
|
||||
}
|
||||
if changedBy != "" {
|
||||
_, _ = s.repo.AppendTaskEvent(ctx, task.Event{
|
||||
TaskID: item.ID,
|
||||
EventType: "updated",
|
||||
BodyMarkdown: item.ResultSummaryMarkdown,
|
||||
CreatedByRoleName: changedBy,
|
||||
CreatedAt: timeutil.FormatRFC3339(s.clock.Now()),
|
||||
})
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *Service) AppendEvent(ctx context.Context, value task.Event) (task.Event, error) {
|
||||
return s.repo.AppendTaskEvent(ctx, value)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"inbox/internal/base/timeutil"
|
||||
"inbox/internal/domain/lane"
|
||||
"inbox/internal/domain/task"
|
||||
"inbox/internal/domain/topic"
|
||||
"inbox/internal/domain/workspace"
|
||||
sqlitestore "inbox/internal/store/sqlite"
|
||||
)
|
||||
|
||||
func TestPatchDoesNotPartiallyPersistWhenDependencyReplaceFails(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
clock := timeutil.FixedClock{Time: time.Date(2026, 3, 16, 14, 0, 0, 0, time.UTC)}
|
||||
store, err := sqlitestore.OpenInMemory(clock)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenInMemory() error = %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
ws, chainRecord, taskRecord := seedTaskServiceGraph(t, ctx, store)
|
||||
_ = ws
|
||||
|
||||
service := NewService(store, clock)
|
||||
newTitle := "Updated title should roll back"
|
||||
_, err = service.Patch(ctx, taskRecord.ID, Patch{
|
||||
Title: &newTitle,
|
||||
Dependencies: &[]task.Dependency{
|
||||
{TaskID: taskRecord.ID, DependsOnTaskID: "missing-task"},
|
||||
},
|
||||
}, "tester")
|
||||
if err == nil {
|
||||
t.Fatalf("expected Patch() error when dependency replace fails")
|
||||
}
|
||||
|
||||
persisted, err := store.GetTask(ctx, taskRecord.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetTask() error = %v", err)
|
||||
}
|
||||
if persisted.Title != taskRecord.Title {
|
||||
t.Fatalf("expected task title to remain %q, got %#v", taskRecord.Title, persisted)
|
||||
}
|
||||
deps, err := store.ListTaskDependencies(ctx, taskRecord.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListTaskDependencies() error = %v", err)
|
||||
}
|
||||
if len(deps) != 0 {
|
||||
t.Fatalf("expected dependencies unchanged, got %#v", deps)
|
||||
}
|
||||
updatedTasks, err := store.ListTasksByLane(ctx, chainRecord.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListTasksByLane() error = %v", err)
|
||||
}
|
||||
if len(updatedTasks) != 1 || updatedTasks[0].Title != taskRecord.Title {
|
||||
t.Fatalf("unexpected tasks after failed patch: %#v", updatedTasks)
|
||||
}
|
||||
}
|
||||
|
||||
func seedTaskServiceGraph(t *testing.T, ctx context.Context, store *sqlitestore.Store) (workspace.Workspace, lane.Record, task.Record) {
|
||||
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)
|
||||
}
|
||||
topicRecord, err := store.CreateTopic(ctx, topic.Record{
|
||||
WorkspaceID: ws.ID,
|
||||
Slug: "cleanup",
|
||||
Title: "Cleanup",
|
||||
Space: topic.SpaceWorkflow,
|
||||
Status: "execution",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTopic() error = %v", err)
|
||||
}
|
||||
chainRecord, err := store.CreateLane(ctx, lane.Record{
|
||||
WorkspaceID: ws.ID,
|
||||
TopicID: topicRecord.ID,
|
||||
Name: "Main Chain",
|
||||
Slug: "main-chain",
|
||||
Status: lane.StatusDraft,
|
||||
BaseBranch: "main",
|
||||
BranchName: "lane/main/main-lane",
|
||||
WorktreePath: t.TempDir(),
|
||||
ContainerName: "lane-main-main-lane",
|
||||
CreatedByRoleName: "leader",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateLane() error = %v", err)
|
||||
}
|
||||
taskRecord, err := store.CreateTask(ctx, task.Record{
|
||||
WorkspaceID: ws.ID,
|
||||
TopicID: topicRecord.ID,
|
||||
LaneID: chainRecord.ID,
|
||||
Title: "Original title",
|
||||
BodyMarkdown: "Initial body.",
|
||||
Status: task.StatusDraft,
|
||||
TaskOrder: 1,
|
||||
Priority: 1,
|
||||
CreatedByRoleName: "leader",
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTask() error = %v", err)
|
||||
}
|
||||
return ws, chainRecord, taskRecord
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package topics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"inbox/internal/base/slug"
|
||||
"inbox/internal/base/timeutil"
|
||||
"inbox/internal/domain/lane"
|
||||
"inbox/internal/domain/message"
|
||||
"inbox/internal/domain/task"
|
||||
"inbox/internal/domain/taskgraph"
|
||||
"inbox/internal/domain/topic"
|
||||
"inbox/internal/domain/workflow"
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
ListTopics(ctx context.Context, workspaceID string) ([]topic.Record, error)
|
||||
CreateTopic(ctx context.Context, value topic.Record) (topic.Record, error)
|
||||
GetTopic(ctx context.Context, topicID string) (topic.Record, error)
|
||||
UpdateTopic(ctx context.Context, value topic.Record) (topic.Record, error)
|
||||
DeleteTopic(ctx context.Context, topicID string) error
|
||||
ListMessagesByTopic(ctx context.Context, topicID string) ([]message.Record, error)
|
||||
CreateMessage(ctx context.Context, value message.Record) (message.Record, error)
|
||||
ListLanesByTopic(ctx context.Context, topicID string) ([]lane.Record, error)
|
||||
UpdateLane(ctx context.Context, value lane.Record) (lane.Record, error)
|
||||
ListTasksByTopic(ctx context.Context, topicID string) ([]task.Record, error)
|
||||
UpdateTask(ctx context.Context, value task.Record) (task.Record, error)
|
||||
ListWorkflowRunsByTopic(ctx context.Context, topicID string) ([]workflow.Run, error)
|
||||
UpdateWorkflowRun(ctx context.Context, value workflow.Run) (workflow.Run, error)
|
||||
ArchiveMessageDeliveries(ctx context.Context, messageID string) error
|
||||
CreateTaskGraphVersion(ctx context.Context, value taskgraph.Record) (taskgraph.Record, error)
|
||||
GetLatestTaskGraphVersionByTopic(ctx context.Context, topicID string) (taskgraph.Record, error)
|
||||
UpdateTaskGraphVersion(ctx context.Context, value taskgraph.Record) (taskgraph.Record, error)
|
||||
}
|
||||
|
||||
type RuntimeManager interface {
|
||||
EnsureLane(ctx context.Context, laneID string) (lane.Record, error)
|
||||
StopLane(ctx context.Context, laneID string) (lane.Record, error)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repo Repository
|
||||
runtime RuntimeManager
|
||||
clock timeutil.Clock
|
||||
}
|
||||
|
||||
func NewService(repo Repository, runtime RuntimeManager, clock timeutil.Clock) *Service {
|
||||
if clock == nil {
|
||||
clock = timeutil.SystemClock{}
|
||||
}
|
||||
return &Service{repo: repo, runtime: runtime, clock: clock}
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context, workspaceID string) ([]topic.Record, error) {
|
||||
return s.repo.ListTopics(ctx, workspaceID)
|
||||
}
|
||||
|
||||
func (s *Service) Create(ctx context.Context, value topic.Record) (topic.Record, error) {
|
||||
if value.Slug == "" {
|
||||
value.Slug = normalizeSlug(value.Title)
|
||||
}
|
||||
return s.repo.CreateTopic(ctx, value)
|
||||
}
|
||||
|
||||
func (s *Service) Get(ctx context.Context, topicID string) (topic.Record, error) {
|
||||
return s.repo.GetTopic(ctx, topicID)
|
||||
}
|
||||
|
||||
func (s *Service) Delete(ctx context.Context, topicID string) error {
|
||||
return s.repo.DeleteTopic(ctx, topicID)
|
||||
}
|
||||
|
||||
func (s *Service) Stop(ctx context.Context, topicID string) (topic.Record, error) {
|
||||
current, err := s.repo.GetTopic(ctx, topicID)
|
||||
if err != nil {
|
||||
return topic.Record{}, err
|
||||
}
|
||||
|
||||
now := timeutil.FormatRFC3339(s.clock.Now())
|
||||
stopReason := "Stopped manually on user request."
|
||||
|
||||
lanes, err := s.repo.ListLanesByTopic(ctx, topicID)
|
||||
if err != nil {
|
||||
return topic.Record{}, err
|
||||
}
|
||||
for _, item := range lanes {
|
||||
if isTerminalLaneStatus(item.Status) {
|
||||
continue
|
||||
}
|
||||
if s.runtime != nil {
|
||||
if _, err := s.runtime.StopLane(ctx, item.ID); err != nil {
|
||||
return topic.Record{}, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
item.Status = lane.StatusCancelled
|
||||
item.RuntimeEndpoint = ""
|
||||
item.ErrorMessage = stopReason
|
||||
if item.CompletedAt == "" {
|
||||
item.CompletedAt = now
|
||||
}
|
||||
if _, err := s.repo.UpdateLane(ctx, item); err != nil {
|
||||
return topic.Record{}, err
|
||||
}
|
||||
}
|
||||
|
||||
tasks, err := s.repo.ListTasksByTopic(ctx, topicID)
|
||||
if err != nil {
|
||||
return topic.Record{}, err
|
||||
}
|
||||
for _, item := range tasks {
|
||||
if isTerminalTaskStatus(item.Status) {
|
||||
continue
|
||||
}
|
||||
item.Status = task.StatusCancelled
|
||||
item.BlockingReasonMarkdown = stopReason
|
||||
if item.CompletedAt == "" {
|
||||
item.CompletedAt = now
|
||||
}
|
||||
if _, err := s.repo.UpdateTask(ctx, item); err != nil {
|
||||
return topic.Record{}, err
|
||||
}
|
||||
}
|
||||
|
||||
runs, err := s.repo.ListWorkflowRunsByTopic(ctx, topicID)
|
||||
if err != nil {
|
||||
return topic.Record{}, err
|
||||
}
|
||||
for _, item := range runs {
|
||||
if item.Status != workflow.RunStatusRunning {
|
||||
continue
|
||||
}
|
||||
item.Status = workflow.RunStatusCancelled
|
||||
item.ExitCode = 130
|
||||
item.ErrorMessage = stopReason
|
||||
if item.CompletedAt == "" {
|
||||
item.CompletedAt = now
|
||||
}
|
||||
if _, err := s.repo.UpdateWorkflowRun(ctx, item); err != nil {
|
||||
return topic.Record{}, err
|
||||
}
|
||||
}
|
||||
|
||||
messages, err := s.repo.ListMessagesByTopic(ctx, topicID)
|
||||
if err != nil {
|
||||
return topic.Record{}, err
|
||||
}
|
||||
for _, item := range messages {
|
||||
if err := s.repo.ArchiveMessageDeliveries(ctx, item.ID); err != nil {
|
||||
return topic.Record{}, err
|
||||
}
|
||||
}
|
||||
|
||||
current.Status = "cancelled"
|
||||
if current.ClosedAt == "" {
|
||||
current.ClosedAt = now
|
||||
}
|
||||
return s.repo.UpdateTopic(ctx, current)
|
||||
}
|
||||
|
||||
func (s *Service) ConfirmPlan(ctx context.Context, topicID string) (topic.Record, error) {
|
||||
current, err := s.repo.GetTopic(ctx, topicID)
|
||||
if err != nil {
|
||||
return topic.Record{}, err
|
||||
}
|
||||
if current.Status != "awaiting_confirmation" {
|
||||
return current, nil
|
||||
}
|
||||
|
||||
latestGraph, err := s.repo.GetLatestTaskGraphVersionByTopic(ctx, topicID)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return topic.Record{}, err
|
||||
}
|
||||
if err == nil && latestGraph.Status == taskgraph.StatusDraft {
|
||||
latestGraph.Status = taskgraph.StatusActive
|
||||
latestGraph.ConfirmedAt = timeutil.FormatRFC3339(s.clock.Now())
|
||||
if _, err := s.repo.UpdateTaskGraphVersion(ctx, latestGraph); err != nil {
|
||||
return topic.Record{}, err
|
||||
}
|
||||
}
|
||||
|
||||
current.Status = "execution"
|
||||
current.ClosedAt = ""
|
||||
current, err = s.repo.UpdateTopic(ctx, current)
|
||||
if err != nil {
|
||||
return topic.Record{}, err
|
||||
}
|
||||
if s.runtime == nil {
|
||||
return current, nil
|
||||
}
|
||||
|
||||
tasks, err := s.repo.ListTasksByTopic(ctx, topicID)
|
||||
if err != nil {
|
||||
return topic.Record{}, err
|
||||
}
|
||||
lanes, err := s.repo.ListLanesByTopic(ctx, topicID)
|
||||
if err != nil {
|
||||
return topic.Record{}, err
|
||||
}
|
||||
laneByID := make(map[string]lane.Record, len(lanes))
|
||||
for _, item := range lanes {
|
||||
laneByID[item.ID] = item
|
||||
}
|
||||
|
||||
hasOpenGate := false
|
||||
for _, item := range tasks {
|
||||
if item.Kind == task.KindGate && item.Status != task.StatusSucceeded && item.Status != task.StatusCancelled {
|
||||
hasOpenGate = true
|
||||
break
|
||||
}
|
||||
}
|
||||
seen := map[string]struct{}{}
|
||||
for _, item := range tasks {
|
||||
if item.Status != task.StatusReady {
|
||||
continue
|
||||
}
|
||||
if hasOpenGate && item.Kind != task.KindGate {
|
||||
continue
|
||||
}
|
||||
laneItem, ok := laneByID[item.LaneID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[laneItem.ID]; exists {
|
||||
continue
|
||||
}
|
||||
seen[laneItem.ID] = struct{}{}
|
||||
if _, err := s.runtime.EnsureLane(ctx, laneItem.ID); err != nil {
|
||||
return topic.Record{}, err
|
||||
}
|
||||
}
|
||||
return current, nil
|
||||
}
|
||||
|
||||
func (s *Service) ListMessages(ctx context.Context, topicID string) ([]message.Record, error) {
|
||||
return s.repo.ListMessagesByTopic(ctx, topicID)
|
||||
}
|
||||
|
||||
func (s *Service) CreateMessage(ctx context.Context, value message.Record) (message.Record, error) {
|
||||
return s.repo.CreateMessage(ctx, value)
|
||||
}
|
||||
|
||||
func normalizeSlug(value string) string {
|
||||
return slug.Normalize(value)
|
||||
}
|
||||
|
||||
func isTerminalLaneStatus(status lane.Status) bool {
|
||||
switch status {
|
||||
case lane.StatusSucceeded, lane.StatusFailed, lane.StatusCancelled:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isTerminalTaskStatus(status task.Status) bool {
|
||||
switch status {
|
||||
case task.StatusSucceeded, task.StatusFailed, task.StatusCancelled:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
package topics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"inbox/internal/base/timeutil"
|
||||
"inbox/internal/domain/lane"
|
||||
"inbox/internal/domain/message"
|
||||
"inbox/internal/domain/task"
|
||||
"inbox/internal/domain/taskgraph"
|
||||
"inbox/internal/domain/topic"
|
||||
"inbox/internal/domain/workflow"
|
||||
"inbox/internal/domain/workspace"
|
||||
sqlitestore "inbox/internal/store/sqlite"
|
||||
)
|
||||
|
||||
func TestStopCancelsTopicExecutionState(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
clock := timeutil.FixedClock{Time: time.Date(2026, 3, 17, 7, 30, 0, 0, time.UTC)}
|
||||
store, err := sqlitestore.OpenInMemory(clock)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenInMemory() error = %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
now := timeutil.FormatRFC3339(clock.Now())
|
||||
project, err := store.CreateProject(ctx, workspace.Project{
|
||||
Slug: "demo",
|
||||
Name: "Demo",
|
||||
RootPath: t.TempDir(),
|
||||
DefaultBranch: "main",
|
||||
Status: "active",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProject() error = %v", err)
|
||||
}
|
||||
ws, err := store.CreateWorkspace(ctx, workspace.Workspace{
|
||||
ProjectID: project.ID,
|
||||
Slug: "demo",
|
||||
Name: "demo",
|
||||
RootPath: t.TempDir(),
|
||||
BaseBranch: "main",
|
||||
WorktreeBranch: "worktree/demo",
|
||||
RuntimeBackend: "host",
|
||||
Status: "active",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWorkspace() error = %v", err)
|
||||
}
|
||||
topicRecord, err := store.CreateTopic(ctx, topic.Record{
|
||||
WorkspaceID: ws.ID,
|
||||
Slug: "sample",
|
||||
Title: "sample",
|
||||
Space: topic.SpaceWorkflow,
|
||||
Status: "execution",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTopic() error = %v", err)
|
||||
}
|
||||
runningLane, err := store.CreateLane(ctx, lane.Record{
|
||||
WorkspaceID: ws.ID,
|
||||
TopicID: topicRecord.ID,
|
||||
Name: "Execution Chain",
|
||||
Slug: "execution-chain",
|
||||
Status: lane.StatusRunning,
|
||||
BranchName: "lane/demo/execution-lane",
|
||||
WorktreePath: t.TempDir() + "/execution-chain",
|
||||
ContainerName: "lane-demo",
|
||||
RuntimeEndpoint: "http://127.0.0.1:40123",
|
||||
CreatedByRoleName: "leader",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
StartedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateLane() error = %v", err)
|
||||
}
|
||||
readyLane, err := store.CreateLane(ctx, lane.Record{
|
||||
WorkspaceID: ws.ID,
|
||||
TopicID: topicRecord.ID,
|
||||
Name: "Ready Chain",
|
||||
Slug: "ready-chain",
|
||||
Status: lane.StatusReady,
|
||||
BranchName: "lane/demo/ready-lane",
|
||||
WorktreePath: t.TempDir() + "/ready-chain",
|
||||
ContainerName: "lane-ready",
|
||||
CreatedByRoleName: "leader",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateLane(ready) error = %v", err)
|
||||
}
|
||||
run, err := store.CreateWorkflowRun(ctx, workflow.Run{
|
||||
WorkspaceID: ws.ID,
|
||||
TopicID: topicRecord.ID,
|
||||
RoleName: "worker",
|
||||
Stage: workflow.StageExecution,
|
||||
Mode: "task",
|
||||
Status: workflow.RunStatusRunning,
|
||||
CommandJSON: "{}",
|
||||
StartedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWorkflowRun() error = %v", err)
|
||||
}
|
||||
if _, err := store.CreateTask(ctx, task.Record{
|
||||
WorkspaceID: ws.ID,
|
||||
TopicID: topicRecord.ID,
|
||||
LaneID: runningLane.ID,
|
||||
Title: "Implement feature",
|
||||
BodyMarkdown: "Ship the feature.",
|
||||
Kind: task.KindExecution,
|
||||
Status: task.StatusRunning,
|
||||
AssignedRunID: run.ID,
|
||||
CreatedByRoleName: "leader",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
StartedAt: now,
|
||||
}, nil); err != nil {
|
||||
t.Fatalf("CreateTask(running) error = %v", err)
|
||||
}
|
||||
if _, err := store.CreateTask(ctx, task.Record{
|
||||
WorkspaceID: ws.ID,
|
||||
TopicID: topicRecord.ID,
|
||||
LaneID: readyLane.ID,
|
||||
Title: "Verify feature",
|
||||
BodyMarkdown: "Verify the feature.",
|
||||
Kind: task.KindVerification,
|
||||
Status: task.StatusReady,
|
||||
CreatedByRoleName: "leader",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}, nil); err != nil {
|
||||
t.Fatalf("CreateTask(ready) error = %v", err)
|
||||
}
|
||||
msg, err := store.CreateMessage(ctx, message.Record{
|
||||
WorkspaceID: ws.ID,
|
||||
TopicID: topicRecord.ID,
|
||||
FromRoleName: "leader",
|
||||
ToExpr: "worker",
|
||||
Type: message.TypeSummary,
|
||||
Stage: "execution",
|
||||
BodyMarkdown: "Continue execution.",
|
||||
CreatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateMessage() error = %v", err)
|
||||
}
|
||||
|
||||
service := NewService(store, &fakeTopicRuntime{store: store, now: now}, clock)
|
||||
stopped, err := service.Stop(ctx, topicRecord.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Stop() error = %v", err)
|
||||
}
|
||||
if stopped.Status != "cancelled" {
|
||||
t.Fatalf("expected cancelled topic, got %#v", stopped)
|
||||
}
|
||||
if stopped.ClosedAt == "" {
|
||||
t.Fatalf("expected closed_at to be set")
|
||||
}
|
||||
|
||||
lanes, err := store.ListLanesByTopic(ctx, topicRecord.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListLanesByTopic() error = %v", err)
|
||||
}
|
||||
for _, item := range lanes {
|
||||
if item.Status != lane.StatusCancelled {
|
||||
t.Fatalf("expected cancelled lane, got %#v", item)
|
||||
}
|
||||
}
|
||||
|
||||
tasks, err := store.ListTasksByTopic(ctx, topicRecord.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListTasksByTopic() error = %v", err)
|
||||
}
|
||||
for _, item := range tasks {
|
||||
if item.Status != task.StatusCancelled {
|
||||
t.Fatalf("expected cancelled task, got %#v", item)
|
||||
}
|
||||
if item.CompletedAt == "" {
|
||||
t.Fatalf("expected completed_at on task %#v", item)
|
||||
}
|
||||
}
|
||||
|
||||
updatedRun, err := store.GetWorkflowRun(ctx, run.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetWorkflowRun() error = %v", err)
|
||||
}
|
||||
if updatedRun.Status != workflow.RunStatusCancelled {
|
||||
t.Fatalf("expected cancelled run, got %#v", updatedRun)
|
||||
}
|
||||
if updatedRun.CompletedAt == "" {
|
||||
t.Fatalf("expected completed_at on run %#v", updatedRun)
|
||||
}
|
||||
|
||||
var deliveryState string
|
||||
if err := store.DB().QueryRowContext(ctx, `
|
||||
SELECT state
|
||||
FROM message_deliveries
|
||||
WHERE message_id = ? AND recipient_role_name = 'worker'
|
||||
`, msg.ID).Scan(&deliveryState); err != nil {
|
||||
t.Fatalf("select delivery state: %v", err)
|
||||
}
|
||||
if deliveryState != string(message.DeliveryArchived) {
|
||||
t.Fatalf("expected archived delivery, got %q", deliveryState)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmPlanActivatesDraftGraphAndStartsReadyLanes(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
clock := timeutil.FixedClock{Time: time.Date(2026, 3, 17, 8, 0, 0, 0, time.UTC)}
|
||||
store, err := sqlitestore.OpenInMemory(clock)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenInMemory() error = %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
now := timeutil.FormatRFC3339(clock.Now())
|
||||
project, err := store.CreateProject(ctx, workspace.Project{
|
||||
Slug: "demo",
|
||||
Name: "Demo",
|
||||
RootPath: t.TempDir(),
|
||||
DefaultBranch: "main",
|
||||
Status: "active",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProject() error = %v", err)
|
||||
}
|
||||
ws, err := store.CreateWorkspace(ctx, workspace.Workspace{
|
||||
ProjectID: project.ID,
|
||||
Slug: "demo",
|
||||
Name: "demo",
|
||||
RootPath: t.TempDir(),
|
||||
BaseBranch: "main",
|
||||
WorktreeBranch: "worktree/demo",
|
||||
RuntimeBackend: "host",
|
||||
Status: "active",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWorkspace() error = %v", err)
|
||||
}
|
||||
topicRecord, err := store.CreateTopic(ctx, topic.Record{
|
||||
WorkspaceID: ws.ID,
|
||||
Slug: "sample",
|
||||
Title: "sample",
|
||||
Space: topic.SpaceWorkflow,
|
||||
Status: "awaiting_confirmation",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTopic() error = %v", err)
|
||||
}
|
||||
gateLane, err := store.CreateLane(ctx, lane.Record{
|
||||
WorkspaceID: ws.ID,
|
||||
TopicID: topicRecord.ID,
|
||||
Name: "Gate Chain",
|
||||
Slug: "gate-chain",
|
||||
Status: lane.StatusReady,
|
||||
BranchName: "lane/demo/gate-lane",
|
||||
WorktreePath: t.TempDir() + "/gate-chain",
|
||||
ContainerName: "lane-gate",
|
||||
CreatedByRoleName: "leader",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateLane(gate) error = %v", err)
|
||||
}
|
||||
readyLane, err := store.CreateLane(ctx, lane.Record{
|
||||
WorkspaceID: ws.ID,
|
||||
TopicID: topicRecord.ID,
|
||||
Name: "Ready Chain",
|
||||
Slug: "ready-chain",
|
||||
Status: lane.StatusReady,
|
||||
BranchName: "lane/demo/ready-lane",
|
||||
WorktreePath: t.TempDir() + "/ready-chain",
|
||||
ContainerName: "lane-ready",
|
||||
CreatedByRoleName: "leader",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateLane(ready) error = %v", err)
|
||||
}
|
||||
if _, err := store.CreateTask(ctx, task.Record{
|
||||
WorkspaceID: ws.ID,
|
||||
TopicID: topicRecord.ID,
|
||||
LaneID: gateLane.ID,
|
||||
Title: "Inspect workspace",
|
||||
BodyMarkdown: "Inspect workspace.",
|
||||
Kind: task.KindGate,
|
||||
Status: task.StatusReady,
|
||||
CreatedByRoleName: "leader",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}, nil); err != nil {
|
||||
t.Fatalf("CreateTask(gate) error = %v", err)
|
||||
}
|
||||
if _, err := store.CreateTask(ctx, task.Record{
|
||||
WorkspaceID: ws.ID,
|
||||
TopicID: topicRecord.ID,
|
||||
LaneID: readyLane.ID,
|
||||
Title: "Implement feature",
|
||||
BodyMarkdown: "Implement feature.",
|
||||
Kind: task.KindExecution,
|
||||
Status: task.StatusReady,
|
||||
CreatedByRoleName: "leader",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}, nil); err != nil {
|
||||
t.Fatalf("CreateTask(ready) error = %v", err)
|
||||
}
|
||||
if _, err := store.CreateTaskGraphVersion(ctx, taskgraph.Record{
|
||||
TopicID: topicRecord.ID,
|
||||
Version: 1,
|
||||
Status: taskgraph.StatusDraft,
|
||||
PlanJSON: `{"plan_version":"1"}`,
|
||||
PlanSummaryMarkdown: "Initial graph.",
|
||||
CreatedByRoleName: "leader",
|
||||
CreatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateTaskGraphVersion() error = %v", err)
|
||||
}
|
||||
|
||||
runtime := &fakeTopicRuntime{store: store, now: now}
|
||||
service := NewService(store, runtime, clock)
|
||||
confirmed, err := service.ConfirmPlan(ctx, topicRecord.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ConfirmPlan() error = %v", err)
|
||||
}
|
||||
if confirmed.Status != "execution" {
|
||||
t.Fatalf("expected execution topic, got %#v", confirmed)
|
||||
}
|
||||
if len(runtime.startedLaneIDs) != 1 || runtime.startedLaneIDs[0] != gateLane.ID {
|
||||
t.Fatalf("expected only gate lane to start, got %#v", runtime.startedLaneIDs)
|
||||
}
|
||||
graphVersion, err := store.GetLatestTaskGraphVersionByTopic(ctx, topicRecord.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLatestTaskGraphVersionByTopic() error = %v", err)
|
||||
}
|
||||
if graphVersion.Status != taskgraph.StatusActive || graphVersion.ConfirmedAt == "" {
|
||||
t.Fatalf("expected active confirmed graph version, got %#v", graphVersion)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeTopicRuntime struct {
|
||||
store *sqlitestore.Store
|
||||
now string
|
||||
startedLaneIDs []string
|
||||
}
|
||||
|
||||
func (f *fakeTopicRuntime) StopLane(ctx context.Context, laneID string) (lane.Record, error) {
|
||||
item, err := f.store.GetLane(ctx, laneID)
|
||||
if err != nil {
|
||||
return lane.Record{}, err
|
||||
}
|
||||
item.Status = lane.StatusCancelled
|
||||
item.RuntimeEndpoint = ""
|
||||
item.ErrorMessage = "Stopped manually on user request."
|
||||
item.CompletedAt = f.now
|
||||
return f.store.UpdateLane(ctx, item)
|
||||
}
|
||||
|
||||
func (f *fakeTopicRuntime) EnsureLane(ctx context.Context, laneID string) (lane.Record, error) {
|
||||
f.startedLaneIDs = append(f.startedLaneIDs, laneID)
|
||||
item, err := f.store.GetLane(ctx, laneID)
|
||||
if err != nil {
|
||||
return lane.Record{}, err
|
||||
}
|
||||
item.Status = lane.StatusRunning
|
||||
item.StartedAt = f.now
|
||||
return f.store.UpdateLane(ctx, item)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package workflowrun
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"inbox/internal/app/runtimeconfig"
|
||||
"inbox/internal/base/timeutil"
|
||||
"inbox/internal/domain/workflow"
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
CreateWorkflowRun(ctx context.Context, value workflow.Run) (workflow.Run, error)
|
||||
GetWorkflowRun(ctx context.Context, runID string) (workflow.Run, error)
|
||||
UpdateWorkflowRun(ctx context.Context, value workflow.Run) (workflow.Run, error)
|
||||
ListWorkflowRunsByTopic(ctx context.Context, topicID string) ([]workflow.Run, error)
|
||||
ListWorkflowRunLogs(ctx context.Context, runID string, afterSeq int) ([]workflow.RunLog, error)
|
||||
AppendWorkflowRunLog(ctx context.Context, value workflow.RunLog) (workflow.RunLog, error)
|
||||
}
|
||||
|
||||
type RuntimeResolver interface {
|
||||
ResolveRole(ctx context.Context, workspaceID, roleName string) (runtimeconfig.ResolvedRole, error)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repo Repository
|
||||
resolver RuntimeResolver
|
||||
clock timeutil.Clock
|
||||
}
|
||||
|
||||
type Patch struct {
|
||||
Status *workflow.RunStatus
|
||||
ReplyMessageID *string
|
||||
ExitCode *int
|
||||
CompletedAt *string
|
||||
ErrorMessage *string
|
||||
CommandJSON *string
|
||||
}
|
||||
|
||||
func NewService(repo Repository, resolver RuntimeResolver, clock timeutil.Clock) *Service {
|
||||
if clock == nil {
|
||||
clock = timeutil.SystemClock{}
|
||||
}
|
||||
return &Service{
|
||||
repo: repo,
|
||||
resolver: resolver,
|
||||
clock: clock,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) Start(ctx context.Context, run workflow.Run) (workflow.Run, error) {
|
||||
resolved, err := s.resolver.ResolveRole(ctx, run.WorkspaceID, run.RoleName)
|
||||
if err != nil {
|
||||
return workflow.Run{}, fmt.Errorf("resolve runtime config for %s: %w", run.RoleName, err)
|
||||
}
|
||||
snapshot, err := resolved.SnapshotJSON()
|
||||
if err != nil {
|
||||
return workflow.Run{}, err
|
||||
}
|
||||
run.ConfigSnapshotJSON = snapshot
|
||||
if run.Status == "" {
|
||||
run.Status = workflow.RunStatusRunning
|
||||
}
|
||||
return s.repo.CreateWorkflowRun(ctx, run)
|
||||
}
|
||||
|
||||
func (s *Service) ListByTopic(ctx context.Context, topicID string) ([]workflow.Run, error) {
|
||||
return s.repo.ListWorkflowRunsByTopic(ctx, topicID)
|
||||
}
|
||||
|
||||
func (s *Service) Get(ctx context.Context, runID string) (workflow.Run, error) {
|
||||
return s.repo.GetWorkflowRun(ctx, runID)
|
||||
}
|
||||
|
||||
func (s *Service) Update(ctx context.Context, value workflow.Run) (workflow.Run, error) {
|
||||
if value.Status != workflow.RunStatusRunning && value.CompletedAt == "" {
|
||||
value.CompletedAt = timeutil.FormatRFC3339(s.clock.Now())
|
||||
}
|
||||
return s.repo.UpdateWorkflowRun(ctx, value)
|
||||
}
|
||||
|
||||
func (s *Service) Patch(ctx context.Context, runID string, patch Patch) (workflow.Run, error) {
|
||||
current, err := s.repo.GetWorkflowRun(ctx, runID)
|
||||
if err != nil {
|
||||
return workflow.Run{}, err
|
||||
}
|
||||
if patch.Status != nil {
|
||||
current.Status = *patch.Status
|
||||
}
|
||||
if patch.ReplyMessageID != nil {
|
||||
current.ReplyMessageID = *patch.ReplyMessageID
|
||||
}
|
||||
if patch.ExitCode != nil {
|
||||
current.ExitCode = *patch.ExitCode
|
||||
}
|
||||
if patch.CompletedAt != nil {
|
||||
current.CompletedAt = *patch.CompletedAt
|
||||
}
|
||||
if patch.ErrorMessage != nil {
|
||||
current.ErrorMessage = *patch.ErrorMessage
|
||||
}
|
||||
if patch.CommandJSON != nil {
|
||||
current.CommandJSON = *patch.CommandJSON
|
||||
}
|
||||
return s.Update(ctx, current)
|
||||
}
|
||||
|
||||
func (s *Service) ListLogs(ctx context.Context, runID string, afterSeq int) ([]workflow.RunLog, error) {
|
||||
if _, err := s.repo.GetWorkflowRun(ctx, runID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.ListWorkflowRunLogs(ctx, runID, afterSeq)
|
||||
}
|
||||
|
||||
func (s *Service) AppendLog(ctx context.Context, value workflow.RunLog) (workflow.RunLog, error) {
|
||||
if _, err := s.repo.GetWorkflowRun(ctx, value.RunID); err != nil {
|
||||
return workflow.RunLog{}, err
|
||||
}
|
||||
return s.repo.AppendWorkflowRunLog(ctx, value)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package workflowrun
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"inbox/internal/app/runtimeconfig"
|
||||
"inbox/internal/base/timeutil"
|
||||
"inbox/internal/domain/role"
|
||||
"inbox/internal/domain/workflow"
|
||||
sqlitestore "inbox/internal/store/sqlite"
|
||||
)
|
||||
|
||||
func TestStartCreatesRunWithConfigSnapshot(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
clock := timeutil.FixedClock{Time: time.Date(2026, 3, 13, 17, 0, 0, 0, time.UTC)}
|
||||
store, err := sqlitestore.OpenInMemory(clock)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenInMemory() error = %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
workspaceRoot := t.TempDir()
|
||||
now := timeutil.FormatRFC3339(clock.Now())
|
||||
if _, err := store.DB().Exec(`
|
||||
INSERT INTO projects(id, slug, name, root_path, default_branch, status, created_at, updated_at)
|
||||
VALUES('proj_1', 'proj', 'Project', ?, 'main', 'active', ?, ?)
|
||||
`, workspaceRoot, now, now); err != nil {
|
||||
t.Fatalf("insert project: %v", err)
|
||||
}
|
||||
if _, err := store.DB().Exec(`
|
||||
INSERT INTO workspaces(id, project_id, slug, name, root_path, base_branch, worktree_branch, runtime_backend, status, created_at, updated_at)
|
||||
VALUES('ws_1', 'proj_1', 'main', 'Main', ?, 'main', 'worktree/main', 'local', 'active', ?, ?)
|
||||
`, workspaceRoot, now, now); err != nil {
|
||||
t.Fatalf("insert workspace: %v", err)
|
||||
}
|
||||
if _, err := store.UpsertRole(ctx, role.Definition{
|
||||
Name: "backend",
|
||||
Title: "Backend",
|
||||
IsEnabled: true,
|
||||
IsBuiltin: true,
|
||||
}, "seed"); err != nil {
|
||||
t.Fatalf("UpsertRole() error = %v", err)
|
||||
}
|
||||
if _, err := store.UpsertRoleConfig(ctx, role.Config{
|
||||
RoleName: "backend",
|
||||
ConfigTOML: "model = \"gpt-5.4\"",
|
||||
AuthJSON: "{\"OPENAI_API_KEY\":\"token-1\"}",
|
||||
}, "seed"); err != nil {
|
||||
t.Fatalf("UpsertRoleConfig() error = %v", err)
|
||||
}
|
||||
if _, err := store.DB().Exec(`
|
||||
INSERT INTO topics(id, workspace_id, slug, title, space, status, created_at, updated_at)
|
||||
VALUES('topic_1', 'ws_1', 'signup', 'Signup', 'workflow', 'execution', ?, ?)
|
||||
`, now, now); err != nil {
|
||||
t.Fatalf("insert topic: %v", err)
|
||||
}
|
||||
|
||||
resolver := runtimeconfig.NewService(store, store, clock)
|
||||
service := NewService(store, resolver, clock)
|
||||
run, err := service.Start(ctx, workflow.Run{
|
||||
WorkspaceID: "ws_1",
|
||||
TopicID: "topic_1",
|
||||
RoleName: "backend",
|
||||
Stage: workflow.StageExecution,
|
||||
Mode: "once",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Start() error = %v", err)
|
||||
}
|
||||
if run.ConfigSnapshotJSON == "" || run.ConfigSnapshotJSON == "{}" {
|
||||
t.Fatalf("expected non-empty config snapshot, got %q", run.ConfigSnapshotJSON)
|
||||
}
|
||||
if run.Status != workflow.RunStatusRunning {
|
||||
t.Fatalf("expected running status, got %q", run.Status)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package workspacelifecycle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"inbox/internal/app/workspaceprovision"
|
||||
"inbox/internal/app/workspaceruntime"
|
||||
"inbox/internal/domain/workspace"
|
||||
)
|
||||
|
||||
type Provisioner interface {
|
||||
Provision(ctx context.Context, req workspaceprovision.ProvisionRequest) (workspace.Workspace, workspaceruntime.Runtime, error)
|
||||
}
|
||||
|
||||
type RuntimeManager interface {
|
||||
Ensure(ctx context.Context, workspaceID string) (workspace.Workspace, workspaceruntime.Runtime, error)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
provision Provisioner
|
||||
runtime RuntimeManager
|
||||
}
|
||||
|
||||
func NewService(provision Provisioner, runtime RuntimeManager) *Service {
|
||||
return &Service{
|
||||
provision: provision,
|
||||
runtime: runtime,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) Provision(ctx context.Context, req workspaceprovision.ProvisionRequest) (workspace.Workspace, workspaceruntime.Runtime, error) {
|
||||
if s.provision == nil {
|
||||
return workspace.Workspace{}, workspaceruntime.Runtime{}, fmt.Errorf("workspace provisioning is not configured")
|
||||
}
|
||||
return s.provision.Provision(ctx, req)
|
||||
}
|
||||
|
||||
func (s *Service) Ensure(ctx context.Context, workspaceID string) (workspace.Workspace, workspaceruntime.Runtime, error) {
|
||||
if s.runtime == nil {
|
||||
return workspace.Workspace{}, workspaceruntime.Runtime{}, fmt.Errorf("workspace runtime is not configured")
|
||||
}
|
||||
return s.runtime.Ensure(ctx, workspaceID)
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package workspaceprovision
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"inbox/internal/app/workspaceruntime"
|
||||
"inbox/internal/base/slug"
|
||||
"inbox/internal/domain/workspace"
|
||||
)
|
||||
|
||||
type store interface {
|
||||
GetProject(context.Context, string) (workspace.Project, error)
|
||||
GetProjectByRootPath(context.Context, string) (workspace.Project, error)
|
||||
GetProjectBySlug(context.Context, string) (workspace.Project, error)
|
||||
CreateProject(context.Context, workspace.Project) (workspace.Project, error)
|
||||
UpdateProjectDefaultBranch(context.Context, string, string) error
|
||||
GetWorkspace(context.Context, string) (workspace.Workspace, error)
|
||||
GetWorkspaceByProjectAndSlug(context.Context, string, string) (workspace.Workspace, error)
|
||||
CreateWorkspace(context.Context, workspace.Workspace) (workspace.Workspace, error)
|
||||
UpdateWorkspace(context.Context, workspace.Workspace) error
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
store store
|
||||
workspacesDir string
|
||||
runtime runtimeManager
|
||||
}
|
||||
|
||||
type ProvisionRequest struct {
|
||||
ProjectDir string
|
||||
Name string
|
||||
}
|
||||
|
||||
type runtimeManager interface {
|
||||
EnsureRepository(ctx context.Context, projectDir string) (string, error)
|
||||
Ensure(ctx context.Context, workspaceID string) (workspace.Workspace, workspaceruntime.Runtime, error)
|
||||
}
|
||||
|
||||
func NewService(store store, workspacesDir string, runtime runtimeManager) *Service {
|
||||
return &Service{
|
||||
store: store,
|
||||
workspacesDir: strings.TrimSpace(workspacesDir),
|
||||
runtime: runtime,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) Provision(ctx context.Context, req ProvisionRequest) (workspace.Workspace, workspaceruntime.Runtime, error) {
|
||||
if s.runtime == nil {
|
||||
return workspace.Workspace{}, workspaceruntime.Runtime{}, fmt.Errorf("workspace runtime is not configured")
|
||||
}
|
||||
projectDir, err := filepath.Abs(strings.TrimSpace(req.ProjectDir))
|
||||
if err != nil {
|
||||
return workspace.Workspace{}, workspaceruntime.Runtime{}, fmt.Errorf("resolve project dir: %w", err)
|
||||
}
|
||||
info, err := os.Stat(projectDir)
|
||||
if err != nil {
|
||||
return workspace.Workspace{}, workspaceruntime.Runtime{}, fmt.Errorf("stat project dir: %w", err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return workspace.Workspace{}, workspaceruntime.Runtime{}, fmt.Errorf("project dir is not a directory: %s", projectDir)
|
||||
}
|
||||
|
||||
projectName := filepath.Base(projectDir)
|
||||
projectSlug := slug.Normalize(projectName)
|
||||
if projectSlug == "" {
|
||||
projectSlug = "project"
|
||||
}
|
||||
workspaceSlug := slug.Normalize(firstNonEmpty(req.Name, projectName))
|
||||
if workspaceSlug == "" {
|
||||
workspaceSlug = projectSlug
|
||||
}
|
||||
|
||||
baseBranch, err := s.runtime.EnsureRepository(ctx, projectDir)
|
||||
if err != nil {
|
||||
return workspace.Workspace{}, workspaceruntime.Runtime{}, err
|
||||
}
|
||||
|
||||
project, err := s.ensureProject(ctx, projectDir, projectName, projectSlug, baseBranch)
|
||||
if err != nil {
|
||||
return workspace.Workspace{}, workspaceruntime.Runtime{}, err
|
||||
}
|
||||
|
||||
ws, err := s.store.GetWorkspaceByProjectAndSlug(ctx, project.ID, workspaceSlug)
|
||||
if err != nil {
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return workspace.Workspace{}, workspaceruntime.Runtime{}, err
|
||||
}
|
||||
ws = workspace.NormalizeWorkspaceForCreate(workspace.Workspace{
|
||||
ProjectID: project.ID,
|
||||
Slug: workspaceSlug,
|
||||
Name: workspaceSlug,
|
||||
RootPath: filepath.Join(s.workspacesDir, workspaceSlug),
|
||||
BaseBranch: baseBranch,
|
||||
})
|
||||
ws, err = s.store.CreateWorkspace(ctx, ws)
|
||||
if err != nil {
|
||||
return workspace.Workspace{}, workspaceruntime.Runtime{}, err
|
||||
}
|
||||
}
|
||||
|
||||
ws = workspace.ApplyManagedRuntimeConfig(ws, s.workspacesDir, baseBranch)
|
||||
if err := s.store.UpdateWorkspace(ctx, ws); err != nil {
|
||||
return workspace.Workspace{}, workspaceruntime.Runtime{}, err
|
||||
}
|
||||
return s.runtime.Ensure(ctx, ws.ID)
|
||||
}
|
||||
|
||||
func (s *Service) ensureProject(ctx context.Context, projectDir, projectName, projectSlug, baseBranch string) (workspace.Project, error) {
|
||||
project, err := s.store.GetProjectByRootPath(ctx, projectDir)
|
||||
if err != nil {
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return workspace.Project{}, err
|
||||
}
|
||||
project = workspace.NormalizeProjectForCreate(workspace.Project{
|
||||
Slug: projectSlug,
|
||||
Name: projectName,
|
||||
RootPath: projectDir,
|
||||
DefaultBranch: baseBranch,
|
||||
})
|
||||
created, createErr := s.store.CreateProject(ctx, project)
|
||||
if createErr == nil {
|
||||
return created, nil
|
||||
}
|
||||
if existing, lookupErr := s.store.GetProjectBySlug(ctx, projectSlug); lookupErr == nil && existing.RootPath == projectDir {
|
||||
project = existing
|
||||
} else {
|
||||
return workspace.Project{}, createErr
|
||||
}
|
||||
}
|
||||
if project.DefaultBranch != baseBranch {
|
||||
if err := s.store.UpdateProjectDefaultBranch(ctx, project.ID, baseBranch); err != nil {
|
||||
return workspace.Project{}, err
|
||||
}
|
||||
project.DefaultBranch = baseBranch
|
||||
}
|
||||
return project, nil
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package workspaceruntime
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func commandError(action, output string, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
output = strings.TrimSpace(output)
|
||||
if output == "" {
|
||||
return fmt.Errorf("%s: %w", action, err)
|
||||
}
|
||||
return fmt.Errorf("%s: %s: %w", action, output, err)
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
package workspaceruntime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"inbox/internal/app/lanegit"
|
||||
"inbox/internal/app/runtimecodex"
|
||||
"inbox/internal/domain/lane"
|
||||
"inbox/internal/domain/workspace"
|
||||
)
|
||||
|
||||
const (
|
||||
runtimeBaseImage = "localhost/ai-workflow-agent-runner:local"
|
||||
runnerContainerPort = "31417/tcp"
|
||||
runnerContainerPortNum = "31417"
|
||||
)
|
||||
|
||||
type podmanInspect struct {
|
||||
ImageName string `json:"ImageName"`
|
||||
Path string `json:"Path"`
|
||||
Args []string `json:"Args"`
|
||||
Config struct {
|
||||
Image string `json:"Image"`
|
||||
User string `json:"User"`
|
||||
Env []string `json:"Env"`
|
||||
WorkingDir string `json:"WorkingDir"`
|
||||
} `json:"Config"`
|
||||
State struct {
|
||||
Running bool `json:"Running"`
|
||||
Status string `json:"Status"`
|
||||
} `json:"State"`
|
||||
NetworkSettings struct {
|
||||
Ports map[string][]struct {
|
||||
HostIP string `json:"HostIp"`
|
||||
HostPort string `json:"HostPort"`
|
||||
} `json:"Ports"`
|
||||
} `json:"NetworkSettings"`
|
||||
Mounts []struct {
|
||||
Source string `json:"Source"`
|
||||
Destination string `json:"Destination"`
|
||||
} `json:"Mounts"`
|
||||
}
|
||||
|
||||
type containerRuntime struct {
|
||||
projectRoot string
|
||||
serverPort int
|
||||
runner lanegit.Runner
|
||||
probe *endpointProbe
|
||||
}
|
||||
|
||||
const laneWorkerContainerPath = "/usr/local/bin/lane-worker"
|
||||
const inboxContainerPath = "/usr/local/bin/inbox"
|
||||
|
||||
func (r *containerRuntime) ensureRunnerImage(ctx context.Context) error {
|
||||
out, err := r.runner.Run(ctx, r.projectRoot, nil, "podman", "image", "exists", runtimeBaseImage)
|
||||
if err != nil {
|
||||
if strings.TrimSpace(out) == "" {
|
||||
return fmt.Errorf("runtime base image is missing: %s", runtimeBaseImage)
|
||||
}
|
||||
return commandError("runtime base image is missing: "+runtimeBaseImage, out, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *containerRuntime) ensureLaneContainer(ctx context.Context, ws workspace.Workspace, item lane.Record, workerBinary, inboxBinary, workerCodexDir string) (string, error) {
|
||||
codexFingerprint, err := runtimeCodexFingerprint(workerCodexDir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
info, found, err := r.inspect(ctx, item.ContainerName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if found && r.laneDrifted(info, ws, item, workerBinary, inboxBinary, codexFingerprint) {
|
||||
if err := r.stopAndRemoveContainer(ctx, item.ContainerName); err != nil {
|
||||
return "", err
|
||||
}
|
||||
found = false
|
||||
}
|
||||
if !found {
|
||||
args := []string{
|
||||
"create",
|
||||
"--name", item.ContainerName,
|
||||
"--user", "root",
|
||||
"--entrypoint", laneWorkerContainerPath,
|
||||
"-p", "127.0.0.1::" + runnerContainerPortNum,
|
||||
"-e", "INBOX_WORKSPACE=/workspace",
|
||||
"-e", "INBOX_WORKSPACE_ID=" + ws.ID,
|
||||
"-e", "INBOX_LANE_ID=" + item.ID,
|
||||
"-e", "INBOX_API_URL=http://host.containers.internal:" + strconv.Itoa(r.serverPort),
|
||||
"-e", "INBOX_RUNTIME_AGENT_ID=" + item.ContainerName,
|
||||
"-e", "HOME=" + runtimecodex.ContainerUserHomeDir(),
|
||||
"-v", filepath.Join(item.WorktreePath) + ":/workspace:z",
|
||||
"-v", workerBinary + ":" + laneWorkerContainerPath + ":z,ro",
|
||||
"-v", inboxBinary + ":" + inboxContainerPath + ":z,ro",
|
||||
"-w", "/workspace",
|
||||
}
|
||||
if codexFingerprint != "" {
|
||||
args = append(args, "-e", "INBOX_RUNTIME_CODEX_SHA="+codexFingerprint)
|
||||
}
|
||||
args = append(args, runtimeBaseImage)
|
||||
out, err := r.runner.Run(ctx, r.projectRoot, nil, "podman", args...)
|
||||
if err != nil {
|
||||
return "", commandError("create lane container "+item.ContainerName, out, err)
|
||||
}
|
||||
if err := r.copyRuntimeCodex(ctx, item.ContainerName, workerCodexDir); err != nil {
|
||||
_ = r.stopAndRemoveContainer(ctx, item.ContainerName)
|
||||
return "", err
|
||||
}
|
||||
info, found, err = r.inspect(ctx, item.ContainerName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !found {
|
||||
return "", fmt.Errorf("container %s was not created", item.ContainerName)
|
||||
}
|
||||
}
|
||||
if !info.State.Running {
|
||||
out, err := r.runner.Run(ctx, r.projectRoot, nil, "podman", "start", item.ContainerName)
|
||||
if err != nil {
|
||||
return "", commandError("start lane container "+item.ContainerName, out, err)
|
||||
}
|
||||
}
|
||||
endpoint, err := r.endpoint(ctx, item.ContainerName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := r.probe.wait(endpoint); err != nil {
|
||||
return endpoint, err
|
||||
}
|
||||
return endpoint, nil
|
||||
}
|
||||
|
||||
func (r *containerRuntime) inspect(ctx context.Context, name string) (podmanInspect, bool, error) {
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return podmanInspect{}, false, nil
|
||||
}
|
||||
out, err := r.runner.Run(ctx, r.projectRoot, nil, "podman", "inspect", name)
|
||||
if err != nil {
|
||||
if strings.Contains(out, "no such object") || strings.Contains(out, "no container with name or ID") {
|
||||
return podmanInspect{}, false, nil
|
||||
}
|
||||
return podmanInspect{}, false, fmt.Errorf("inspect container %s: %w", name, err)
|
||||
}
|
||||
var items []podmanInspect
|
||||
if err := json.Unmarshal([]byte(out), &items); err != nil {
|
||||
return podmanInspect{}, false, fmt.Errorf("decode podman inspect %s: %w", name, err)
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return podmanInspect{}, false, nil
|
||||
}
|
||||
return items[0], true, nil
|
||||
}
|
||||
|
||||
func (r *containerRuntime) laneDrifted(info podmanInspect, ws workspace.Workspace, item lane.Record, workerBinary, inboxBinary, codexFingerprint string) bool {
|
||||
if info.ImageName != runtimeBaseImage && info.Config.Image != runtimeBaseImage {
|
||||
return true
|
||||
}
|
||||
if strings.TrimSpace(info.Config.User) != "root" {
|
||||
return true
|
||||
}
|
||||
if !hasPublishedPort(info, runnerContainerPort) {
|
||||
return true
|
||||
}
|
||||
if info.Config.WorkingDir != "/workspace" {
|
||||
return true
|
||||
}
|
||||
if filepath.Clean(strings.TrimSpace(info.Path)) != laneWorkerContainerPath {
|
||||
return true
|
||||
}
|
||||
mounts := make(map[string]string, len(info.Mounts))
|
||||
for _, mount := range info.Mounts {
|
||||
mounts[filepath.Clean(mount.Destination)] = filepath.Clean(mount.Source)
|
||||
}
|
||||
if _, ok := mounts[filepath.Clean(runtimecodex.ContainerCodexDir())]; ok {
|
||||
return true
|
||||
}
|
||||
expectedMounts := map[string]string{
|
||||
"/workspace": filepath.Clean(item.WorktreePath),
|
||||
laneWorkerContainerPath: filepath.Clean(workerBinary),
|
||||
inboxContainerPath: filepath.Clean(inboxBinary),
|
||||
}
|
||||
for destination, source := range expectedMounts {
|
||||
if filepath.Clean(mounts[destination]) != filepath.Clean(source) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
envs := make(map[string]string)
|
||||
for _, item := range info.Config.Env {
|
||||
key, value, ok := strings.Cut(item, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
envs[key] = value
|
||||
}
|
||||
expectedEnv := map[string]string{
|
||||
"INBOX_WORKSPACE": "/workspace",
|
||||
"INBOX_WORKSPACE_ID": ws.ID,
|
||||
"INBOX_LANE_ID": item.ID,
|
||||
"INBOX_RUNTIME_AGENT_ID": item.ContainerName,
|
||||
"INBOX_API_URL": "http://host.containers.internal:" + strconv.Itoa(r.serverPort),
|
||||
}
|
||||
if codexFingerprint != "" {
|
||||
expectedEnv["INBOX_RUNTIME_CODEX_SHA"] = codexFingerprint
|
||||
} else if _, ok := envs["INBOX_RUNTIME_CODEX_SHA"]; ok {
|
||||
return true
|
||||
}
|
||||
for key, value := range expectedEnv {
|
||||
if envs[key] != value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (r *containerRuntime) copyRuntimeCodex(ctx context.Context, containerName, workerCodexDir string) error {
|
||||
if strings.TrimSpace(workerCodexDir) == "" {
|
||||
return nil
|
||||
}
|
||||
out, err := r.runner.Run(ctx, r.projectRoot, nil, "podman", "cp", workerCodexDir, containerName+":"+runtimecodex.ContainerUserHomeDir())
|
||||
if err != nil {
|
||||
return commandError("copy runtime codex into "+containerName, out, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runtimeCodexFingerprint(workerCodexDir string) (string, error) {
|
||||
if strings.TrimSpace(workerCodexDir) == "" {
|
||||
return "", nil
|
||||
}
|
||||
names := make([]string, 0, 8)
|
||||
err := filepath.Walk(workerCodexDir, func(path string, info os.FileInfo, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(workerCodexDir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
names = append(names, filepath.ToSlash(rel))
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("walk runtime codex dir %s: %w", workerCodexDir, err)
|
||||
}
|
||||
sort.Strings(names)
|
||||
sum := sha256.New()
|
||||
for _, name := range names {
|
||||
body, err := os.ReadFile(filepath.Join(workerCodexDir, filepath.FromSlash(name)))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read runtime codex file %s: %w", filepath.Join(workerCodexDir, filepath.FromSlash(name)), err)
|
||||
}
|
||||
_, _ = sum.Write([]byte(name))
|
||||
_, _ = sum.Write([]byte{0})
|
||||
_, _ = sum.Write(body)
|
||||
_, _ = sum.Write([]byte{0})
|
||||
}
|
||||
return fmt.Sprintf("%x", sum.Sum(nil)), nil
|
||||
}
|
||||
|
||||
func hasPublishedPort(info podmanInspect, containerPort string) bool {
|
||||
bindings, ok := info.NetworkSettings.Ports[containerPort]
|
||||
if !ok || len(bindings) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, binding := range bindings {
|
||||
if strings.TrimSpace(binding.HostPort) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (r *containerRuntime) stopContainer(ctx context.Context, containerName string) error {
|
||||
info, found, err := r.inspect(ctx, containerName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found {
|
||||
return nil
|
||||
}
|
||||
if info.State.Running {
|
||||
out, err := r.runner.Run(ctx, r.projectRoot, nil, "podman", "stop", containerName)
|
||||
if err != nil {
|
||||
return commandError("stop container "+containerName, out, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *containerRuntime) stopAndRemoveContainer(ctx context.Context, containerName string) error {
|
||||
out, err := r.runner.Run(ctx, r.projectRoot, nil, "podman", "rm", "-f", containerName)
|
||||
if err != nil {
|
||||
return commandError("remove container "+containerName, out, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *containerRuntime) endpoint(ctx context.Context, containerName string) (string, error) {
|
||||
out, err := r.runner.Run(ctx, r.projectRoot, nil, "podman", "port", containerName, runnerContainerPort)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read container port for %s: %w", containerName, err)
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(out), "\n")
|
||||
if len(lines) == 0 || strings.TrimSpace(lines[0]) == "" {
|
||||
return "", fmt.Errorf("container %s does not expose %s", containerName, runnerContainerPort)
|
||||
}
|
||||
line := strings.TrimSpace(lines[0])
|
||||
port := line[strings.LastIndex(line, ":")+1:]
|
||||
if _, err := strconv.Atoi(port); err != nil {
|
||||
return "", fmt.Errorf("parse runner port %q", line)
|
||||
}
|
||||
return "http://127.0.0.1:" + port, nil
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package workspaceruntime
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"inbox/internal/domain/lane"
|
||||
"inbox/internal/domain/workspace"
|
||||
)
|
||||
|
||||
func TestDriftedWhenRunnerPortIsMissing(t *testing.T) {
|
||||
ws := workspace.Workspace{
|
||||
ID: "ws_1",
|
||||
RootPath: "/tmp/ws",
|
||||
}
|
||||
item := lane.Record{
|
||||
ID: "chain_1",
|
||||
ContainerName: "lane-demo",
|
||||
WorktreePath: "/tmp/ws-chain",
|
||||
}
|
||||
|
||||
runtime := &containerRuntime{serverPort: 3000}
|
||||
info := validInspect(ws, item, "/tmp/lane-worker", "/tmp/inbox", "codex-sha-1")
|
||||
delete(info.NetworkSettings.Ports, runnerContainerPort)
|
||||
|
||||
if !runtime.laneDrifted(info, ws, item, "/tmp/lane-worker", "/tmp/inbox", "codex-sha-1") {
|
||||
t.Fatalf("expected container without %s mapping to drift", runnerContainerPort)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriftedWhenRunnerPortIsPublished(t *testing.T) {
|
||||
ws := workspace.Workspace{
|
||||
ID: "ws_1",
|
||||
RootPath: "/tmp/ws",
|
||||
}
|
||||
item := lane.Record{
|
||||
ID: "chain_1",
|
||||
ContainerName: "lane-demo",
|
||||
WorktreePath: "/tmp/ws-chain",
|
||||
}
|
||||
|
||||
runtime := &containerRuntime{serverPort: 3000}
|
||||
info := validInspect(ws, item, "/tmp/lane-worker", "/tmp/inbox", "codex-sha-1")
|
||||
|
||||
if runtime.laneDrifted(info, ws, item, "/tmp/lane-worker", "/tmp/inbox", "codex-sha-1") {
|
||||
t.Fatalf("expected container with %s mapping to remain reusable", runnerContainerPort)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriftedWhenContainerUserIsNotRoot(t *testing.T) {
|
||||
ws := workspace.Workspace{ID: "ws_1", RootPath: "/tmp/ws"}
|
||||
item := lane.Record{
|
||||
ID: "chain_1",
|
||||
ContainerName: "lane-demo",
|
||||
WorktreePath: "/tmp/ws-chain",
|
||||
}
|
||||
|
||||
runtime := &containerRuntime{serverPort: 3000}
|
||||
info := validInspect(ws, item, "/tmp/lane-worker", "/tmp/inbox", "codex-sha-1")
|
||||
info.Config.User = "runner"
|
||||
|
||||
if !runtime.laneDrifted(info, ws, item, "/tmp/lane-worker", "/tmp/inbox", "codex-sha-1") {
|
||||
t.Fatalf("expected non-root container user to drift")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriftedWhenRuntimeCodexFingerprintChanges(t *testing.T) {
|
||||
ws := workspace.Workspace{ID: "ws_1", RootPath: "/tmp/ws"}
|
||||
item := lane.Record{
|
||||
ID: "chain_1",
|
||||
ContainerName: "lane-demo",
|
||||
WorktreePath: "/tmp/ws-chain",
|
||||
}
|
||||
|
||||
runtime := &containerRuntime{serverPort: 3000}
|
||||
info := validInspect(ws, item, "/tmp/lane-worker", "/tmp/inbox", "codex-sha-1")
|
||||
|
||||
if !runtime.laneDrifted(info, ws, item, "/tmp/lane-worker", "/tmp/inbox", "codex-sha-2") {
|
||||
t.Fatalf("expected runtime codex fingerprint mismatch to drift")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeCodexFingerprint(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "config.toml"), []byte("model = \"gpt-5.3-codex\"\n"), 0644); err != nil {
|
||||
t.Fatalf("WriteFile(config.toml) error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "auth.json"), []byte("{\"OPENAI_API_KEY\":\"token\"}\n"), 0600); err != nil {
|
||||
t.Fatalf("WriteFile(auth.json) error = %v", err)
|
||||
}
|
||||
|
||||
sum1, err := runtimeCodexFingerprint(root)
|
||||
if err != nil {
|
||||
t.Fatalf("runtimeCodexFingerprint() error = %v", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filepath.Join(root, "auth.json"), []byte("{\"OPENAI_API_KEY\":\"changed\"}\n"), 0600); err != nil {
|
||||
t.Fatalf("WriteFile(auth.json) error = %v", err)
|
||||
}
|
||||
sum2, err := runtimeCodexFingerprint(root)
|
||||
if err != nil {
|
||||
t.Fatalf("runtimeCodexFingerprint() error = %v", err)
|
||||
}
|
||||
|
||||
if sum1 == "" || sum2 == "" {
|
||||
t.Fatalf("expected non-empty fingerprints, got %q and %q", sum1, sum2)
|
||||
}
|
||||
if sum1 == sum2 {
|
||||
t.Fatalf("expected fingerprint to change when runtime codex contents change")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeCodexFingerprintChangesForNestedSkillFiles(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
skillDir := filepath.Join(root, "skills", "inbox")
|
||||
if err := os.MkdirAll(skillDir, 0755); err != nil {
|
||||
t.Fatalf("MkdirAll(skillDir) error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# Inbox\n"), 0644); err != nil {
|
||||
t.Fatalf("WriteFile(SKILL.md) error = %v", err)
|
||||
}
|
||||
|
||||
sum1, err := runtimeCodexFingerprint(root)
|
||||
if err != nil {
|
||||
t.Fatalf("runtimeCodexFingerprint() error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# Inbox\n\nUpdated\n"), 0644); err != nil {
|
||||
t.Fatalf("WriteFile(SKILL.md) error = %v", err)
|
||||
}
|
||||
sum2, err := runtimeCodexFingerprint(root)
|
||||
if err != nil {
|
||||
t.Fatalf("runtimeCodexFingerprint() error = %v", err)
|
||||
}
|
||||
if sum1 == sum2 {
|
||||
t.Fatalf("expected nested skill file change to affect fingerprint")
|
||||
}
|
||||
}
|
||||
|
||||
func validInspect(ws workspace.Workspace, item lane.Record, workerBinary, inboxBinary, codexFingerprint string) podmanInspect {
|
||||
var info podmanInspect
|
||||
info.ImageName = runtimeBaseImage
|
||||
info.Path = laneWorkerContainerPath
|
||||
info.Config.Image = runtimeBaseImage
|
||||
info.Config.User = "root"
|
||||
info.Config.WorkingDir = "/workspace"
|
||||
info.Config.Env = []string{
|
||||
"INBOX_WORKSPACE=/workspace",
|
||||
"INBOX_WORKSPACE_ID=" + ws.ID,
|
||||
"INBOX_LANE_ID=" + item.ID,
|
||||
"INBOX_RUNTIME_AGENT_ID=" + item.ContainerName,
|
||||
"INBOX_API_URL=http://host.containers.internal:3000",
|
||||
"HOME=/root",
|
||||
"INBOX_RUNTIME_CODEX_SHA=" + codexFingerprint,
|
||||
}
|
||||
info.NetworkSettings.Ports = map[string][]struct {
|
||||
HostIP string `json:"HostIp"`
|
||||
HostPort string `json:"HostPort"`
|
||||
}{
|
||||
runnerContainerPort: {{
|
||||
HostIP: "127.0.0.1",
|
||||
HostPort: "40123",
|
||||
}},
|
||||
}
|
||||
info.Mounts = []struct {
|
||||
Source string `json:"Source"`
|
||||
Destination string `json:"Destination"`
|
||||
}{
|
||||
{Source: item.WorktreePath, Destination: "/workspace"},
|
||||
{Source: workerBinary, Destination: laneWorkerContainerPath},
|
||||
{Source: inboxBinary, Destination: inboxContainerPath},
|
||||
}
|
||||
return info
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package workspaceruntime
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type endpointProbe struct {
|
||||
attempts int
|
||||
dialTimeout time.Duration
|
||||
retryDelay time.Duration
|
||||
}
|
||||
|
||||
func newEndpointProbe() *endpointProbe {
|
||||
return &endpointProbe{
|
||||
attempts: 20,
|
||||
dialTimeout: 500 * time.Millisecond,
|
||||
retryDelay: 250 * time.Millisecond,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *endpointProbe) wait(endpoint string) error {
|
||||
address := strings.TrimPrefix(endpoint, "http://")
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < p.attempts; attempt++ {
|
||||
conn, err := net.DialTimeout("tcp", address, p.dialTimeout)
|
||||
if err == nil {
|
||||
_ = conn.Close()
|
||||
return nil
|
||||
}
|
||||
lastErr = err
|
||||
time.Sleep(p.retryDelay)
|
||||
}
|
||||
return fmt.Errorf("runner endpoint %s is not reachable: %w", endpoint, lastErr)
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package workspaceruntime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"inbox/internal/app/lanegit"
|
||||
)
|
||||
|
||||
type gitWorktree struct {
|
||||
Path string
|
||||
Branch string
|
||||
}
|
||||
|
||||
type gitWorktreeManager struct {
|
||||
projectRoot string
|
||||
runner lanegit.Runner
|
||||
}
|
||||
|
||||
func (g *gitWorktreeManager) ensureRepository(ctx context.Context, projectDir string) (string, error) {
|
||||
isRepo, err := g.isRepository(ctx, projectDir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if isRepo {
|
||||
branch, err := g.currentBranch(ctx, projectDir)
|
||||
if err == nil && strings.TrimSpace(branch) != "" {
|
||||
return strings.TrimSpace(branch), nil
|
||||
}
|
||||
return "main", nil
|
||||
}
|
||||
|
||||
out, err := g.runner.Run(ctx, g.projectRoot, nil, "git", "-C", projectDir, "init", "-b", "main")
|
||||
if err != nil {
|
||||
return "", commandError("git init "+projectDir, out, err)
|
||||
}
|
||||
env := map[string]string{
|
||||
"GIT_AUTHOR_NAME": "Inbox",
|
||||
"GIT_AUTHOR_EMAIL": "inbox@local",
|
||||
"GIT_COMMITTER_NAME": "Inbox",
|
||||
"GIT_COMMITTER_EMAIL": "inbox@local",
|
||||
}
|
||||
out, err = g.runner.Run(ctx, g.projectRoot, env, "git", "-C", projectDir, "commit", "--allow-empty", "-m", "Initialize workspace repository")
|
||||
if err != nil {
|
||||
return "", commandError("create initial empty commit", out, err)
|
||||
}
|
||||
return "main", nil
|
||||
}
|
||||
|
||||
func (g *gitWorktreeManager) ensureWorktree(ctx context.Context, projectDir, worktreePath, baseBranch, worktreeBranch string) error {
|
||||
worktreePath = filepath.Clean(worktreePath)
|
||||
entries, err := g.listWorktrees(ctx, projectDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if stale, err := hasMissingWorktreePaths(entries); err != nil {
|
||||
return err
|
||||
} else if stale {
|
||||
if err := g.pruneWorktrees(ctx, projectDir); err != nil {
|
||||
return err
|
||||
}
|
||||
entries, err = g.listWorktrees(ctx, projectDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if filepath.Clean(entry.Path) == worktreePath {
|
||||
expected := "refs/heads/" + worktreeBranch
|
||||
if strings.TrimSpace(entry.Branch) != "" && entry.Branch != expected {
|
||||
return fmt.Errorf("worktree path %s is attached to %s, want %s", worktreePath, entry.Branch, expected)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if entry.Branch == "refs/heads/"+worktreeBranch && filepath.Clean(entry.Path) != worktreePath {
|
||||
return fmt.Errorf("worktree branch %s already attached at %s", worktreeBranch, entry.Path)
|
||||
}
|
||||
}
|
||||
|
||||
if info, err := os.Stat(worktreePath); err == nil && info.IsDir() {
|
||||
return fmt.Errorf("worktree path already exists but is not registered: %s", worktreePath)
|
||||
} else if err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("stat worktree path: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(worktreePath), 0755); err != nil {
|
||||
return fmt.Errorf("create worktree parent: %w", err)
|
||||
}
|
||||
|
||||
branchExists, err := g.branchExists(ctx, projectDir, worktreeBranch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
args := []string{"-C", projectDir, "worktree", "add"}
|
||||
if !branchExists {
|
||||
args = append(args, "-b", worktreeBranch)
|
||||
}
|
||||
args = append(args, worktreePath)
|
||||
if branchExists {
|
||||
args = append(args, worktreeBranch)
|
||||
} else {
|
||||
args = append(args, baseBranch)
|
||||
}
|
||||
out, err := g.runner.Run(ctx, g.projectRoot, nil, "git", args...)
|
||||
if err != nil {
|
||||
return commandError("create worktree "+worktreePath, out, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *gitWorktreeManager) isRepository(ctx context.Context, projectDir string) (bool, error) {
|
||||
out, err := g.runner.Run(ctx, g.projectRoot, nil, "git", "-C", projectDir, "rev-parse", "--is-inside-work-tree")
|
||||
if err != nil {
|
||||
if strings.Contains(out, "not a git repository") {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("detect git repository: %w", err)
|
||||
}
|
||||
return strings.TrimSpace(out) == "true", nil
|
||||
}
|
||||
|
||||
func (g *gitWorktreeManager) currentBranch(ctx context.Context, projectDir string) (string, error) {
|
||||
out, err := g.runner.Run(ctx, g.projectRoot, nil, "git", "-C", projectDir, "symbolic-ref", "--quiet", "--short", "HEAD")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(out), nil
|
||||
}
|
||||
|
||||
func (g *gitWorktreeManager) listWorktrees(ctx context.Context, projectDir string) ([]gitWorktree, error) {
|
||||
out, err := g.runner.Run(ctx, g.projectRoot, nil, "git", "-C", projectDir, "worktree", "list", "--porcelain")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list git worktrees: %w", err)
|
||||
}
|
||||
lines := strings.Split(out, "\n")
|
||||
items := make([]gitWorktree, 0)
|
||||
var current gitWorktree
|
||||
flush := func() {
|
||||
if strings.TrimSpace(current.Path) == "" {
|
||||
return
|
||||
}
|
||||
items = append(items, current)
|
||||
current = gitWorktree{}
|
||||
}
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(line, "worktree "):
|
||||
flush()
|
||||
current.Path = strings.TrimSpace(strings.TrimPrefix(line, "worktree "))
|
||||
case strings.HasPrefix(line, "branch "):
|
||||
current.Branch = strings.TrimSpace(strings.TrimPrefix(line, "branch "))
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (g *gitWorktreeManager) branchExists(ctx context.Context, projectDir, branch string) (bool, error) {
|
||||
out, err := g.runner.Run(ctx, g.projectRoot, nil, "git", "-C", projectDir, "show-ref", "--verify", "--quiet", "refs/heads/"+branch)
|
||||
if err != nil {
|
||||
if out == "" {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("check git branch %s: %w", branch, err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (g *gitWorktreeManager) pruneWorktrees(ctx context.Context, projectDir string) error {
|
||||
out, err := g.runner.Run(ctx, g.projectRoot, nil, "git", "-C", projectDir, "worktree", "prune", "--expire", "now")
|
||||
if err != nil {
|
||||
return commandError("prune stale worktrees", out, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hasMissingWorktreePaths(entries []gitWorktree) (bool, error) {
|
||||
for _, entry := range entries {
|
||||
info, err := os.Stat(filepath.Clean(entry.Path))
|
||||
if err == nil {
|
||||
if !info.IsDir() {
|
||||
return false, fmt.Errorf("worktree path is not a directory: %s", entry.Path)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if os.IsNotExist(err) {
|
||||
return true, nil
|
||||
}
|
||||
return false, fmt.Errorf("stat registered worktree path %s: %w", entry.Path, err)
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package workspaceruntime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
"inbox/internal/app/lanegit"
|
||||
)
|
||||
|
||||
type hostInboxBinary struct {
|
||||
projectRoot string
|
||||
runner lanegit.Runner
|
||||
}
|
||||
|
||||
func (b *hostInboxBinary) ensure(ctx context.Context) (string, error) {
|
||||
binaryPath := filepath.Join(b.projectRoot, ".runtime", "bin", "inbox")
|
||||
needsBuild, err := b.needsBuild(binaryPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !needsBuild {
|
||||
return binaryPath, nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(binaryPath), 0755); err != nil {
|
||||
return "", fmt.Errorf("create runtime bin dir: %w", err)
|
||||
}
|
||||
env := map[string]string{
|
||||
"GOOS": "linux",
|
||||
"GOARCH": runtime.GOARCH,
|
||||
}
|
||||
out, err := b.runner.Run(ctx, filepath.Join(b.projectRoot, "inbox"), env, "go", "build", "-o", binaryPath, "./cmd/inbox")
|
||||
if err != nil {
|
||||
return "", commandError("build inbox binary", out, err)
|
||||
}
|
||||
return binaryPath, nil
|
||||
}
|
||||
|
||||
func (b *hostInboxBinary) needsBuild(binaryPath string) (bool, error) {
|
||||
return sourceTreeNeedsBuild(b.projectRoot, "inbox", binaryPath)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package workspaceruntime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
"inbox/internal/app/lanegit"
|
||||
)
|
||||
|
||||
type hostLaneWorkerBinary struct {
|
||||
projectRoot string
|
||||
runner lanegit.Runner
|
||||
}
|
||||
|
||||
func (b *hostLaneWorkerBinary) ensure(ctx context.Context) (string, error) {
|
||||
binaryPath := filepath.Join(b.projectRoot, ".runtime", "bin", "lane-worker")
|
||||
needsBuild, err := b.needsBuild(binaryPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !needsBuild {
|
||||
return binaryPath, nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(binaryPath), 0755); err != nil {
|
||||
return "", fmt.Errorf("create runtime bin dir: %w", err)
|
||||
}
|
||||
env := map[string]string{
|
||||
"GOOS": "linux",
|
||||
"GOARCH": runtime.GOARCH,
|
||||
}
|
||||
out, err := b.runner.Run(ctx, filepath.Join(b.projectRoot, "inbox"), env, "go", "build", "-o", binaryPath, "./cmd/lane-worker")
|
||||
if err != nil {
|
||||
return "", commandError("build lane worker binary", out, err)
|
||||
}
|
||||
return binaryPath, nil
|
||||
}
|
||||
|
||||
func (b *hostLaneWorkerBinary) needsBuild(binaryPath string) (bool, error) {
|
||||
return sourceTreeNeedsBuild(b.projectRoot, "inbox", binaryPath)
|
||||
}
|
||||
|
||||
func sourceTreeNeedsBuild(projectRoot, sourceSubdir, binaryPath string) (bool, error) {
|
||||
info, err := os.Stat(binaryPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return true, nil
|
||||
}
|
||||
return false, fmt.Errorf("stat runtime binary: %w", err)
|
||||
}
|
||||
latest := info.ModTime()
|
||||
sourceRoot := filepath.Join(projectRoot, sourceSubdir)
|
||||
err = filepath.Walk(sourceRoot, func(path string, fileInfo os.FileInfo, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if fileInfo.IsDir() {
|
||||
base := filepath.Base(path)
|
||||
if base == ".git" || base == "bin" {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if fileInfo.ModTime().After(latest) {
|
||||
latest = fileInfo.ModTime()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("walk runtime source tree: %w", err)
|
||||
}
|
||||
return latest.After(info.ModTime()), nil
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
package workspaceruntime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"inbox/internal/app/lanegit"
|
||||
"inbox/internal/app/runtimecodex"
|
||||
"inbox/internal/app/runtimeconfig"
|
||||
"inbox/internal/base/slug"
|
||||
"inbox/internal/base/timeutil"
|
||||
"inbox/internal/domain/lane"
|
||||
"inbox/internal/domain/role"
|
||||
"inbox/internal/domain/skill"
|
||||
"inbox/internal/domain/workspace"
|
||||
)
|
||||
|
||||
type store interface {
|
||||
GetProject(context.Context, string) (workspace.Project, error)
|
||||
UpdateProjectDefaultBranch(context.Context, string, string) error
|
||||
GetWorkspace(context.Context, string) (workspace.Workspace, error)
|
||||
UpdateWorkspace(context.Context, workspace.Workspace) error
|
||||
GetLane(context.Context, string) (lane.Record, error)
|
||||
UpdateLane(context.Context, lane.Record) (lane.Record, error)
|
||||
}
|
||||
|
||||
type runtimeCodexStore interface {
|
||||
store
|
||||
ListRoles(context.Context) ([]role.Definition, error)
|
||||
GetRole(context.Context, string) (role.Definition, error)
|
||||
GetRoleConfig(context.Context, string) (role.Config, error)
|
||||
ListRolePrompts(context.Context, string) ([]role.Prompt, error)
|
||||
ListRoleSkillBindings(context.Context, string) ([]role.SkillBinding, error)
|
||||
ListSkillsByIDs(context.Context, []string) (map[string]skill.Definition, error)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
store store
|
||||
projectRoot string
|
||||
clock timeutil.Clock
|
||||
workspacesDir string
|
||||
git *gitWorktreeManager
|
||||
worker *hostLaneWorkerBinary
|
||||
inbox *hostInboxBinary
|
||||
codexHomes *runtimecodex.Materializer
|
||||
runtime *containerRuntime
|
||||
}
|
||||
|
||||
type Runtime struct {
|
||||
ContainerName string `json:"container_name"`
|
||||
ContainerState string `json:"container_state"`
|
||||
WorktreePath string `json:"worktree_path"`
|
||||
RunnerEndpoint string `json:"runner_endpoint"`
|
||||
Ensured bool `json:"ensured"`
|
||||
}
|
||||
|
||||
func NewService(store store, projectRoot, workspacesDir string, serverPort int, clock timeutil.Clock, runner lanegit.Runner) *Service {
|
||||
if clock == nil {
|
||||
clock = timeutil.SystemClock{}
|
||||
}
|
||||
if runner == nil {
|
||||
runner = lanegit.ExecRunner{}
|
||||
}
|
||||
root := strings.TrimSpace(projectRoot)
|
||||
probe := newEndpointProbe()
|
||||
var codexHomes *runtimecodex.Materializer
|
||||
if configStore, ok := store.(runtimeCodexStore); ok {
|
||||
codexHomes = runtimecodex.NewMaterializer(
|
||||
configStore,
|
||||
runtimeconfig.NewService(configStore, configStore, clock),
|
||||
)
|
||||
}
|
||||
return &Service{
|
||||
store: store,
|
||||
projectRoot: root,
|
||||
clock: clock,
|
||||
workspacesDir: strings.TrimSpace(workspacesDir),
|
||||
git: &gitWorktreeManager{projectRoot: root, runner: runner},
|
||||
worker: &hostLaneWorkerBinary{projectRoot: root, runner: runner},
|
||||
inbox: &hostInboxBinary{projectRoot: root, runner: runner},
|
||||
codexHomes: codexHomes,
|
||||
runtime: &containerRuntime{projectRoot: root, serverPort: serverPort, runner: runner, probe: probe},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) EnsureRepository(ctx context.Context, projectDir string) (string, error) {
|
||||
return s.git.ensureRepository(ctx, projectDir)
|
||||
}
|
||||
|
||||
func (s *Service) Ensure(ctx context.Context, workspaceID string) (workspace.Workspace, Runtime, error) {
|
||||
ws, err := s.store.GetWorkspace(ctx, strings.TrimSpace(workspaceID))
|
||||
if err != nil {
|
||||
return workspace.Workspace{}, Runtime{}, err
|
||||
}
|
||||
project, err := s.store.GetProject(ctx, ws.ProjectID)
|
||||
if err != nil {
|
||||
return workspace.Workspace{}, Runtime{}, err
|
||||
}
|
||||
baseBranch, err := s.git.ensureRepository(ctx, project.RootPath)
|
||||
if err != nil {
|
||||
_, _ = s.failWorkspace(ctx, ws, err)
|
||||
return ws, Runtime{}, err
|
||||
}
|
||||
if project.DefaultBranch != baseBranch {
|
||||
if err := s.store.UpdateProjectDefaultBranch(ctx, project.ID, baseBranch); err != nil {
|
||||
return ws, Runtime{}, err
|
||||
}
|
||||
project.DefaultBranch = baseBranch
|
||||
}
|
||||
if strings.TrimSpace(s.workspacesDir) != "" {
|
||||
ws.RootPath = filepath.Join(s.workspacesDir, ws.Slug)
|
||||
}
|
||||
ws = workspace.ApplyManagedRuntimeConfig(ws, s.workspacesDir, baseBranch)
|
||||
return s.ensureWorkspaceHost(ctx, project, ws)
|
||||
}
|
||||
|
||||
func (s *Service) ensureWorkspaceHost(ctx context.Context, project workspace.Project, ws workspace.Workspace) (workspace.Workspace, Runtime, error) {
|
||||
runtime := Runtime{
|
||||
ContainerName: "",
|
||||
WorktreePath: ws.RootPath,
|
||||
}
|
||||
if err := s.git.ensureWorktree(ctx, project.RootPath, ws.RootPath, ws.BaseBranch, ws.WorktreeBranch); err != nil {
|
||||
failed, _ := s.failWorkspace(ctx, ws, err)
|
||||
return failed, runtime, err
|
||||
}
|
||||
now := timeutil.FormatRFC3339(s.clock.Now())
|
||||
ws.RootPath = filepath.Clean(ws.RootPath)
|
||||
ws.RuntimeBackend = "host"
|
||||
ws.ProvisionState = "ready"
|
||||
ws.ProvisionError = ""
|
||||
ws.LastProvisionedAt = now
|
||||
ws.ContainerState = ""
|
||||
if err := s.store.UpdateWorkspace(ctx, ws); err != nil {
|
||||
return ws, runtime, err
|
||||
}
|
||||
runtime.ContainerState = ""
|
||||
runtime.RunnerEndpoint = ""
|
||||
runtime.Ensured = true
|
||||
return ws, runtime, nil
|
||||
}
|
||||
|
||||
func (s *Service) failWorkspace(ctx context.Context, ws workspace.Workspace, cause error) (workspace.Workspace, error) {
|
||||
ws.ProvisionState = "failed"
|
||||
ws.ProvisionError = strings.TrimSpace(cause.Error())
|
||||
ws.ContainerState = "missing"
|
||||
if err := s.store.UpdateWorkspace(ctx, ws); err != nil {
|
||||
return ws, err
|
||||
}
|
||||
return ws, cause
|
||||
}
|
||||
|
||||
func (s *Service) EnsureLane(ctx context.Context, laneID string) (lane.Record, error) {
|
||||
item, err := s.store.GetLane(ctx, strings.TrimSpace(laneID))
|
||||
if err != nil {
|
||||
return lane.Record{}, err
|
||||
}
|
||||
ws, err := s.store.GetWorkspace(ctx, item.WorkspaceID)
|
||||
if err != nil {
|
||||
return lane.Record{}, err
|
||||
}
|
||||
project, err := s.store.GetProject(ctx, ws.ProjectID)
|
||||
if err != nil {
|
||||
return lane.Record{}, err
|
||||
}
|
||||
baseBranch, err := s.git.ensureRepository(ctx, project.RootPath)
|
||||
if err != nil {
|
||||
return lane.Record{}, err
|
||||
}
|
||||
if ws, _, err = s.ensureWorkspaceHost(ctx, project, ws); err != nil {
|
||||
return lane.Record{}, err
|
||||
}
|
||||
if item.BaseBranch == "" {
|
||||
item.BaseBranch = firstNonEmpty(strings.TrimSpace(ws.BaseBranch), baseBranch)
|
||||
}
|
||||
if item.Slug == "" {
|
||||
item.Slug = slug.Normalize(item.Name)
|
||||
}
|
||||
if item.BranchName == "" {
|
||||
item.BranchName = lane.DefaultBranchName(ws.Slug, item.TopicID, item.Slug)
|
||||
}
|
||||
if item.WorktreePath == "" {
|
||||
item.WorktreePath = lane.DefaultWorktreePath(ws.RootPath, ws.Slug, item.TopicID, item.Slug)
|
||||
}
|
||||
if item.ContainerName == "" {
|
||||
item.ContainerName = lane.DefaultContainerName(ws.Slug, item.TopicID, item.Slug)
|
||||
}
|
||||
if err := s.git.ensureWorktree(ctx, project.RootPath, item.WorktreePath, item.BaseBranch, item.BranchName); err != nil {
|
||||
return lane.Record{}, err
|
||||
}
|
||||
workerCodexDir := ""
|
||||
if s.codexHomes != nil {
|
||||
runtimeRoot := runtimecodex.HostContainerRuntimeRoot(s.projectRoot, ws.ID)
|
||||
if err := s.codexHomes.Sync(ctx, ws.ID, runtimeRoot); err != nil {
|
||||
return lane.Record{}, err
|
||||
}
|
||||
workerCodexDir = runtimecodex.HostContainerCodexDir(s.projectRoot, ws.ID, "worker")
|
||||
}
|
||||
workerBinary, err := s.worker.ensure(ctx)
|
||||
if err != nil {
|
||||
return lane.Record{}, err
|
||||
}
|
||||
inboxBinary, err := s.inbox.ensure(ctx)
|
||||
if err != nil {
|
||||
return lane.Record{}, err
|
||||
}
|
||||
if err := s.runtime.ensureRunnerImage(ctx); err != nil {
|
||||
return lane.Record{}, err
|
||||
}
|
||||
endpoint, err := s.runtime.ensureLaneContainer(ctx, ws, item, workerBinary, inboxBinary, workerCodexDir)
|
||||
if err != nil {
|
||||
return lane.Record{}, err
|
||||
}
|
||||
item.RuntimeEndpoint = endpoint
|
||||
item.Status = lane.StatusRunning
|
||||
now := timeutil.FormatRFC3339(s.clock.Now())
|
||||
if item.StartedAt == "" {
|
||||
item.StartedAt = now
|
||||
}
|
||||
item.UpdatedAt = now
|
||||
return s.store.UpdateLane(ctx, item)
|
||||
}
|
||||
|
||||
func (s *Service) StopLane(ctx context.Context, laneID string) (lane.Record, error) {
|
||||
item, err := s.store.GetLane(ctx, strings.TrimSpace(laneID))
|
||||
if err != nil {
|
||||
return lane.Record{}, err
|
||||
}
|
||||
if item.ContainerName != "" {
|
||||
if err := s.runtime.stopContainer(ctx, item.ContainerName); err != nil {
|
||||
return lane.Record{}, err
|
||||
}
|
||||
}
|
||||
item.Status = lane.StatusCancelled
|
||||
item.CompletedAt = timeutil.FormatRFC3339(s.clock.Now())
|
||||
item.RuntimeEndpoint = ""
|
||||
return s.store.UpdateLane(ctx, item)
|
||||
}
|
||||
|
||||
func (s *Service) ReleaseLaneRuntime(ctx context.Context, laneID string) (lane.Record, error) {
|
||||
item, err := s.store.GetLane(ctx, strings.TrimSpace(laneID))
|
||||
if err != nil {
|
||||
return lane.Record{}, err
|
||||
}
|
||||
if item.ContainerName != "" {
|
||||
if err := s.runtime.stopContainer(ctx, item.ContainerName); err != nil {
|
||||
return lane.Record{}, err
|
||||
}
|
||||
}
|
||||
item.RuntimeEndpoint = ""
|
||||
item.UpdatedAt = timeutil.FormatRFC3339(s.clock.Now())
|
||||
return s.store.UpdateLane(ctx, item)
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package workspaceruntime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"inbox/internal/base/timeutil"
|
||||
"inbox/internal/domain/workspace"
|
||||
sqlitestore "inbox/internal/store/sqlite"
|
||||
)
|
||||
|
||||
func TestEnsureRepairsMissingRegisteredWorkspaceWorktree(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
clock := timeutil.FixedClock{Time: time.Date(2026, 3, 18, 2, 0, 0, 0, time.UTC)}
|
||||
store, err := sqlitestore.OpenInMemory(clock)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenInMemory() error = %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
rootDir := t.TempDir()
|
||||
projectDir := filepath.Join(rootDir, "project")
|
||||
workspacesDir := filepath.Join(rootDir, "workspaces")
|
||||
if err := os.MkdirAll(projectDir, 0755); err != nil {
|
||||
t.Fatalf("MkdirAll(projectDir) error = %v", err)
|
||||
}
|
||||
|
||||
project, err := store.CreateProject(ctx, workspace.NormalizeProjectForCreate(workspace.Project{
|
||||
Slug: "demo-project",
|
||||
Name: "demo-project",
|
||||
RootPath: projectDir,
|
||||
DefaultBranch: "main",
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProject() error = %v", err)
|
||||
}
|
||||
ws, err := store.CreateWorkspace(ctx, workspace.NormalizeWorkspaceForCreate(workspace.Workspace{
|
||||
ProjectID: project.ID,
|
||||
Slug: "todo",
|
||||
Name: "todo",
|
||||
RootPath: filepath.Join(workspacesDir, "todo"),
|
||||
BaseBranch: "main",
|
||||
WorktreeBranch: "worktree/todo",
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWorkspace() error = %v", err)
|
||||
}
|
||||
|
||||
service := NewService(store, rootDir, workspacesDir, 3000, clock, nil)
|
||||
ensured, runtime, err := service.Ensure(ctx, ws.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Ensure() initial error = %v", err)
|
||||
}
|
||||
if !runtime.Ensured {
|
||||
t.Fatalf("expected initial ensure to succeed, got %#v", runtime)
|
||||
}
|
||||
if _, err := os.Stat(ensured.RootPath); err != nil {
|
||||
t.Fatalf("Stat(initial worktree) error = %v", err)
|
||||
}
|
||||
|
||||
if err := os.RemoveAll(ensured.RootPath); err != nil {
|
||||
t.Fatalf("RemoveAll(worktree) error = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(ensured.RootPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected worktree path to be removed, stat err = %v", err)
|
||||
}
|
||||
|
||||
ensured, runtime, err = service.Ensure(ctx, ws.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Ensure() repair error = %v", err)
|
||||
}
|
||||
if !runtime.Ensured {
|
||||
t.Fatalf("expected repaired ensure to succeed, got %#v", runtime)
|
||||
}
|
||||
if _, err := os.Stat(ensured.RootPath); err != nil {
|
||||
t.Fatalf("Stat(repaired worktree) error = %v", err)
|
||||
}
|
||||
if ensured.ProvisionState != "ready" {
|
||||
t.Fatalf("expected workspace to be ready after repair, got %#v", ensured)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package workspaces
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"inbox/internal/base/slug"
|
||||
"inbox/internal/domain/workspace"
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
ListProjects(ctx context.Context) ([]workspace.Project, error)
|
||||
CreateProject(ctx context.Context, value workspace.Project) (workspace.Project, error)
|
||||
ListWorkspaces(ctx context.Context, projectID string) ([]workspace.Workspace, error)
|
||||
CreateWorkspace(ctx context.Context, value workspace.Workspace) (workspace.Workspace, error)
|
||||
GetWorkspace(ctx context.Context, workspaceID string) (workspace.Workspace, error)
|
||||
GetWorkspaceBySlugOrName(ctx context.Context, value string) (workspace.Workspace, error)
|
||||
UpdateWorkspace(ctx context.Context, value workspace.Workspace) error
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repo Repository
|
||||
}
|
||||
|
||||
func NewService(repo Repository) *Service {
|
||||
return &Service{
|
||||
repo: repo,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) ListProjects(ctx context.Context) ([]workspace.Project, error) {
|
||||
return s.repo.ListProjects(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) CreateProject(ctx context.Context, value workspace.Project) (workspace.Project, error) {
|
||||
value.Slug = normalizeSlug(value.Slug, value.Name)
|
||||
value = workspace.NormalizeProjectForCreate(value)
|
||||
return s.repo.CreateProject(ctx, value)
|
||||
}
|
||||
|
||||
func (s *Service) ListWorkspaces(ctx context.Context, projectID string) ([]workspace.Workspace, error) {
|
||||
return s.repo.ListWorkspaces(ctx, strings.TrimSpace(projectID))
|
||||
}
|
||||
|
||||
func (s *Service) CreateWorkspace(ctx context.Context, value workspace.Workspace) (workspace.Workspace, error) {
|
||||
value.Slug = normalizeSlug(value.Slug, value.Name)
|
||||
value.RuntimeBackend = workspace.ManagedRuntimeBackend
|
||||
value = workspace.NormalizeWorkspaceForCreate(value)
|
||||
return s.repo.CreateWorkspace(ctx, value)
|
||||
}
|
||||
|
||||
func (s *Service) Get(ctx context.Context, workspaceID string) (workspace.Workspace, error) {
|
||||
return s.repo.GetWorkspace(ctx, workspaceID)
|
||||
}
|
||||
|
||||
func (s *Service) GetBySlugOrName(ctx context.Context, value string) (workspace.Workspace, error) {
|
||||
return s.repo.GetWorkspaceBySlugOrName(ctx, strings.TrimSpace(value))
|
||||
}
|
||||
|
||||
func (s *Service) Resolve(ctx context.Context, workspaceID, workspaceValue string) (workspace.Workspace, error) {
|
||||
if workspaceID = strings.TrimSpace(workspaceID); workspaceID != "" {
|
||||
item, err := s.repo.GetWorkspace(ctx, workspaceID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return workspace.Workspace{}, fmt.Errorf("workspace not found: %s: %w", workspaceID, sql.ErrNoRows)
|
||||
}
|
||||
return workspace.Workspace{}, err
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
if workspaceValue = strings.TrimSpace(workspaceValue); workspaceValue != "" {
|
||||
item, err := s.repo.GetWorkspaceBySlugOrName(ctx, workspaceValue)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return workspace.Workspace{}, fmt.Errorf("workspace not found: %s: %w", workspaceValue, sql.ErrNoRows)
|
||||
}
|
||||
return workspace.Workspace{}, err
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
return workspace.Workspace{}, fmt.Errorf("workspace is required")
|
||||
}
|
||||
|
||||
func normalizeSlug(explicit, fallback string) string {
|
||||
if value := slug.Normalize(explicit); value != "" {
|
||||
return value
|
||||
}
|
||||
return slug.Normalize(fallback)
|
||||
}
|
||||
Reference in New Issue
Block a user