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
+135
View File
@@ -0,0 +1,135 @@
package httpapi
import (
"database/sql"
"errors"
"net/http"
"os"
"strings"
"inbox/internal/base/httpx"
"inbox/internal/base/timeutil"
"inbox/internal/inboxapp"
sqlitestore "inbox/internal/store/sqlite"
)
type HandlerOptions = inboxapp.Options
type Services = inboxapp.Services
type Handler struct {
Services
}
func NewHandler(store *sqlitestore.Store, clock timeutil.Clock) http.Handler {
return NewHandlerWithOptions(store, clock, HandlerOptions{})
}
func NewHandlerWithOptions(store *sqlitestore.Store, clock timeutil.Clock, opts HandlerOptions) http.Handler {
return NewHandlerWithServices(inboxapp.BuildServices(store, clock, opts))
}
func NewHandlerWithServices(services Services) http.Handler {
handler := &Handler{Services: services}
mux := http.NewServeMux()
handler.register(mux)
return httpx.CORSMiddleware(mux)
}
func writeSystemFSError(w http.ResponseWriter, err error) {
var pathErr *os.PathError
if isValidationError(err) || errors.As(err, &pathErr) {
httpx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
httpx.WriteError(w, http.StatusInternalServerError, err.Error())
}
func (h *Handler) register(mux *http.ServeMux) {
mux.HandleFunc("GET /api/v2/config/roles", h.listRoles)
mux.HandleFunc("GET /api/v2/config/roles/{roleName}", h.getRoleDetail)
mux.HandleFunc("PUT /api/v2/config/roles/{roleName}", h.putRole)
mux.HandleFunc("PUT /api/v2/config/roles/{roleName}/prompts/{promptKind}", h.putRolePrompt)
mux.HandleFunc("PUT /api/v2/config/roles/{roleName}/config", h.putRoleConfig)
mux.HandleFunc("GET /api/v2/config/skills", h.listSkills)
mux.HandleFunc("PUT /api/v2/config/skills/{skillKey}", h.putSkill)
mux.HandleFunc("DELETE /api/v2/config/skills/{skillKey}", h.deleteSkill)
mux.HandleFunc("PUT /api/v2/config/roles/{roleName}/skills/{skillKey}", h.putRoleSkillBinding)
mux.HandleFunc("GET /api/v2/runtime/roles/{roleName}", h.getResolvedRole)
mux.HandleFunc("GET /api/v2/projects", h.listProjects)
mux.HandleFunc("POST /api/v2/projects", h.createProject)
mux.HandleFunc("GET /api/v2/workspaces", h.listWorkspaces)
mux.HandleFunc("POST /api/v2/workspaces", h.createWorkspace)
mux.HandleFunc("POST /api/v2/workspaces/provision", h.provisionWorkspace)
mux.HandleFunc("GET /api/v2/topics", h.listTopics)
mux.HandleFunc("POST /api/v2/topics", h.createTopic)
mux.HandleFunc("GET /api/v2/topics/{topicID}", h.getTopic)
mux.HandleFunc("POST /api/v2/topics/{topicID}/confirm", h.confirmTopicPlan)
mux.HandleFunc("POST /api/v2/topics/{topicID}/stop", h.stopTopic)
mux.HandleFunc("DELETE /api/v2/topics/{topicID}", h.deleteTopic)
mux.HandleFunc("GET /api/v2/topics/{topicID}/messages", h.listTopicMessages)
mux.HandleFunc("POST /api/v2/topics/{topicID}/messages", h.createTopicMessage)
mux.HandleFunc("GET /api/v2/topics/{topicID}/lanes", h.listLanes)
mux.HandleFunc("POST /api/v2/topics/{topicID}/lanes", h.createLane)
mux.HandleFunc("GET /api/v2/lanes/{laneID}", h.getLane)
mux.HandleFunc("PATCH /api/v2/lanes/{laneID}", h.patchLane)
mux.HandleFunc("POST /api/v2/lanes/{laneID}/start", h.startLane)
mux.HandleFunc("POST /api/v2/lanes/{laneID}/stop", h.stopLane)
mux.HandleFunc("GET /api/v2/topics/{topicID}/tasks", h.listTasks)
mux.HandleFunc("POST /api/v2/topics/{topicID}/tasks", h.createTask)
mux.HandleFunc("GET /api/v2/lanes/{laneID}/tasks", h.listLaneTasks)
mux.HandleFunc("GET /api/v2/tasks/{taskID}", h.getTask)
mux.HandleFunc("PATCH /api/v2/tasks/{taskID}", h.patchTask)
mux.HandleFunc("GET /api/v2/tasks/{taskID}/dependencies", h.listTaskDependencies)
mux.HandleFunc("GET /api/v2/tasks/{taskID}/events", h.listTaskEvents)
mux.HandleFunc("POST /api/v2/tasks/{taskID}/events", h.appendTaskEvent)
mux.HandleFunc("GET /api/v2/topics/{topicID}/human-tasks", h.listTopicHumanTasks)
mux.HandleFunc("POST /api/v2/human-tasks/{taskID}/answer", h.answerHumanTask)
mux.HandleFunc("GET /api/v2/topics/{topicID}/workflow-runs", h.listWorkflowRuns)
mux.HandleFunc("POST /api/v2/topics/{topicID}/workflow-runs", h.createWorkflowRun)
mux.HandleFunc("GET /api/v2/workflow-runs/{runID}", h.getWorkflowRun)
mux.HandleFunc("PATCH /api/v2/workflow-runs/{runID}", h.patchWorkflowRun)
mux.HandleFunc("GET /api/v2/workflow-runs/{runID}/logs", h.listWorkflowRunLogs)
mux.HandleFunc("POST /api/v2/workflow-runs/{runID}/logs", h.appendWorkflowRunLog)
mux.HandleFunc("GET /api/v2/system/fs/dirs", h.listDirectories)
mux.HandleFunc("POST /api/v2/system/fs/dirs", h.createDirectory)
mux.HandleFunc("GET /api/v2/dashboard/messages", h.dashboardMessages)
mux.HandleFunc("GET /api/v2/dashboard/topics", h.dashboardTopics)
mux.HandleFunc("GET /api/v2/dashboard/topics/records", h.dashboardTopicRecords)
mux.HandleFunc("GET /api/v2/dashboard/roles", h.dashboardRoles)
mux.HandleFunc("GET /api/v2/dashboard/dispatch", h.dashboardDispatch)
mux.HandleFunc("GET /api/v2/dashboard/dispatch/live", h.dashboardDispatchLive)
mux.HandleFunc("GET /api/v2/dashboard/workflow/board", h.dashboardWorkflowBoard)
mux.HandleFunc("GET /api/v2/dashboard/spaces/{space}/topics", h.dashboardSpaceTopics)
mux.HandleFunc("GET /api/v2/dashboard/spaces/{space}/messages", h.dashboardSpaceMessages)
mux.HandleFunc("POST /api/v2/runtime/lanes/{laneID}/tasks/next", h.runtimeNextTaskExecution)
mux.HandleFunc("POST /api/v2/runtime/task-executions/{runID}/complete", h.runtimeTaskExecutionComplete)
}
func writeStoreError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, sql.ErrNoRows):
httpx.WriteError(w, http.StatusNotFound, err.Error())
case isValidationError(err):
httpx.WriteError(w, http.StatusBadRequest, err.Error())
default:
httpx.WriteError(w, http.StatusInternalServerError, err.Error())
}
}
func isValidationError(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "required") || strings.Contains(msg, "invalid")
}
+126
View File
@@ -0,0 +1,126 @@
package httpapi
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"inbox/internal/base/timeutil"
sqlitestore "inbox/internal/store/sqlite"
)
func TestConfigAPIResolveRole(t *testing.T) {
clock := timeutil.FixedClock{Time: time.Date(2026, 3, 13, 15, 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())
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/project', 'main', 'worktree/main', 'local', 'active', ?, ?)
`, now, now); err != nil {
t.Fatalf("insert workspace: %v", err)
}
handler := NewHandler(store, clock)
mustDoJSON(t, handler, http.MethodPut, "/api/v2/config/roles/leader", map[string]any{
"title": "Leader",
"is_enabled": true,
"is_builtin": true,
"updated_by": "tester",
})
mustDoJSON(t, handler, http.MethodPut, "/api/v2/config/roles/leader/prompts/system", map[string]any{
"content_markdown": "global-system",
"updated_by": "tester",
})
mustDoJSON(t, handler, http.MethodPut, "/api/v2/config/roles/leader/prompts/system", map[string]any{
"workspace_id": "ws_1",
"content_markdown": "workspace-system",
"updated_by": "tester",
})
mustDoJSON(t, handler, http.MethodPut, "/api/v2/config/roles/leader/config", map[string]any{
"config_toml": "model = \"gpt-global\"",
"auth_json": "{\"OPENAI_API_KEY\":\"token-1\"}",
"updated_by": "tester",
})
mustDoJSON(t, handler, http.MethodPut, "/api/v2/config/skills/clarify", map[string]any{
"name": "Clarify",
"source_type": "custom",
"content_markdown": "clarify skill",
"status": "active",
"updated_by": "tester",
})
mustDoJSON(t, handler, http.MethodPut, "/api/v2/config/roles/leader/skills/clarify", map[string]any{
"workspace_id": "ws_1",
"is_enabled": true,
"sort_order": 5,
"updated_by": "tester",
})
req := httptest.NewRequest(http.MethodGet, "/api/v2/runtime/roles/leader?workspace_id=ws_1", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("GET resolved role status = %d body=%s", rec.Code, rec.Body.String())
}
var payload struct {
Config struct {
ConfigTOML string `json:"config_toml"`
} `json:"config"`
Prompts map[string]struct {
ContentMarkdown string `json:"content_markdown"`
} `json:"prompts"`
Skills []struct {
Skill struct {
SkillKey string `json:"skill_key"`
} `json:"skill"`
} `json:"skills"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload.Config.ConfigTOML != "model = \"gpt-global\"" {
t.Fatalf("expected global config_toml, got %q", payload.Config.ConfigTOML)
}
if payload.Prompts["system"].ContentMarkdown != "workspace-system" {
t.Fatalf("expected workspace prompt, got %#v", payload.Prompts["system"])
}
if len(payload.Skills) != 2 {
t.Fatalf("expected 2 skills in payload, got %#v", payload.Skills)
}
if payload.Skills[0].Skill.SkillKey != "clarify" {
t.Fatalf("expected workspace skill clarify first, got %#v", payload.Skills)
}
if payload.Skills[1].Skill.SkillKey != "inbox" {
t.Fatalf("expected builtin inbox skill in payload, got %#v", payload.Skills)
}
}
func mustDoJSON(t *testing.T, handler http.Handler, method, path string, payload map[string]any) {
t.Helper()
body, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal payload: %v", err)
}
req := httptest.NewRequest(method, path, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("%s %s status = %d body=%s", method, path, rec.Code, rec.Body.String())
}
}
+173
View File
@@ -0,0 +1,173 @@
package httpapi
import (
"fmt"
"net/http"
"strconv"
"strings"
"inbox/internal/base/httpx"
"inbox/internal/domain/topic"
"inbox/internal/domain/workspace"
)
func (h *Handler) dashboardMessages(w http.ResponseWriter, r *http.Request) {
h.writeWorkspacePayload(w, r, http.StatusOK, func(ws workspace.Workspace) (any, error) {
return h.Dashboard.Messages(r.Context(), ws)
})
}
func (h *Handler) dashboardTopics(w http.ResponseWriter, r *http.Request) {
h.writeWorkspacePayload(w, r, http.StatusOK, func(ws workspace.Workspace) (any, error) {
return h.Dashboard.Topics(r.Context(), ws)
})
}
func (h *Handler) dashboardTopicRecords(w http.ResponseWriter, r *http.Request) {
spaceFilter := strings.TrimSpace(r.URL.Query().Get("space"))
h.writeWorkspacePayload(w, r, http.StatusOK, func(ws workspace.Workspace) (any, error) {
return h.Dashboard.TopicRecords(r.Context(), ws, spaceFilter)
})
}
func (h *Handler) dashboardSpaceTopics(w http.ResponseWriter, r *http.Request) {
space, err := parseTopicSpace(r.PathValue("space"))
if err != nil {
httpx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
h.writeWorkspacePayload(w, r, http.StatusOK, func(ws workspace.Workspace) (any, error) {
return h.Dashboard.SpaceTopics(r.Context(), ws, space)
})
}
func (h *Handler) dashboardSpaceMessages(w http.ResponseWriter, r *http.Request) {
space, err := parseTopicSpace(r.PathValue("space"))
if err != nil {
httpx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
topicSlug := strings.TrimSpace(r.URL.Query().Get("topic"))
if topicSlug == "" {
httpx.WriteError(w, http.StatusBadRequest, "topic is required")
return
}
h.writeWorkspacePayload(w, r, http.StatusOK, func(ws workspace.Workspace) (any, error) {
return h.Dashboard.SpaceMessages(r.Context(), ws, space, topicSlug)
})
}
func (h *Handler) dashboardDispatch(w http.ResponseWriter, r *http.Request) {
h.writeWorkspacePayload(w, r, http.StatusOK, func(ws workspace.Workspace) (any, error) {
return h.Dashboard.Dispatch(r.Context(), ws)
})
}
func (h *Handler) dashboardDispatchLive(w http.ResponseWriter, r *http.Request) {
topicSlug := strings.TrimSpace(r.URL.Query().Get("topic"))
roleName := strings.TrimSpace(r.URL.Query().Get("role"))
if topicSlug == "" || roleName == "" {
httpx.WriteError(w, http.StatusBadRequest, "topic and role are required")
return
}
afterSeq, err := parseIntQuery(r, "offset")
if err != nil {
httpx.WriteError(w, http.StatusBadRequest, "offset must be an integer")
return
}
h.writeWorkspacePayload(w, r, http.StatusOK, func(ws workspace.Workspace) (any, error) {
return h.Dashboard.DispatchLive(r.Context(), ws, topicSlug, roleName, afterSeq)
})
}
func (h *Handler) dashboardRoles(w http.ResponseWriter, r *http.Request) {
ws, ok := h.optionalWorkspace(w, r)
if !ok {
return
}
payload, err := h.Dashboard.Roles(r.Context(), ws)
writePayload(w, http.StatusOK, payload, err)
}
func (h *Handler) dashboardWorkflowBoard(w http.ResponseWriter, r *http.Request) {
activeTopic := strings.TrimSpace(r.URL.Query().Get("topic"))
h.writeWorkspacePayload(w, r, http.StatusOK, func(ws workspace.Workspace) (any, error) {
return h.Dashboard.WorkflowBoard(r.Context(), ws, activeTopic)
})
}
func (h *Handler) resolveWorkspace(r *http.Request) (workspace.Workspace, error) {
return h.Workspaces.Resolve(
r.Context(),
r.URL.Query().Get("workspace_id"),
firstNonEmpty(r.URL.Query().Get("workspace"), r.URL.Query().Get("workspace_slug")),
)
}
func (h *Handler) requireWorkspace(w http.ResponseWriter, r *http.Request) (workspace.Workspace, bool) {
ws, err := h.resolveWorkspace(r)
if err != nil {
writeStoreError(w, err)
return workspace.Workspace{}, false
}
return ws, true
}
func (h *Handler) optionalWorkspace(w http.ResponseWriter, r *http.Request) (*workspace.Workspace, bool) {
if strings.TrimSpace(firstNonEmpty(
r.URL.Query().Get("workspace_id"),
r.URL.Query().Get("workspace"),
r.URL.Query().Get("workspace_slug"),
)) == "" {
return nil, true
}
ws, err := h.resolveWorkspace(r)
if err != nil {
writeStoreError(w, err)
return nil, false
}
return &ws, true
}
func (h *Handler) writeWorkspacePayload(w http.ResponseWriter, r *http.Request, status int, load func(workspace.Workspace) (any, error)) {
ws, ok := h.requireWorkspace(w, r)
if !ok {
return
}
payload, err := load(ws)
writePayload(w, status, payload, err)
}
func writePayload(w http.ResponseWriter, status int, payload any, err error) {
if err != nil {
writeStoreError(w, err)
return
}
httpx.WriteJSON(w, status, payload)
}
func parseTopicSpace(value string) (topic.Space, error) {
switch topic.Space(value) {
case topic.SpaceClarify, topic.SpaceWorkflow:
return topic.Space(value), nil
default:
return "", fmt.Errorf("invalid space %q", value)
}
}
func parseIntQuery(r *http.Request, key string) (int, error) {
value := strings.TrimSpace(r.URL.Query().Get(key))
if value == "" {
return 0, nil
}
return strconv.Atoi(value)
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
+81
View File
@@ -0,0 +1,81 @@
package httpapi
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"inbox/internal/base/timeutil"
sqlitestore "inbox/internal/store/sqlite"
)
func TestDashboardWorkflowBoardMissingWorkspace(t *testing.T) {
clock := timeutil.FixedClock{Time: time.Date(2026, 3, 13, 18, 30, 0, 0, time.UTC)}
store, err := sqlitestore.OpenInMemory(clock)
if err != nil {
t.Fatalf("OpenInMemory() error = %v", err)
}
defer store.Close()
handler := NewHandler(store, clock)
req := httptest.NewRequest(http.MethodGet, "/api/v2/dashboard/workflow/board?workspace=blog", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("GET workflow board status = %d body=%s", rec.Code, rec.Body.String())
}
var payload struct {
Error string `json:"error"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if !strings.Contains(payload.Error, "workspace not found: blog") {
t.Fatalf("unexpected error payload: %#v", payload)
}
}
func TestDashboardRolesWithoutWorkspaceReturnsGlobalRoster(t *testing.T) {
clock := timeutil.FixedClock{Time: time.Date(2026, 3, 13, 18, 30, 0, 0, time.UTC)}
store, err := sqlitestore.OpenInMemory(clock)
if err != nil {
t.Fatalf("OpenInMemory() error = %v", err)
}
defer store.Close()
handler := NewHandler(store, clock)
req := httptest.NewRequest(http.MethodGet, "/api/v2/dashboard/roles", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("GET dashboard roles status = %d body=%s", rec.Code, rec.Body.String())
}
var payload struct {
Roles []struct {
Name string `json:"name"`
Pending int `json:"pending"`
Session any `json:"session"`
} `json:"roles"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(payload.Roles) != 2 {
t.Fatalf("expected 2 enabled builtin roles, got %d", len(payload.Roles))
}
for _, item := range payload.Roles {
if item.Pending != 0 {
t.Fatalf("expected %s pending=0 in global mode, got %d", item.Name, item.Pending)
}
if item.Session != nil {
t.Fatalf("expected %s session=nil in global mode, got %#v", item.Name, item.Session)
}
}
}
+33
View File
@@ -0,0 +1,33 @@
package httpapi
import (
"net/http"
"inbox/internal/base/httpx"
)
func (h *Handler) listTopicHumanTasks(w http.ResponseWriter, r *http.Request) {
items, err := h.HumanTasks.ListByTopic(r.Context(), r.PathValue("topicID"))
if err != nil {
writeStoreError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"tasks": items})
}
func (h *Handler) answerHumanTask(w http.ResponseWriter, r *http.Request) {
type request struct {
BodyMarkdown string `json:"body_markdown"`
}
var req request
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
item, err := h.HumanTasks.Answer(r.Context(), r.PathValue("taskID"), req.BodyMarkdown)
if err != nil {
writeStoreError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, item)
}
+104
View File
@@ -0,0 +1,104 @@
package httpapi
import (
"net/http"
lanesapp "inbox/internal/app/lanes"
"inbox/internal/base/httpx"
"inbox/internal/domain/lane"
)
func (h *Handler) listLanes(w http.ResponseWriter, r *http.Request) {
items, err := h.Lanes.ListByTopic(r.Context(), r.PathValue("topicID"))
if err != nil {
writeStoreError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"lanes": items})
}
func (h *Handler) createLane(w http.ResponseWriter, r *http.Request) {
type request struct {
WorkspaceID string `json:"workspace_id"`
Name string `json:"name"`
Slug string `json:"slug"`
Status string `json:"status"`
BaseBranch string `json:"base_branch"`
CreatedByRoleName string `json:"created_by_role_name"`
}
var req request
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
item, err := h.Lanes.Create(r.Context(), lane.Record{
WorkspaceID: req.WorkspaceID,
TopicID: r.PathValue("topicID"),
Name: req.Name,
Slug: req.Slug,
Status: lane.Status(req.Status),
BaseBranch: req.BaseBranch,
CreatedByRoleName: req.CreatedByRoleName,
})
if err != nil {
writeStoreError(w, err)
return
}
httpx.WriteJSON(w, http.StatusCreated, item)
}
func (h *Handler) getLane(w http.ResponseWriter, r *http.Request) {
item, err := h.Lanes.Get(r.Context(), r.PathValue("laneID"))
if err != nil {
writeStoreError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, item)
}
func (h *Handler) patchLane(w http.ResponseWriter, r *http.Request) {
type request struct {
Name *string `json:"name"`
Status *lane.Status `json:"status"`
ResultSummaryMarkdown *string `json:"result_summary_markdown"`
ErrorMessage *string `json:"error_message"`
StartedAt *string `json:"started_at"`
CompletedAt *string `json:"completed_at"`
}
var req request
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
item, err := h.Lanes.Patch(r.Context(), r.PathValue("laneID"), lanesapp.Patch{
Name: req.Name,
Status: req.Status,
ResultSummaryMarkdown: req.ResultSummaryMarkdown,
ErrorMessage: req.ErrorMessage,
StartedAt: req.StartedAt,
CompletedAt: req.CompletedAt,
})
if err != nil {
writeStoreError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, item)
}
func (h *Handler) startLane(w http.ResponseWriter, r *http.Request) {
item, err := h.Lanes.Start(r.Context(), r.PathValue("laneID"))
if err != nil {
writeStoreError(w, err)
return
}
httpx.WriteJSON(w, http.StatusAccepted, item)
}
func (h *Handler) stopLane(w http.ResponseWriter, r *http.Request) {
item, err := h.Lanes.Stop(r.Context(), r.PathValue("laneID"))
if err != nil {
writeStoreError(w, err)
return
}
httpx.WriteJSON(w, http.StatusAccepted, item)
}
+220
View File
@@ -0,0 +1,220 @@
package httpapi
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"inbox/internal/base/timeutil"
sqlitestore "inbox/internal/store/sqlite"
)
func TestResourceAPIFullFlow(t *testing.T) {
clock := timeutil.FixedClock{Time: time.Date(2026, 3, 13, 18, 0, 0, 0, time.UTC)}
store, err := sqlitestore.OpenInMemory(clock)
if err != nil {
t.Fatalf("OpenInMemory() error = %v", err)
}
defer store.Close()
handler := NewHandler(store, clock)
workspaceRoot := t.TempDir()
roleResp := mustDoJSONResponse(t, handler, http.MethodPut, "/api/v2/config/roles/leader", map[string]any{
"title": "Leader",
"is_enabled": true,
"is_builtin": true,
"updated_by": "tester",
})
_ = roleResp
mustDoJSON(t, handler, http.MethodPut, "/api/v2/config/roles/worker", map[string]any{
"title": "Worker",
"is_enabled": true,
"is_builtin": true,
"updated_by": "tester",
})
mustDoJSON(t, handler, http.MethodPut, "/api/v2/config/roles/worker/config", map[string]any{
"config_toml": "model = \"gpt-5.4\"",
"auth_json": "{\"OPENAI_API_KEY\":\"token-1\"}",
"updated_by": "tester",
})
projectResp := mustDoJSONResponse(t, handler, http.MethodPost, "/api/v2/projects", map[string]any{
"slug": "demo",
"name": "Demo",
"root_path": workspaceRoot,
"default_branch": "main",
})
var project struct {
ID string `json:"id"`
}
decodeBody(t, projectResp.Body.Bytes(), &project)
workspaceResp := mustDoJSONResponse(t, handler, http.MethodPost, "/api/v2/workspaces", map[string]any{
"project_id": project.ID,
"slug": "main",
"name": "Main",
"root_path": workspaceRoot,
"base_branch": "main",
"worktree_branch": "worktree/main",
})
var ws struct {
ID string `json:"id"`
}
decodeBody(t, workspaceResp.Body.Bytes(), &ws)
topicResp := mustDoJSONResponse(t, handler, http.MethodPost, "/api/v2/topics", map[string]any{
"workspace_id": ws.ID,
"title": "Signup Flow",
"space": "workflow",
"status": "execution",
})
var topic struct {
ID string `json:"id"`
}
decodeBody(t, topicResp.Body.Bytes(), &topic)
msgResp := mustDoJSONResponse(t, handler, http.MethodPost, "/api/v2/topics/"+topic.ID+"/messages", map[string]any{
"workspace_id": ws.ID,
"from_role_name": "leader",
"to_expr": "worker",
"type": "decision",
"stage": "execution",
"body_markdown": "Please implement signup.",
})
var msg struct {
ID string `json:"id"`
}
decodeBody(t, msgResp.Body.Bytes(), &msg)
laneResp := mustDoJSONResponse(t, handler, http.MethodPost, "/api/v2/topics/"+topic.ID+"/lanes", map[string]any{
"workspace_id": ws.ID,
"name": "UI Chain",
"slug": "ui-chain",
"status": "ready",
"base_branch": "main",
"created_by_role_name": "leader",
})
var laneRecord struct {
ID string `json:"id"`
}
decodeBody(t, laneResp.Body.Bytes(), &laneRecord)
if laneRecord.ID == "" {
t.Fatal("expected lane id")
}
taskResp := mustDoJSONResponse(t, handler, http.MethodPost, "/api/v2/topics/"+topic.ID+"/tasks", map[string]any{
"workspace_id": ws.ID,
"lane_id": laneRecord.ID,
"title": "Implement signup flow",
"body_markdown": "Create leader-managed signup execution.",
"status": "ready",
"priority": 9,
"task_order": 1,
"created_by_role_name": "leader",
})
var taskRecord struct {
ID string `json:"id"`
}
decodeBody(t, taskResp.Body.Bytes(), &taskRecord)
if taskRecord.ID == "" {
t.Fatal("expected task id")
}
runResp := mustDoJSONResponse(t, handler, http.MethodPost, "/api/v2/topics/"+topic.ID+"/workflow-runs", map[string]any{
"workspace_id": ws.ID,
"role_name": "worker",
"stage": "execution",
"mode": "once",
"request_message_id": msg.ID,
})
var run struct {
ID string `json:"id"`
ConfigSnapshotJSON string `json:"config_snapshot_json"`
}
decodeBody(t, runResp.Body.Bytes(), &run)
if run.ID == "" || run.ConfigSnapshotJSON == "" || run.ConfigSnapshotJSON == "{}" {
t.Fatalf("unexpected workflow run response: %#v", run)
}
runDetailReq := httptest.NewRequest(http.MethodGet, "/api/v2/workflow-runs/"+run.ID, nil)
runDetailRec := httptest.NewRecorder()
handler.ServeHTTP(runDetailRec, runDetailReq)
if runDetailRec.Code != http.StatusOK {
t.Fatalf("GET workflow run status = %d body=%s", runDetailRec.Code, runDetailRec.Body.String())
}
mustDoJSONResponse(t, handler, http.MethodPatch, "/api/v2/workflow-runs/"+run.ID, map[string]any{
"status": "succeeded",
"exit_code": 0,
})
mustDoJSONResponse(t, handler, http.MethodPost, "/api/v2/workflow-runs/"+run.ID+"/logs", map[string]any{
"stream": "stdout",
"content": "step 1",
})
mustDoJSONResponse(t, handler, http.MethodPost, "/api/v2/workflow-runs/"+run.ID+"/logs", map[string]any{
"stream": "stderr",
"content": "step 2",
})
checkTopicList := httptest.NewRequest(http.MethodGet, "/api/v2/topics?workspace_id="+ws.ID, nil)
checkTopicRec := httptest.NewRecorder()
handler.ServeHTTP(checkTopicRec, checkTopicList)
if checkTopicRec.Code != http.StatusOK {
t.Fatalf("GET /api/v2/topics status = %d body=%s", checkTopicRec.Code, checkTopicRec.Body.String())
}
checkRuns := httptest.NewRequest(http.MethodGet, "/api/v2/topics/"+topic.ID+"/workflow-runs", nil)
checkRunsRec := httptest.NewRecorder()
handler.ServeHTTP(checkRunsRec, checkRuns)
if checkRunsRec.Code != http.StatusOK {
t.Fatalf("GET workflow runs status = %d body=%s", checkRunsRec.Code, checkRunsRec.Body.String())
}
checkRunLogs := httptest.NewRequest(http.MethodGet, "/api/v2/workflow-runs/"+run.ID+"/logs?after_seq=0", nil)
checkRunLogsRec := httptest.NewRecorder()
handler.ServeHTTP(checkRunLogsRec, checkRunLogs)
if checkRunLogsRec.Code != http.StatusOK {
t.Fatalf("GET workflow run logs status = %d body=%s", checkRunLogsRec.Code, checkRunLogsRec.Body.String())
}
checkLanes := httptest.NewRequest(http.MethodGet, "/api/v2/topics/"+topic.ID+"/lanes", nil)
checkLanesRec := httptest.NewRecorder()
handler.ServeHTTP(checkLanesRec, checkLanes)
if checkLanesRec.Code != http.StatusOK {
t.Fatalf("GET lanes status = %d body=%s", checkLanesRec.Code, checkLanesRec.Body.String())
}
checkTasks := httptest.NewRequest(http.MethodGet, "/api/v2/topics/"+topic.ID+"/tasks", nil)
checkTasksRec := httptest.NewRecorder()
handler.ServeHTTP(checkTasksRec, checkTasks)
if checkTasksRec.Code != http.StatusOK {
t.Fatalf("GET tasks status = %d body=%s", checkTasksRec.Code, checkTasksRec.Body.String())
}
}
func mustDoJSONResponse(t *testing.T, handler http.Handler, method, path string, payload map[string]any) *httptest.ResponseRecorder {
t.Helper()
body, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal payload: %v", err)
}
req := httptest.NewRequest(method, path, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK && rec.Code != http.StatusCreated {
t.Fatalf("%s %s status = %d body=%s", method, path, rec.Code, rec.Body.String())
}
return rec
}
func decodeBody(t *testing.T, body []byte, dst any) {
t.Helper()
if err := json.Unmarshal(body, dst); err != nil {
t.Fatalf("decode body: %v body=%s", err, string(body))
}
}
+139
View File
@@ -0,0 +1,139 @@
package httpapi
import (
"net/http"
"inbox/internal/base/httpx"
"inbox/internal/domain/role"
)
func (h *Handler) listRoles(w http.ResponseWriter, r *http.Request) {
items, err := h.Roles.List(r.Context())
if err != nil {
writeStoreError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"roles": items})
}
func (h *Handler) getRoleDetail(w http.ResponseWriter, r *http.Request) {
item, err := h.Roles.GetDetail(r.Context(), r.PathValue("roleName"), r.URL.Query().Get("workspace_id"))
if err != nil {
writeStoreError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, item)
}
func (h *Handler) putRole(w http.ResponseWriter, r *http.Request) {
type request struct {
Title string `json:"title"`
ExecutorKind role.ExecutorKind `json:"executor_kind"`
Description string `json:"description"`
IsEnabled bool `json:"is_enabled"`
IsBuiltin bool `json:"is_builtin"`
SortOrder int `json:"sort_order"`
UpdatedBy string `json:"updated_by"`
}
var req request
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
item, err := h.Roles.Upsert(r.Context(), role.Definition{
Name: r.PathValue("roleName"),
Title: req.Title,
ExecutorKind: req.ExecutorKind,
Description: req.Description,
IsEnabled: req.IsEnabled,
IsBuiltin: req.IsBuiltin,
SortOrder: req.SortOrder,
}, req.UpdatedBy)
if err != nil {
writeStoreError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, item)
}
func (h *Handler) putRolePrompt(w http.ResponseWriter, r *http.Request) {
type request struct {
WorkspaceID string `json:"workspace_id"`
ContentMarkdown string `json:"content_markdown"`
UpdatedBy string `json:"updated_by"`
}
var req request
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
item, err := h.Roles.UpsertPrompt(r.Context(), role.Prompt{
RoleName: r.PathValue("roleName"),
WorkspaceID: req.WorkspaceID,
PromptKind: role.PromptKind(r.PathValue("promptKind")),
ContentMarkdown: req.ContentMarkdown,
}, req.UpdatedBy)
if err != nil {
writeStoreError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, item)
}
func (h *Handler) putRoleConfig(w http.ResponseWriter, r *http.Request) {
type request struct {
ConfigTOML string `json:"config_toml"`
AuthJSON string `json:"auth_json"`
UpdatedBy string `json:"updated_by"`
}
var req request
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
item, err := h.Roles.UpsertConfig(r.Context(), role.Config{
RoleName: r.PathValue("roleName"),
ConfigTOML: req.ConfigTOML,
AuthJSON: req.AuthJSON,
}, req.UpdatedBy)
if err != nil {
writeStoreError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, item)
}
func (h *Handler) putRoleSkillBinding(w http.ResponseWriter, r *http.Request) {
type request struct {
WorkspaceID string `json:"workspace_id"`
IsEnabled bool `json:"is_enabled"`
SortOrder int `json:"sort_order"`
Config map[string]any `json:"config"`
UpdatedBy string `json:"updated_by"`
}
var req request
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
item, err := h.Roles.UpsertSkillBinding(r.Context(), r.PathValue("roleName"), r.PathValue("skillKey"), role.SkillBinding{
WorkspaceID: req.WorkspaceID,
IsEnabled: req.IsEnabled,
SortOrder: req.SortOrder,
Config: req.Config,
}, req.UpdatedBy)
if err != nil {
writeStoreError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, item)
}
func (h *Handler) getResolvedRole(w http.ResponseWriter, r *http.Request) {
item, err := h.RuntimeConfig.ResolveRole(r.Context(), r.URL.Query().Get("workspace_id"), r.PathValue("roleName"))
if err != nil {
writeStoreError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, item)
}
+54
View File
@@ -0,0 +1,54 @@
package httpapi
import (
"net/http"
"inbox/internal/base/httpx"
"inbox/internal/domain/skill"
)
func (h *Handler) listSkills(w http.ResponseWriter, r *http.Request) {
items, err := h.Skills.List(r.Context())
if err != nil {
writeStoreError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"skills": items})
}
func (h *Handler) putSkill(w http.ResponseWriter, r *http.Request) {
type request struct {
Name string `json:"name"`
Description string `json:"description"`
SourceType string `json:"source_type"`
ContentMarkdown string `json:"content_markdown"`
Status string `json:"status"`
UpdatedBy string `json:"updated_by"`
}
var req request
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
item, err := h.Skills.Upsert(r.Context(), skill.Definition{
SkillKey: r.PathValue("skillKey"),
Name: req.Name,
Description: req.Description,
SourceType: req.SourceType,
ContentMarkdown: req.ContentMarkdown,
Status: req.Status,
}, req.UpdatedBy)
if err != nil {
writeStoreError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, item)
}
func (h *Handler) deleteSkill(w http.ResponseWriter, r *http.Request) {
if err := h.Skills.Delete(r.Context(), r.PathValue("skillKey")); err != nil {
writeStoreError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
+34
View File
@@ -0,0 +1,34 @@
package httpapi
import (
"net/http"
"inbox/internal/base/httpx"
)
func (h *Handler) listDirectories(w http.ResponseWriter, r *http.Request) {
listing, err := h.SystemFS.ListDirectories(r.URL.Query().Get("path"))
if err != nil {
writeSystemFSError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, listing)
}
func (h *Handler) createDirectory(w http.ResponseWriter, r *http.Request) {
type request struct {
Parent string `json:"parent"`
Name string `json:"name"`
}
var req request
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
path, err := h.SystemFS.CreateDirectory(req.Parent, req.Name)
if err != nil {
writeSystemFSError(w, err)
return
}
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"path": path})
}
+57
View File
@@ -0,0 +1,57 @@
package httpapi
import (
"database/sql"
"net/http"
taskexecapp "inbox/internal/app/taskexec"
"inbox/internal/base/httpx"
"inbox/internal/domain/workflow"
)
func (h *Handler) runtimeNextTaskExecution(w http.ResponseWriter, r *http.Request) {
type request struct {
RunnerID string `json:"runner_id"`
}
var req request
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
item, err := h.TaskExec.ClaimNext(r.Context(), r.PathValue("laneID"), req.RunnerID)
if err != nil {
if err == sql.ErrNoRows {
w.WriteHeader(http.StatusNoContent)
return
}
writeStoreError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"assignment": item})
}
func (h *Handler) runtimeTaskExecutionComplete(w http.ResponseWriter, r *http.Request) {
type request struct {
Status string `json:"status"`
ExitCode int `json:"exit_code"`
ResultMarkdown string `json:"result_markdown"`
ErrorMessage string `json:"error_message"`
}
var req request
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
item, err := h.TaskExec.Complete(r.Context(), taskexecapp.Completion{
RunID: r.PathValue("runID"),
Status: workflow.RunStatus(req.Status),
ExitCode: req.ExitCode,
ResultMarkdown: req.ResultMarkdown,
ErrorMessage: req.ErrorMessage,
})
if err != nil {
writeStoreError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, item)
}
@@ -0,0 +1,211 @@
package httpapi
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"inbox/internal/base/timeutil"
"inbox/internal/domain/lane"
"inbox/internal/domain/lanesync"
"inbox/internal/domain/task"
"inbox/internal/domain/topic"
"inbox/internal/domain/workspace"
sqlitestore "inbox/internal/store/sqlite"
)
func TestDashboardWorkflowBoardIncludesLanesAndTasks(t *testing.T) {
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()
ctx := context.Background()
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)
}
record, err := store.CreateTopic(ctx, topic.Record{
WorkspaceID: ws.ID,
Slug: "signup-flow",
Title: "Signup Flow",
Space: topic.SpaceWorkflow,
Status: "execution",
})
if err != nil {
t.Fatalf("CreateTopic() error = %v", err)
}
laneRecord, err := store.CreateLane(ctx, lane.Record{
WorkspaceID: ws.ID,
TopicID: record.ID,
Name: "UI Chain",
Slug: "ui-chain",
Status: lane.StatusRunning,
BaseBranch: "main",
BranchName: "lane/main/ui-lane",
HeadCommit: "abc123def4567890",
WorktreePath: "/tmp/ui-chain",
ContainerName: "lane-main-ui-lane",
CreatedByRoleName: "leader",
})
if err != nil {
t.Fatalf("CreateLane() error = %v", err)
}
upstreamLane, err := store.CreateLane(ctx, lane.Record{
WorkspaceID: ws.ID,
TopicID: record.ID,
Name: "Foundation",
Slug: "foundation",
Status: lane.StatusSucceeded,
BaseBranch: "main",
BranchName: "lane/main/foundation",
HeadCommit: "1234567890abcdef",
WorktreePath: "/tmp/foundation",
ContainerName: "lane-main-foundation",
CreatedByRoleName: "leader",
})
if err != nil {
t.Fatalf("CreateLane(upstream) error = %v", err)
}
rootTask, err := store.CreateTask(ctx, task.Record{
WorkspaceID: ws.ID,
TopicID: record.ID,
LaneID: laneRecord.ID,
Title: "Build UI",
BodyMarkdown: "Implement the UI lane.",
Status: task.StatusRunning,
TaskOrder: 1,
Priority: 10,
CreatedByRoleName: "leader",
}, nil)
if err != nil {
t.Fatalf("CreateTask(root) error = %v", err)
}
if _, err := store.CreateTask(ctx, task.Record{
WorkspaceID: ws.ID,
TopicID: record.ID,
LaneID: laneRecord.ID,
Title: "Verify UI",
BodyMarkdown: "Verify the UI lane.",
Status: task.StatusDraft,
TaskOrder: 2,
Priority: 5,
CreatedByRoleName: "leader",
}, []task.Dependency{{DependsOnTaskID: rootTask.ID}}); err != nil {
t.Fatalf("CreateTask(dependent) error = %v", err)
}
if _, err := store.CreateLaneSync(ctx, lanesync.Record{
WorkspaceID: ws.ID,
TopicID: record.ID,
DownstreamLaneID: laneRecord.ID,
UpstreamLaneID: upstreamLane.ID,
TaskID: rootTask.ID,
UpstreamCommit: "1234567890abcdef",
MergeCommit: "fedcba0987654321",
Status: lanesync.StatusApplied,
}); err != nil {
t.Fatalf("CreateLaneSync() error = %v", err)
}
handler := NewHandler(store, clock)
req := httptest.NewRequest(http.MethodGet, "/api/v2/dashboard/workflow/board?workspace="+ws.Slug+"&topic="+record.Slug, nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("GET workflow board status = %d body=%s", rec.Code, rec.Body.String())
}
var payload struct {
Board struct {
Lanes []struct {
Name string `json:"name"`
Status string `json:"status"`
HeadCommit string `json:"head_commit"`
LastSync *struct {
Status string `json:"status"`
UpstreamLaneID string `json:"upstream_lane_id"`
} `json:"last_sync"`
SyncHistory []struct {
Status string `json:"status"`
} `json:"sync_history"`
} `json:"lanes"`
Tasks []struct {
Title string `json:"title"`
Status string `json:"status"`
LaneID string `json:"lane_id"`
Dependencies []struct {
DependsOnTaskID string `json:"depends_on_task_id"`
} `json:"dependencies"`
} `json:"tasks"`
} `json:"board"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode workflow board: %v", err)
}
if len(payload.Board.Lanes) != 2 {
t.Fatalf("unexpected lanes payload: %#v", payload.Board.Lanes)
}
var uiLane *struct {
Name string `json:"name"`
Status string `json:"status"`
HeadCommit string `json:"head_commit"`
LastSync *struct {
Status string `json:"status"`
UpstreamLaneID string `json:"upstream_lane_id"`
} `json:"last_sync"`
SyncHistory []struct {
Status string `json:"status"`
} `json:"sync_history"`
}
for idx := range payload.Board.Lanes {
if payload.Board.Lanes[idx].Name == "UI Chain" {
uiLane = &payload.Board.Lanes[idx]
break
}
}
if uiLane == nil {
t.Fatalf("expected UI Chain in lanes payload, got %#v", payload.Board.Lanes)
}
if uiLane.HeadCommit != "abc123def4567890" {
t.Fatalf("expected head commit in lanes payload, got %#v", uiLane)
}
if uiLane.LastSync == nil || uiLane.LastSync.Status != "applied" || uiLane.LastSync.UpstreamLaneID != upstreamLane.ID {
t.Fatalf("expected latest lane sync in payload, got %#v", uiLane)
}
if len(uiLane.SyncHistory) != 1 {
t.Fatalf("expected sync history in payload, got %#v", uiLane)
}
if len(payload.Board.Tasks) != 2 {
t.Fatalf("unexpected tasks payload: %#v", payload.Board.Tasks)
}
if payload.Board.Tasks[0].LaneID != laneRecord.ID {
t.Fatalf("expected lane id on task payload, got %#v", payload.Board.Tasks[0])
}
if payload.Board.Tasks[1].Dependencies[0].DependsOnTaskID != rootTask.ID {
t.Fatalf("expected dependency on root task, got %#v", payload.Board.Tasks[1])
}
}
+195
View File
@@ -0,0 +1,195 @@
package httpapi
import (
"net/http"
tasksapp "inbox/internal/app/tasks"
"inbox/internal/base/httpx"
"inbox/internal/domain/task"
)
func (h *Handler) listTasks(w http.ResponseWriter, r *http.Request) {
items, err := h.Tasks.ListByTopic(r.Context(), r.PathValue("topicID"))
if err != nil {
writeStoreError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"tasks": items})
}
func (h *Handler) createTask(w http.ResponseWriter, r *http.Request) {
type dependency struct {
DependsOnTaskID string `json:"depends_on_task_id"`
}
type request struct {
WorkspaceID string `json:"workspace_id"`
LaneID string `json:"lane_id"`
Title string `json:"title"`
BodyMarkdown string `json:"body_markdown"`
AcceptanceMarkdown string `json:"acceptance_markdown"`
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"`
CreatedByRoleName string `json:"created_by_role_name"`
Dependencies []dependency `json:"dependencies"`
}
var req request
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
deps := make([]task.Dependency, 0, len(req.Dependencies))
for _, dep := range req.Dependencies {
deps = append(deps, task.Dependency{
DependsOnTaskID: dep.DependsOnTaskID,
})
}
item, err := h.Tasks.Create(r.Context(), tasksapp.CreateInput{
Task: task.Record{
WorkspaceID: req.WorkspaceID,
TopicID: r.PathValue("topicID"),
LaneID: req.LaneID,
Title: req.Title,
BodyMarkdown: req.BodyMarkdown,
AcceptanceMarkdown: req.AcceptanceMarkdown,
Kind: task.Kind(req.Kind),
Deliverables: req.Deliverables,
BatchKey: req.BatchKey,
Status: task.Status(req.Status),
Priority: req.Priority,
TaskOrder: req.TaskOrder,
CreatedByRoleName: req.CreatedByRoleName,
},
Dependencies: deps,
})
if err != nil {
writeStoreError(w, err)
return
}
httpx.WriteJSON(w, http.StatusCreated, item)
}
func (h *Handler) listLaneTasks(w http.ResponseWriter, r *http.Request) {
items, err := h.Tasks.ListByLane(r.Context(), r.PathValue("laneID"))
if err != nil {
writeStoreError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"tasks": items})
}
func (h *Handler) getTask(w http.ResponseWriter, r *http.Request) {
item, err := h.Tasks.Get(r.Context(), r.PathValue("taskID"))
if err != nil {
writeStoreError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, item)
}
func (h *Handler) patchTask(w http.ResponseWriter, r *http.Request) {
type dependency struct {
DependsOnTaskID string `json:"depends_on_task_id"`
}
type request struct {
Title *string `json:"title"`
BodyMarkdown *string `json:"body_markdown"`
AcceptanceMarkdown *string `json:"acceptance_markdown"`
Kind *task.Kind `json:"kind"`
Deliverables *[]string `json:"deliverables"`
BatchKey *string `json:"batch_key"`
Status *task.Status `json:"status"`
Priority *int `json:"priority"`
TaskOrder *int `json:"task_order"`
BlockingReasonMarkdown *string `json:"blocking_reason_markdown"`
ResultSummaryMarkdown *string `json:"result_summary_markdown"`
AssignedRunID *string `json:"assigned_run_id"`
StartedAt *string `json:"started_at"`
CompletedAt *string `json:"completed_at"`
Dependencies *[]dependency `json:"dependencies"`
UpdatedBy string `json:"updated_by"`
}
var req request
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
var deps *[]task.Dependency
if req.Dependencies != nil {
items := make([]task.Dependency, 0, len(*req.Dependencies))
for _, dep := range *req.Dependencies {
items = append(items, task.Dependency{
TaskID: r.PathValue("taskID"),
DependsOnTaskID: dep.DependsOnTaskID,
})
}
deps = &items
}
item, err := h.Tasks.Patch(r.Context(), r.PathValue("taskID"), tasksapp.Patch{
Title: req.Title,
BodyMarkdown: req.BodyMarkdown,
AcceptanceMarkdown: req.AcceptanceMarkdown,
Kind: req.Kind,
Deliverables: req.Deliverables,
BatchKey: req.BatchKey,
Status: req.Status,
Priority: req.Priority,
TaskOrder: req.TaskOrder,
BlockingReasonMarkdown: req.BlockingReasonMarkdown,
ResultSummaryMarkdown: req.ResultSummaryMarkdown,
AssignedRunID: req.AssignedRunID,
StartedAt: req.StartedAt,
CompletedAt: req.CompletedAt,
Dependencies: deps,
}, req.UpdatedBy)
if err != nil {
writeStoreError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, item)
}
func (h *Handler) listTaskDependencies(w http.ResponseWriter, r *http.Request) {
items, err := h.Tasks.Dependencies(r.Context(), r.PathValue("taskID"))
if err != nil {
writeStoreError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"dependencies": items})
}
func (h *Handler) listTaskEvents(w http.ResponseWriter, r *http.Request) {
items, err := h.Tasks.Events(r.Context(), r.PathValue("taskID"))
if err != nil {
writeStoreError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"events": items})
}
func (h *Handler) appendTaskEvent(w http.ResponseWriter, r *http.Request) {
type request struct {
EventType string `json:"event_type"`
BodyMarkdown string `json:"body_markdown"`
CreatedByRoleName string `json:"created_by_role_name"`
}
var req request
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
item, err := h.Tasks.AppendEvent(r.Context(), task.Event{
TaskID: r.PathValue("taskID"),
EventType: req.EventType,
BodyMarkdown: req.BodyMarkdown,
CreatedByRoleName: req.CreatedByRoleName,
})
if err != nil {
writeStoreError(w, err)
return
}
httpx.WriteJSON(w, http.StatusCreated, item)
}
+123
View File
@@ -0,0 +1,123 @@
package httpapi
import (
"net/http"
"inbox/internal/base/httpx"
"inbox/internal/domain/message"
"inbox/internal/domain/topic"
)
func (h *Handler) listTopics(w http.ResponseWriter, r *http.Request) {
items, err := h.Topics.List(r.Context(), r.URL.Query().Get("workspace_id"))
if err != nil {
writeStoreError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"topics": items})
}
func (h *Handler) createTopic(w http.ResponseWriter, r *http.Request) {
type request struct {
WorkspaceID string `json:"workspace_id"`
Slug string `json:"slug"`
Title string `json:"title"`
Space string `json:"space"`
Status string `json:"status"`
Summary string `json:"summary"`
}
var req request
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
item, err := h.Topics.Create(r.Context(), topic.Record{
WorkspaceID: req.WorkspaceID,
Slug: req.Slug,
Title: req.Title,
Space: topic.Space(req.Space),
Status: req.Status,
Summary: req.Summary,
})
if err != nil {
writeStoreError(w, err)
return
}
httpx.WriteJSON(w, http.StatusCreated, item)
}
func (h *Handler) getTopic(w http.ResponseWriter, r *http.Request) {
item, err := h.Topics.Get(r.Context(), r.PathValue("topicID"))
if err != nil {
writeStoreError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, item)
}
func (h *Handler) stopTopic(w http.ResponseWriter, r *http.Request) {
item, err := h.Topics.Stop(r.Context(), r.PathValue("topicID"))
if err != nil {
writeStoreError(w, err)
return
}
httpx.WriteJSON(w, http.StatusAccepted, item)
}
func (h *Handler) confirmTopicPlan(w http.ResponseWriter, r *http.Request) {
item, err := h.Topics.ConfirmPlan(r.Context(), r.PathValue("topicID"))
if err != nil {
writeStoreError(w, err)
return
}
httpx.WriteJSON(w, http.StatusAccepted, item)
}
func (h *Handler) deleteTopic(w http.ResponseWriter, r *http.Request) {
if err := h.Topics.Delete(r.Context(), r.PathValue("topicID")); err != nil {
writeStoreError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) listTopicMessages(w http.ResponseWriter, r *http.Request) {
items, err := h.Topics.ListMessages(r.Context(), r.PathValue("topicID"))
if err != nil {
writeStoreError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"messages": items})
}
func (h *Handler) createTopicMessage(w http.ResponseWriter, r *http.Request) {
type request struct {
WorkspaceID string `json:"workspace_id"`
FromRoleName string `json:"from_role_name"`
ToExpr string `json:"to_expr"`
Type string `json:"type"`
Stage string `json:"stage"`
ReplyToMessageID string `json:"reply_to_message_id"`
BodyMarkdown string `json:"body_markdown"`
}
var req request
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
item, err := h.Topics.CreateMessage(r.Context(), message.Record{
WorkspaceID: req.WorkspaceID,
TopicID: r.PathValue("topicID"),
FromRoleName: req.FromRoleName,
ToExpr: req.ToExpr,
Type: message.Type(req.Type),
Stage: req.Stage,
ReplyToMessageID: req.ReplyToMessageID,
BodyMarkdown: req.BodyMarkdown,
})
if err != nil {
writeStoreError(w, err)
return
}
httpx.WriteJSON(w, http.StatusCreated, item)
}
+167
View File
@@ -0,0 +1,167 @@
package httpapi
import (
"encoding/json"
"net/http"
"strconv"
workflowrunapp "inbox/internal/app/workflowrun"
"inbox/internal/base/httpx"
"inbox/internal/domain/workflow"
)
func (h *Handler) getWorkflowRun(w http.ResponseWriter, r *http.Request) {
item, err := h.WorkflowRun.Get(r.Context(), r.PathValue("runID"))
if err != nil {
writeStoreError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, workflowRunResponse(item))
}
func (h *Handler) listWorkflowRuns(w http.ResponseWriter, r *http.Request) {
items, err := h.WorkflowRun.ListByTopic(r.Context(), r.PathValue("topicID"))
if err != nil {
writeStoreError(w, err)
return
}
out := make([]map[string]any, 0, len(items))
for _, item := range items {
out = append(out, workflowRunResponse(item))
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"workflow_runs": out})
}
func (h *Handler) createWorkflowRun(w http.ResponseWriter, r *http.Request) {
type request struct {
WorkspaceID string `json:"workspace_id"`
RoleName string `json:"role_name"`
Stage string `json:"stage"`
Mode string `json:"mode"`
RequestMessageID string `json:"request_message_id"`
CommandJSON string `json:"command_json"`
}
var req request
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
item, err := h.WorkflowRun.Start(r.Context(), workflow.Run{
WorkspaceID: req.WorkspaceID,
TopicID: r.PathValue("topicID"),
RoleName: req.RoleName,
Stage: workflow.Stage(req.Stage),
Mode: req.Mode,
RequestMessageID: req.RequestMessageID,
CommandJSON: req.CommandJSON,
})
if err != nil {
writeStoreError(w, err)
return
}
httpx.WriteJSON(w, http.StatusCreated, item)
}
func (h *Handler) patchWorkflowRun(w http.ResponseWriter, r *http.Request) {
type request struct {
Status *workflow.RunStatus `json:"status"`
ReplyMessageID *string `json:"reply_message_id"`
ExitCode *int `json:"exit_code"`
CompletedAt *string `json:"completed_at"`
ErrorMessage *string `json:"error_message"`
CommandJSON *string `json:"command_json"`
}
var req request
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
item, err := h.WorkflowRun.Patch(r.Context(), r.PathValue("runID"), workflowrunapp.Patch{
Status: req.Status,
ReplyMessageID: req.ReplyMessageID,
ExitCode: req.ExitCode,
CompletedAt: req.CompletedAt,
ErrorMessage: req.ErrorMessage,
CommandJSON: req.CommandJSON,
})
if err != nil {
writeStoreError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, item)
}
func (h *Handler) listWorkflowRunLogs(w http.ResponseWriter, r *http.Request) {
afterSeq := 0
if raw := r.URL.Query().Get("after_seq"); raw != "" {
value, err := strconv.Atoi(raw)
if err != nil {
httpx.WriteError(w, http.StatusBadRequest, "after_seq must be an integer")
return
}
afterSeq = value
}
items, err := h.WorkflowRun.ListLogs(r.Context(), r.PathValue("runID"), afterSeq)
if err != nil {
writeStoreError(w, err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"logs": items})
}
func (h *Handler) appendWorkflowRunLog(w http.ResponseWriter, r *http.Request) {
type request struct {
Stream string `json:"stream"`
Content string `json:"content"`
CreatedAt string `json:"created_at"`
}
var req request
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
item, err := h.WorkflowRun.AppendLog(r.Context(), workflow.RunLog{
RunID: r.PathValue("runID"),
Stream: workflow.LogStream(req.Stream),
Content: req.Content,
CreatedAt: req.CreatedAt,
})
if err != nil {
writeStoreError(w, err)
return
}
httpx.WriteJSON(w, http.StatusCreated, item)
}
func workflowRunResponse(item workflow.Run) map[string]any {
payload := map[string]any{}
raw, err := json.Marshal(item)
if err == nil {
_ = json.Unmarshal(raw, &payload)
}
if payload == nil {
payload = map[string]any{}
}
if planning, ok := decodeWorkflowRunPlanning(item.CommandJSON); ok {
payload["planning"] = planning
}
return payload
}
func decodeWorkflowRunPlanning(commandJSON string) (map[string]any, bool) {
if commandJSON == "" {
return nil, false
}
var payload map[string]any
if err := json.Unmarshal([]byte(commandJSON), &payload); err != nil {
return nil, false
}
planning, ok := payload["planning"]
if !ok {
return nil, false
}
typed, ok := planning.(map[string]any)
return typed, ok
}
@@ -0,0 +1,31 @@
package httpapi
import (
"testing"
"inbox/internal/domain/workflow"
)
func TestWorkflowRunResponseIncludesPlanning(t *testing.T) {
resp := workflowRunResponse(workflow.Run{
ID: "run_1",
WorkspaceID: "ws_1",
TopicID: "topic_1",
RoleName: "leader",
Stage: workflow.StagePlan,
Mode: "message",
Status: workflow.RunStatusSucceeded,
CommandJSON: `{"mode":"leader","planning":{"plan_mode":"patch","execution_mode":"plan_and_start"}}`,
})
planning, ok := resp["planning"].(map[string]any)
if !ok {
t.Fatalf("expected planning payload, got %#v", resp["planning"])
}
if planning["plan_mode"] != "patch" {
t.Fatalf("expected plan_mode=patch, got %#v", planning["plan_mode"])
}
if planning["execution_mode"] != "plan_and_start" {
t.Fatalf("expected execution_mode=plan_and_start, got %#v", planning["execution_mode"])
}
}
@@ -0,0 +1,101 @@
package httpapi
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"inbox/internal/app/workspaceprovision"
"inbox/internal/app/workspaceruntime"
"inbox/internal/base/timeutil"
"inbox/internal/domain/lane"
"inbox/internal/domain/workspace"
sqlitestore "inbox/internal/store/sqlite"
)
type fakeWorkspaceProvisioner struct {
provisionReq workspaceprovision.ProvisionRequest
provisionWorkspace workspace.Workspace
provisionRuntime workspaceruntime.Runtime
}
func (f *fakeWorkspaceProvisioner) Provision(_ context.Context, req workspaceprovision.ProvisionRequest) (workspace.Workspace, workspaceruntime.Runtime, error) {
f.provisionReq = req
return f.provisionWorkspace, f.provisionRuntime, nil
}
func (f *fakeWorkspaceProvisioner) Ensure(_ context.Context, _ string) (workspace.Workspace, workspaceruntime.Runtime, error) {
return f.provisionWorkspace, f.provisionRuntime, nil
}
func (f *fakeWorkspaceProvisioner) EnsureLane(_ context.Context, _ string) (lane.Record, error) {
return lane.Record{}, nil
}
func (f *fakeWorkspaceProvisioner) StopLane(_ context.Context, _ string) (lane.Record, error) {
return lane.Record{}, nil
}
func TestProvisionWorkspaceRoute(t *testing.T) {
clock := timeutil.FixedClock{Time: time.Date(2026, 3, 13, 12, 0, 0, 0, time.UTC)}
store, err := sqlitestore.OpenInMemory(clock)
if err != nil {
t.Fatalf("OpenInMemory() error = %v", err)
}
defer store.Close()
fake := &fakeWorkspaceProvisioner{
provisionWorkspace: workspace.Workspace{
ID: "ws_1",
ProjectID: "proj_1",
Slug: "blog",
Name: "blog",
RootPath: "/tmp/worktrees/blog",
BaseBranch: "master",
WorktreeBranch: "worktree/blog",
RuntimeBackend: "host",
Status: "active",
ProvisionState: "ready",
LastProvisionedAt: "2026-03-13T12:00:00Z",
ContainerState: "running",
},
}
handler := NewHandlerWithOptions(store, clock, HandlerOptions{
WorkspaceProvision: fake,
WorkspaceRuntime: fake,
})
body, err := json.Marshal(map[string]any{
"project_dir": "/tmp/source-blog",
"name": "blog",
})
if err != nil {
t.Fatalf("json.Marshal() error = %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v2/workspaces/provision", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
}
if fake.provisionReq.ProjectDir != "/tmp/source-blog" || fake.provisionReq.Name != "blog" {
t.Fatalf("unexpected provision request: %#v", fake.provisionReq)
}
var payload struct {
Workspace workspace.Workspace `json:"workspace"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("json.Unmarshal() error = %v body=%s", err, rec.Body.String())
}
if payload.Workspace.ID != "ws_1" {
t.Fatalf("unexpected response payload: %#v", payload)
}
}
+114
View File
@@ -0,0 +1,114 @@
package httpapi
import (
"net/http"
"inbox/internal/app/workspaceprovision"
"inbox/internal/base/httpx"
"inbox/internal/domain/workspace"
)
func (h *Handler) listProjects(w http.ResponseWriter, r *http.Request) {
items, err := h.Workspaces.ListProjects(r.Context())
if err != nil {
writeStoreError(w, err)
return
}
if items == nil {
items = []workspace.Project{}
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"projects": items})
}
func (h *Handler) createProject(w http.ResponseWriter, r *http.Request) {
type request struct {
Slug string `json:"slug"`
Name string `json:"name"`
RootPath string `json:"root_path"`
DefaultBranch string `json:"default_branch"`
Status string `json:"status"`
}
var req request
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
item, err := h.Workspaces.CreateProject(r.Context(), workspace.Project{
Slug: req.Slug,
Name: req.Name,
RootPath: req.RootPath,
DefaultBranch: req.DefaultBranch,
Status: req.Status,
})
if err != nil {
writeStoreError(w, err)
return
}
httpx.WriteJSON(w, http.StatusCreated, item)
}
func (h *Handler) listWorkspaces(w http.ResponseWriter, r *http.Request) {
items, err := h.Workspaces.ListWorkspaces(r.Context(), r.URL.Query().Get("project_id"))
if err != nil {
writeStoreError(w, err)
return
}
if items == nil {
items = []workspace.Workspace{}
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"workspaces": items})
}
func (h *Handler) createWorkspace(w http.ResponseWriter, r *http.Request) {
type request struct {
ProjectID string `json:"project_id"`
Slug string `json:"slug"`
Name string `json:"name"`
RootPath string `json:"root_path"`
BaseBranch string `json:"base_branch"`
WorktreeBranch string `json:"worktree_branch"`
Status string `json:"status"`
}
var req request
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
item, err := h.Workspaces.CreateWorkspace(r.Context(), workspace.Workspace{
ProjectID: req.ProjectID,
Slug: req.Slug,
Name: req.Name,
RootPath: req.RootPath,
BaseBranch: req.BaseBranch,
WorktreeBranch: req.WorktreeBranch,
Status: req.Status,
})
if err != nil {
writeStoreError(w, err)
return
}
httpx.WriteJSON(w, http.StatusCreated, item)
}
func (h *Handler) provisionWorkspace(w http.ResponseWriter, r *http.Request) {
type request struct {
ProjectDir string `json:"project_dir"`
Name string `json:"name"`
}
var req request
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
item, _, err := h.WorkspaceOps.Provision(r.Context(), workspaceprovision.ProvisionRequest{
ProjectDir: req.ProjectDir,
Name: req.Name,
})
if err != nil {
writeStoreError(w, err)
return
}
httpx.WriteJSON(w, http.StatusCreated, map[string]any{
"workspace": item,
})
}