chore(repo): reinitialize repository

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