chore(repo): reinitialize repository
This commit is contained in:
@@ -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")
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user