Files
kandev/apps/backend/internal/backendapp/boot_state_routes.go
T
João Salavisa 481359ddb2 feat(auth): organization units, tenancy, roles and scopes for team access (#2919)
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 13:40:43 +00:00

1131 lines
42 KiB
Go

package backendapp
import (
"context"
"net/http"
"net/url"
"strings"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/common/httpcookie"
officedashboard "github.com/kandev/kandev/internal/office/dashboard"
taskdto "github.com/kandev/kandev/internal/task/dto"
taskmodels "github.com/kandev/kandev/internal/task/models"
userdto "github.com/kandev/kandev/internal/user/dto"
usermodels "github.com/kandev/kandev/internal/user/models"
)
// tasksPageBootData builds the tasks page boot payload: workspaces, repositories, workflows, steps, tasks, and the user's settings.
func (b bootStateBuilder) tasksPageBootData(ctx context.Context, req *http.Request) (map[string]any, map[string]any) {
if b.p.taskSvc == nil {
return nil, nil
}
workspaces, err := b.p.taskSvc.ListWorkspaces(ctx)
if err != nil {
b.logBootError("list tasks page workspaces", err)
return nil, nil
}
settings, hasSettings := b.userSettings(ctx)
settingsWorkspaceID := ""
settingsWorkflowID := ""
settingsRepositoryID := ""
settingsTasksListSort := ""
settingsTasksListGroup := ""
if hasSettings {
settingsWorkspaceID = settings.Settings.WorkspaceID
settingsWorkflowID = settings.Settings.WorkflowFilterID
settingsTasksListSort = settings.Settings.TasksListSort
settingsTasksListGroup = settings.Settings.TasksListGroup
if len(settings.Settings.RepositoryIDs) > 0 {
settingsRepositoryID = settings.Settings.RepositoryIDs[0]
}
}
tasksListSort := tasksListSortForRoute(queryValue(req, "sort"), settingsTasksListSort)
tasksListGroup := tasksListGroupForRoute(queryValue(req, "group"), settingsTasksListGroup)
workspaceIDs := workspaceIDSet(workspaces)
activeWorkspaceID := firstValidID(
workspaceIDs,
queryValue(req, "workspaceId"),
queryValue(req, "workspace"),
readActiveWorkspaceCookie(req),
settingsWorkspaceID,
firstWorkspaceID(workspaces),
)
state := map[string]any{
"workspaces": map[string]any{
"items": b.workspaceItemStates(ctx, workspaces),
"activeId": nullString(activeWorkspaceID),
},
}
if hasSettings {
state["userSettings"] = mapUserSettingsState(settings, activeWorkspaceID)
}
if activeWorkspaceID == "" {
return state, map[string]any{"activeWorkspaceId": nil, "workflows": []any{}, "steps": []any{}, "repositories": []any{}, "repositorySets": []any{}, "repositoryBranchPolicies": []any{}, "tasks": []any{}, "total": 0, "tasksListSort": tasksListSort, "tasksListGroup": tasksListGroup}
}
workflows, err := b.p.taskSvc.ListWorkflows(ctx, activeWorkspaceID, false)
if err != nil {
b.logBootError("list tasks page workflows", err)
return state, nil
}
activeWorkflowID := validWorkflowOrEmpty(workflows, settingsWorkflowID)
workflowItems := workflowItemStates(workflows)
state["workflows"] = map[string]any{"items": workflowItems, "activeId": nullString(activeWorkflowID)}
if hasSettings {
state["userSettings"] = mapUserSettingsStateWithWorkflow(settings, activeWorkspaceID, activeWorkflowID)
}
repositories := b.repositoriesForState(ctx, activeWorkspaceID, state)
repositorySets := b.repositorySetsForState(ctx, activeWorkspaceID, state)
repositoryBranchPolicies := b.repositoryBranchPoliciesForState(ctx, activeWorkspaceID, state)
steps := b.workflowStepsForWorkspace(ctx, activeWorkspaceID)
tasks, total := b.tasksForWorkspace(ctx, activeWorkspaceID, activeWorkflowID, settingsRepositoryID, tasksListSort)
routeData := map[string]any{
"activeWorkspaceId": activeWorkspaceID,
"workflows": workflowsToDTOs(workflows),
"steps": steps,
"repositories": repositories,
"repositorySets": repositorySets,
"repositoryBranchPolicies": repositoryBranchPolicies,
"tasks": tasks,
"total": total,
"tasksListSort": tasksListSort,
"tasksListGroup": tasksListGroup,
}
return state, routeData
}
// routeContextBootData builds the workspace/route context boot payload (workspaces, active workspace, workflows).
func (b bootStateBuilder) routeContextBootData(ctx context.Context, req *http.Request) (map[string]any, map[string]any) {
if b.p.taskSvc == nil {
return nil, nil
}
workspaces, err := b.p.taskSvc.ListWorkspaces(ctx)
if err != nil {
b.logBootError("list route context workspaces", err)
return nil, nil
}
settings, hasSettings := b.userSettings(ctx)
settingsWorkspaceID := ""
settingsWorkflowID := ""
if hasSettings {
settingsWorkspaceID = settings.Settings.WorkspaceID
settingsWorkflowID = settings.Settings.WorkflowFilterID
}
activeWorkspaceID := firstValidID(
workspaceIDSet(workspaces),
queryValue(req, "workspaceId"),
queryValue(req, "workspace"),
readActiveWorkspaceCookie(req),
settingsWorkspaceID,
firstWorkspaceID(workspaces),
)
state := map[string]any{
"workspaces": map[string]any{
"items": b.workspaceItemStates(ctx, workspaces),
"activeId": nullString(activeWorkspaceID),
},
}
if hasSettings {
state["userSettings"] = mapUserSettingsState(settings, activeWorkspaceID)
}
if activeWorkspaceID == "" {
return state, map[string]any{"activeWorkspaceId": nil, "workflows": []any{}, "steps": []any{}, "repositories": []any{}, "repositorySets": []any{}, "repositoryBranchPolicies": []any{}}
}
workflows, err := b.p.taskSvc.ListWorkflows(ctx, activeWorkspaceID, false)
if err != nil {
b.logBootError("list route context workflows", err)
return state, nil
}
activeWorkflowID := validWorkflowOrEmpty(workflows, settingsWorkflowID)
state["workflows"] = map[string]any{
"items": workflowItemStates(workflows),
"activeId": nullString(activeWorkflowID),
}
if hasSettings {
state["userSettings"] = mapUserSettingsStateWithWorkflow(settings, activeWorkspaceID, activeWorkflowID)
}
repositories := b.repositoriesForState(ctx, activeWorkspaceID, state)
repositorySets := b.repositorySetsForState(ctx, activeWorkspaceID, state)
repositoryBranchPolicies := b.repositoryBranchPoliciesForState(ctx, activeWorkspaceID, state)
steps := b.workflowStepsForWorkspace(ctx, activeWorkspaceID)
return state, map[string]any{
"activeWorkspaceId": activeWorkspaceID,
"workflows": workflowsToDTOs(workflows),
"steps": steps,
"repositories": repositories,
"repositorySets": repositorySets,
"repositoryBranchPolicies": repositoryBranchPolicies,
}
}
// workspaceIDSet collects the ids of all workspaces into a set.
func workspaceIDSet(workspaces []*taskmodels.Workspace) map[string]bool {
result := make(map[string]bool, len(workspaces))
for _, workspace := range workspaces {
if workspace != nil {
result[workspace.ID] = true
}
}
return result
}
// workspaceItemStates maps each workspace to its boot state shape.
// workspaceItemStates maps each workspace to its boot state shape, including
// the caller's resolved role and scopes.
//
// The projection is threaded through here rather than left to the HTTP list
// endpoint: this is a second, independent serialization path into the same
// store, and a field added only to the DTO silently never reaches a
// first-paint render.
func (b bootStateBuilder) workspaceItemStates(ctx context.Context, workspaces []*taskmodels.Workspace) []map[string]any {
access := b.p.taskSvc.ProjectWorkspaceAccess(ctx, workspaces)
items := make([]map[string]any, 0, len(workspaces))
for _, workspace := range workspaces {
if workspace != nil {
items = append(items, mapWorkspaceItemState(taskdto.FromWorkspaceWithAccess(
workspace, access.Decision(workspace.ID), access.MemberCounts[workspace.ID],
)))
}
}
return items
}
// resolveHomeWorkflowID picks the home workflow: the query param, else the saved settings workflow, else the first valid one.
func resolveHomeWorkflowID(
workflows []*taskmodels.Workflow,
queryWorkflowID string,
settingsWorkflowID string,
hasSettings bool,
) string {
if activeWorkflowID := validWorkflowOrEmpty(workflows, queryWorkflowID); activeWorkflowID != "" {
return activeWorkflowID
}
if hasSettings {
return validWorkflowOrEmpty(workflows, settingsWorkflowID)
}
return firstWorkflowID(workflows)
}
// validWorkflowOrEmpty returns the workflow id when it exists in the list, otherwise empty.
func validWorkflowOrEmpty(workflows []*taskmodels.Workflow, workflowID string) string {
for _, workflow := range workflows {
if workflow != nil && workflow.ID == workflowID {
return workflowID
}
}
return ""
}
// repositoriesForState loads the workspace repositories for the boot payload, logging and returning empty on failure.
func (b bootStateBuilder) repositoriesForState(ctx context.Context, workspaceID string, state map[string]any) []taskdto.RepositoryDTO {
repositories, err := b.p.taskSvc.ListRepositories(ctx, workspaceID)
if err != nil {
b.logBootError("list tasks page repositories", err)
return []taskdto.RepositoryDTO{}
}
items := repositoriesToDTOs(repositories)
state["repositories"] = map[string]any{
"itemsByWorkspaceId": map[string]any{workspaceID: items},
"loadingByWorkspaceId": map[string]any{workspaceID: false},
"loadedByWorkspaceId": map[string]any{workspaceID: true},
}
return items
}
// repositorySetsForState loads the workspace's repository sets for the boot
// payload, logging and returning empty on failure. The task-create picker offers
// sets on first paint from this, so the hook does not have to fetch.
func (b bootStateBuilder) repositorySetsForState(
ctx context.Context,
workspaceID string,
state map[string]any,
) []taskdto.RepositorySetDTO {
items := repositorySetsToDTOs(nil)
loaded := false
sets, err := b.p.taskSvc.ListRepositorySets(ctx, workspaceID)
if err != nil {
// Non-fatal, like every neighbour here: boot succeeds with an empty list
// and the client fetches instead. Leaving `loaded` false is what makes
// that fetch happen; marking a failed load as loaded would hide the
// workspace's real sets until an explicit refresh.
b.logBootError("list repository sets", err)
} else {
items = repositorySetsToDTOs(sets)
loaded = true
}
state["repositorySets"] = repositorySetsState(workspaceID, items, loaded)
return items
}
// repositoryBranchPoliciesForState hydrates the repository-keyed policy slice
// used by repository settings and the task-create picker.
func (b bootStateBuilder) repositoryBranchPoliciesForState(
ctx context.Context,
workspaceID string,
state map[string]any,
) []taskdto.RepositoryBranchPolicyDTO {
itemsByRepositoryID := map[string]any{}
loadingByRepositoryID := map[string]any{}
loadedByRepositoryID := map[string]any{}
items := make([]taskdto.RepositoryBranchPolicyDTO, 0)
repositories, err := b.p.taskSvc.ListRepositories(ctx, workspaceID)
if err != nil {
b.logBootError("list repositories for branch policies", err)
state["repositoryBranchPolicies"] = map[string]any{
"itemsByRepositoryId": itemsByRepositoryID,
"loadingByRepositoryId": loadingByRepositoryID,
"loadedByRepositoryId": loadedByRepositoryID,
}
return items
}
policies, err := b.p.taskSvc.ListRepositoryBranchPoliciesForWorkspace(ctx, workspaceID)
if err != nil {
b.logBootError("list repository branch policies", err)
for _, repository := range repositories {
if repository == nil {
continue
}
itemsByRepositoryID[repository.ID] = []taskdto.RepositoryBranchPolicyDTO{}
loadedByRepositoryID[repository.ID] = false
loadingByRepositoryID[repository.ID] = false
}
} else {
policiesByRepositoryID := make(map[string][]*taskmodels.RepositoryBranchPolicy)
for _, policy := range policies {
if policy != nil {
policiesByRepositoryID[policy.RepositoryID] = append(policiesByRepositoryID[policy.RepositoryID], policy)
}
}
for _, repository := range repositories {
if repository == nil {
continue
}
dtos := repositoryBranchPoliciesToDTOs(policiesByRepositoryID[repository.ID])
itemsByRepositoryID[repository.ID] = dtos
loadedByRepositoryID[repository.ID] = true
loadingByRepositoryID[repository.ID] = false
items = append(items, dtos...)
}
}
state["repositoryBranchPolicies"] = map[string]any{
"itemsByRepositoryId": itemsByRepositoryID,
"loadingByRepositoryId": loadingByRepositoryID,
"loadedByRepositoryId": loadedByRepositoryID,
}
return items
}
// repositorySetsState is the workspace-keyed slice shape the web store expects.
// An empty workspace still keys an entry: an absent key reads as "not loaded" to
// the client hook, which then refetches on every dialog open.
func repositorySetsState(
workspaceID string,
items []taskdto.RepositorySetDTO,
loaded bool,
) map[string]any {
return map[string]any{
"itemsByWorkspaceId": map[string]any{workspaceID: items},
"loadingByWorkspaceId": map[string]any{workspaceID: false},
"loadedByWorkspaceId": map[string]any{workspaceID: loaded},
}
}
// repositorySetsToDTOs maps repository set models to DTOs, always as an array.
func repositorySetsToDTOs(sets []*taskmodels.RepositorySet) []taskdto.RepositorySetDTO {
items := make([]taskdto.RepositorySetDTO, 0, len(sets))
for _, set := range sets {
if set != nil {
items = append(items, taskdto.FromRepositorySet(set))
}
}
return items
}
func repositoryBranchPoliciesToDTOs(policies []*taskmodels.RepositoryBranchPolicy) []taskdto.RepositoryBranchPolicyDTO {
items := make([]taskdto.RepositoryBranchPolicyDTO, 0, len(policies))
for _, policy := range policies {
if policy != nil {
items = append(items, taskdto.FromRepositoryBranchPolicy(policy))
}
}
return items
}
// repositoriesToDTOs maps repository models to DTOs.
func repositoriesToDTOs(repositories []*taskmodels.Repository) []taskdto.RepositoryDTO {
items := make([]taskdto.RepositoryDTO, 0, len(repositories))
for _, repository := range repositories {
if repository != nil {
items = append(items, taskdto.FromRepository(repository))
}
}
return items
}
// workflowsToDTOs maps workflow models to DTOs.
func workflowsToDTOs(workflows []*taskmodels.Workflow) []taskdto.WorkflowDTO {
items := make([]taskdto.WorkflowDTO, 0, len(workflows))
for _, workflow := range workflows {
if workflow != nil {
items = append(items, taskdto.FromWorkflow(workflow))
}
}
return items
}
// workflowStepsForWorkspace loads the workflow steps for a workspace, returning empty when the service is unavailable.
func (b bootStateBuilder) workflowStepsForWorkspace(ctx context.Context, workspaceID string) []taskdto.WorkflowStepDTO {
if b.p.services == nil || b.p.services.Workflow == nil {
return []taskdto.WorkflowStepDTO{}
}
steps, err := b.p.services.Workflow.ListStepsByWorkspaceID(ctx, workspaceID)
if err != nil {
b.logBootError("list tasks page workflow steps", err)
return []taskdto.WorkflowStepDTO{}
}
items := make([]taskdto.WorkflowStepDTO, 0, len(steps))
for _, step := range steps {
if step != nil {
items = append(items, taskdto.FromWorkflowStepWithTimestamps(step))
}
}
return items
}
// tasksForWorkspace loads the task page's task list for a workspace/workflow, returning empty on failure.
func (b bootStateBuilder) tasksForWorkspace(ctx context.Context, workspaceID, workflowID, repositoryID, sort string) ([]taskdto.TaskDTO, int) {
tasks, total, err := b.p.taskSvc.ListTasksByWorkspace(ctx, workspaceID, workflowID, repositoryID, "", 1, 25, sort, false, false, false, false)
if err != nil {
b.logBootError("list tasks page tasks", err)
return []taskdto.TaskDTO{}, 0
}
return b.taskDTOsWithSessionInfo(ctx, tasks), total
}
// mergeBootState overlays src key/value pairs onto dst.
func mergeBootState(dst map[string]any, src map[string]any) {
for key, value := range src {
dst[key] = value
}
}
// addPromptsState merges the prompts listing into the boot state when available.
func (b bootStateBuilder) addPromptsState(ctx context.Context, state map[string]any) {
if b.p.promptCtrl == nil {
return
}
response, err := b.p.promptCtrl.ListPrompts(ctx)
if err != nil {
b.logBootError("list prompts", err)
return
}
state["prompts"] = map[string]any{
"items": response.Prompts,
"loaded": true,
"loading": false,
}
}
// addEditorsState merges the editors listing into the boot state when available.
func (b bootStateBuilder) addEditorsState(ctx context.Context, state map[string]any) {
if b.p.editorCtrl == nil {
return
}
response, err := b.p.editorCtrl.ListEditors(ctx)
if err != nil {
b.logBootError("list editors", err)
return
}
state["editors"] = map[string]any{
"items": response.Editors,
"loaded": true,
"loading": false,
}
}
// addOfficeRouteState merges office route state into the boot state when the office feature is enabled.
func (b bootStateBuilder) addOfficeRouteState(ctx context.Context, req *http.Request, state map[string]any) {
if !b.p.features.Office || b.p.services == nil || b.p.services.OfficeSvcs == nil {
return
}
officeSvcs := b.p.services.OfficeSvcs
if officeSvcs.Onboarding != nil {
onboarding, err := officeSvcs.Onboarding.GetOnboardingState(ctx)
if err != nil {
b.logBootError("get office onboarding", err)
return
}
if onboarding != nil && !onboarding.Completed {
return
}
}
workspaces, activeID, err := b.officeWorkspaces(ctx, req)
if err != nil {
b.logBootError("list office workspaces", err)
return
}
state["workspaces"] = map[string]any{
"items": workspaces,
"activeId": activeID,
}
b.addUserSettingsState(ctx, state, activeID)
state["office"] = b.officeState(ctx, activeID)
}
// officeWorkspaces resolves the office workspaces and the active workspace id
// from the request cookies and user settings. Candidates, each validated
// against the office workspace set: general cookie (when it names an office
// workspace) → office cookie → settings → first office workspace.
func (b bootStateBuilder) officeWorkspaces(ctx context.Context, req *http.Request) ([]taskdto.WorkspaceDTO, string, error) {
if b.p.taskSvc == nil {
return nil, "", nil
}
workspaces, err := b.p.taskSvc.ListWorkspaces(ctx)
if err != nil {
return nil, "", err
}
items := make([]taskdto.WorkspaceDTO, 0, len(workspaces))
officeItems := make([]taskdto.WorkspaceDTO, 0, len(workspaces))
for _, workspace := range workspaces {
if workspace == nil {
continue
}
item := taskdto.FromWorkspace(workspace)
items = append(items, item)
if item.OfficeWorkflowID != "" {
officeItems = append(officeItems, item)
}
}
settingsWorkspaceID := ""
if settings, ok := b.userSettings(ctx); ok {
settingsWorkspaceID = settings.Settings.WorkspaceID
}
return items, resolveActiveOfficeWorkspaceID(
officeItems,
readActiveWorkspaceCookie(req),
readOfficeWorkspaceCookie(req),
settingsWorkspaceID,
), nil
}
// officeState assembles the office boot state (agents, projects, inbox, dashboard) for the active workspace.
func (b bootStateBuilder) officeState(ctx context.Context, activeID string) map[string]any {
agents := b.officeAgents(ctx, activeID)
projects := b.officeProjects(ctx, activeID)
inboxItems, inboxCount := b.officeInbox(ctx, activeID)
dashboard := b.officeDashboard(ctx, activeID)
// Workspace-scoped collections ship keyed by workspace id, matching the
// store shape in lib/state/slices/office/types.ts. An empty activeID means
// no office workspace resolved, so every map stays empty rather than
// keying data under "".
byActive := func(value any) map[string]any {
if activeID == "" {
return map[string]any{}
}
return map[string]any{activeID: value}
}
return map[string]any{
"agentProfilesByWorkspaceId": byActive(agents),
"skills": []any{},
"projectsByWorkspaceId": byActive(projects),
"approvals": []any{},
"activity": []any{},
"costSummary": nil,
"budgetPolicies": []any{},
"routines": []any{},
"inboxItemsByWorkspaceId": byActive(inboxItems),
"inboxCountByWorkspaceId": byActive(inboxCount),
"runs": []any{},
"dashboardByWorkspaceId": byActive(dashboard),
"tasks": map[string]any{
"items": []any{},
"filters": map[string]any{"statuses": []any{}, "priorities": []any{}, "assigneeIds": []any{}, "projectIds": []any{}, "search": ""},
"viewMode": "list",
"sortField": "updated",
"sortDir": "desc",
"groupBy": "none",
"nestingEnabled": true,
"isLoading": false,
},
"meta": officedashboard.BuildMetaResponse(),
"isLoading": false,
"refetchTrigger": nil,
"routing": map[string]any{"byWorkspace": map[string]any{}, "knownProviders": []any{}, "preview": map[string]any{"byWorkspace": map[string]any{}}},
"providerHealth": map[string]any{"byWorkspace": map[string]any{}},
"runAttempts": map[string]any{"byRunId": map[string]any{}},
"agentRouting": map[string]any{"byAgentId": map[string]any{}},
}
}
// officeAgents loads the office agent list for a workspace, returning empty when unavailable.
func (b bootStateBuilder) officeAgents(ctx context.Context, activeID string) any {
if activeID == "" || b.p.services.OfficeSvcs.Agents == nil {
return []any{}
}
result, err := b.p.services.OfficeSvcs.Agents.ListAgentsFromConfig(ctx, activeID)
if err != nil {
b.logBootError("list office agents", err)
return []any{}
}
return result
}
// officeProjects loads the office project list with counts, returning empty when unavailable.
func (b bootStateBuilder) officeProjects(ctx context.Context, activeID string) any {
if activeID == "" || b.p.services.OfficeSvcs.Projects == nil {
return []any{}
}
result, err := b.p.services.OfficeSvcs.Projects.ListProjectsWithCountsFromConfig(ctx, activeID)
if err != nil {
b.logBootError("list office projects", err)
return []any{}
}
return result
}
// officeInbox loads the office inbox items and count, returning empty when unavailable.
func (b bootStateBuilder) officeInbox(ctx context.Context, activeID string) (any, int) {
if activeID == "" || b.p.services.OfficeSvcs.Dashboard == nil {
return []any{}, 0
}
result, err := b.p.services.OfficeSvcs.Dashboard.GetInboxItems(ctx, activeID)
if err != nil {
b.logBootError("get office inbox", err)
return []any{}, 0
}
return result, len(result)
}
// officeDashboard loads the office dashboard data, returning nil when unavailable.
func (b bootStateBuilder) officeDashboard(ctx context.Context, activeID string) any {
if activeID == "" || b.p.services.OfficeSvcs.Dashboard == nil {
return nil
}
data, err := b.p.services.OfficeSvcs.Dashboard.GetDashboardData(ctx, activeID)
if err != nil {
b.logBootError("get office dashboard", err)
return nil
}
summaries, err := b.p.services.OfficeSvcs.Dashboard.GetAgentSummaries(ctx, activeID)
if err != nil {
b.logBootError("get office agent summaries", err)
summaries = []officedashboard.AgentSummary{}
}
return officedashboard.NewDashboardResponse(data, summaries)
}
// mapUserSettingsState converts a user settings response to the SPA boot shape, preferring the given workspace id.
func mapUserSettingsState(response userdto.UserSettingsResponse, workspaceID string) map[string]any {
settings := response.Settings
effectiveWorkspaceID := nullString(settings.WorkspaceID)
if workspaceID != "" {
effectiveWorkspaceID = workspaceID
}
return map[string]any{
"revision": settings.Revision,
"workspaceId": effectiveWorkspaceID,
"kanbanViewMode": nullString(settings.KanbanViewMode),
"startupPage": usermodels.NormalizeStartupPage(settings.StartupPage),
"workflowId": nullString(settings.WorkflowFilterID),
"repositoryIds": stringSlice(settings.RepositoryIDs),
"tasksListSort": usermodels.NormalizeTasksListSort(settings.TasksListSort),
"tasksListGroup": usermodels.NormalizeTasksListGroup(settings.TasksListGroup),
"tasksListShowDetails": settings.TasksListShowDetails,
"preferredShell": nullString(settings.PreferredShell),
"shellOptions": response.ShellOptions,
"defaultEditorId": nullString(settings.DefaultEditorID),
"enablePreviewOnClick": settings.EnablePreviewOnClick,
"chatSubmitKey": defaultString(settings.ChatSubmitKey, "cmd_enter"),
"reviewAutoMarkOnScroll": settings.ReviewAutoMarkOnScroll,
"confirmTaskArchive": settings.ConfirmTaskArchive,
"preventAutoStartAgentOnOpen": settings.PreventAutoStartAgentOnOpen,
"unreadDivider": settings.UnreadDivider,
"agentGeneratedTaskTitles": settings.AgentGeneratedTaskTitles,
"mcpTaskAgentProfileDefault": usermodels.NormalizeMCPTaskAgentProfileDefault(settings.MCPTaskAgentProfileDefault),
"showAnchoredPromptBar": settings.ShowAnchoredPromptBar,
"showScrollToLastPrompt": settings.ShowScrollToLastPrompt,
"showScrollToStart": settings.ShowScrollToStart,
"showTranscriptAutoScrollControl": settings.ShowTranscriptAutoScrollControl,
"showTodoListPanel": settings.ShowTodoListPanel,
// Sub-option: only auto-pin when the agent's todo list is not empty.
"showTodoListPanelOnlyWhenNotEmpty": settings.ShowTodoListPanelOnlyWhenNotEmpty,
"showReleaseNotification": settings.ShowReleaseNotification,
"releaseNotesLastSeenVersion": nullString(settings.ReleaseNotesLastSeenVersion),
"lspAutoStartLanguages": stringSlice(settings.LspAutoStartLanguages),
"lspAutoInstallLanguages": stringSlice(settings.LspAutoInstallLanguages),
"lspServerConfigs": mapStringMap(settings.LspServerConfigs),
"lspStatusLocation": usermodels.NormalizeLspStatusLocation(settings.LspStatusLocation),
"savedLayouts": settings.SavedLayouts,
"sidebarViews": mapSidebarViews(settings.SidebarViews),
"sidebarActiveViewId": nullString(settings.SidebarActiveViewID),
"sidebarDraft": mapSidebarDraft(settings.SidebarDraft),
"threadViews": mapThreadViews(settings.ThreadViews),
"threadActiveViewId": nullString(settings.ThreadActiveViewID),
"threadViewDraft": mapThreadViewDraft(settings.ThreadViewDraft),
"sidebarTaskPrefs": mapSidebarTaskPrefs(settings.SidebarTaskPrefs),
"taskCreateLastUsed": mapTaskCreateLastUsed(settings.TaskCreateLastUsed),
"defaultUtilityAgentId": nullString(settings.DefaultUtilityAgentID),
"defaultUtilityAgentProfileId": nullString(settings.DefaultUtilityAgentProfileID),
"keyboardShortcuts": mapStringAny(settings.KeyboardShortcuts),
"terminalLinkBehavior": terminalLinkBehavior(settings.TerminalLinkBehavior),
"terminalFontFamily": nullString(settings.TerminalFontFamily),
"terminalFontSize": nullInt(settings.TerminalFontSize),
"changesPanelLayout": changesPanelLayout(settings.ChangesPanelLayout),
"lastSeenDisplay": lastSeenDisplay(settings.LastSeenDisplay),
"azureDevOpsBrowsePreferences": settings.AzureDevOpsBrowsePreferences,
"systemMetricsDisplay": map[string]any{
"showInTopbar": settings.SystemMetricsDisplay.ShowInTopbar,
"simplified": settings.SystemMetricsDisplay.Simplified,
},
"appStatusBarEnabled": settings.AppStatusBarEnabled,
"resolveSessionHostnames": settings.ResolveSessionHostnames,
"appStatusBarOrder": mapAppStatusBarOrder(settings.AppStatusBarOrder),
"quickChatTabOrderByWorkspace": settings.QuickChatTabOrderByWorkspace,
"hiddenWorkflowStepIds": stringSliceMap(settings.KanbanHiddenStepIDs),
"workflowIdsWithAutoHideEmptySteps": stringSlice(settings.WorkflowIDsWithAutoHideEmptySteps),
"loaded": true,
}
}
// mapUserSettingsStateWithWorkflow adds the resolved workflow id onto the settings boot shape.
func mapUserSettingsStateWithWorkflow(response userdto.UserSettingsResponse, workspaceID, workflowID string) map[string]any {
state := mapUserSettingsState(response, workspaceID)
state["workflowId"] = nullString(workflowID)
return state
}
// mapWorkspaceItemState maps a workspace DTO to its SPA boot shape.
func mapWorkspaceItemState(workspace taskdto.WorkspaceDTO) map[string]any {
return map[string]any{
"id": workspace.ID,
"name": workspace.Name,
"description": workspace.Description,
"owner_id": workspace.OwnerID,
"unit_id": workspace.UnitID,
"viewer_role": workspace.ViewerRole,
"scopes": workspace.Scopes,
"member_count": workspace.MemberCount,
"default_executor_id": workspace.DefaultExecutorID,
"default_environment_id": workspace.DefaultEnvironmentID,
"default_agent_profile_id": workspace.DefaultAgentProfileID,
"default_config_agent_profile_id": workspace.DefaultConfigAgentProfileID,
"office_workflow_id": nullString(workspace.OfficeWorkflowID),
"created_at": workspace.CreatedAt,
"updated_at": workspace.UpdatedAt,
}
}
// mapWorkflowItemState maps a workflow DTO to its SPA boot shape.
func mapWorkflowItemState(workflow taskdto.WorkflowDTO) map[string]any {
return map[string]any{
"id": workflow.ID,
"workspaceId": workflow.WorkspaceID,
"name": workflow.Name,
"description": workflow.Description,
"sortOrder": workflow.SortOrder,
"agent_profile_id": nullString(workflow.AgentProfileID),
"hidden": workflow.Hidden,
"style": workflow.Style,
}
}
// mapKanbanStepState maps a workflow step DTO to the kanban step boot shape.
func mapKanbanStepState(step taskdto.WorkflowStepDTO) map[string]any {
return map[string]any{
"id": step.ID,
"title": step.Name,
"color": defaultString(step.Color, "bg-neutral-400"),
"position": step.Position,
"events": step.Events,
"allow_manual_move": step.AllowManualMove,
"prompt": step.Prompt,
"is_start_step": step.IsStartStep,
"show_in_command_panel": step.ShowInCommandPanel,
"agent_profile_id": nullString(step.AgentProfileID),
"profile_session_start_policy": string(step.ProfileSessionStartPolicy),
"profile_session_end_policy": string(step.ProfileSessionEndPolicy),
"stage_type": nullString(step.StageType),
"wip_limit": step.WIPLimit,
"pull_from_step_id": nullString(step.PullFromStepID),
}
}
// mapKanbanTaskState maps a task DTO to the kanban task boot shape.
func mapKanbanTaskState(task taskdto.TaskDTO) map[string]any {
repositories := make([]map[string]any, 0, len(task.Repositories))
var primaryRepositoryID any
for i, repo := range task.Repositories {
if i == 0 {
primaryRepositoryID = repo.RepositoryID
}
repositories = append(repositories, map[string]any{
"id": repo.ID,
"repository_id": repo.RepositoryID,
"base_branch": repo.BaseBranch,
"checkout_branch": repo.CheckoutBranch,
"position": repo.Position,
})
}
return map[string]any{
"id": task.ID,
"workflowStepId": task.WorkflowStepID,
"title": task.Title,
"description": task.Description,
"position": task.Position,
"state": task.State,
"repositoryId": primaryRepositoryID,
"repositories": repositories,
"primarySessionId": task.PrimarySessionID,
"primarySessionState": task.PrimarySessionState,
"primarySessionPendingAction": task.PrimarySessionPendingAction,
"taskPendingAction": task.TaskPendingAction,
"wipAdmitted": task.WIPAdmitted,
"queuedForStepId": nullString(task.QueuedForStepID),
"queuedAt": task.QueuedAt,
"interrupted": task.Interrupted,
"autoStartFailed": task.AutoStartFailed,
"statusSummary": task.StatusSummary,
"sessionCount": task.SessionCount,
"reviewStatus": nullString(string(task.ReviewStatus)),
"parentTaskId": nullString(task.ParentID),
"priority": task.Priority,
"updatedAt": task.UpdatedAt,
"createdAt": task.CreatedAt,
// Dependency projection. This mapper is a camelCase whitelist writing
// straight into the frontend store shape, so a new DTO field is invisible
// to the boot payload until it is listed here — the board badge and the
// dependency chip both read these on first paint.
"blocked": task.Blocked,
"blockedReason": nullString(task.BlockedReason),
"dependsOn": dependencyRefStates(task.DependsOn),
"blocks": dependencyRefStates(task.Blocks),
"startWhenUnblocked": task.StartWhenUnblocked,
}
}
// dependencyRefStates maps dependency edge entries into the store shape. Returns
// an empty slice rather than nil so the client reads "no edges" instead of
// "unknown" and does not keep a stale badge.
func dependencyRefStates(refs []taskdto.TaskDependencyRefDTO) []map[string]any {
out := make([]map[string]any, 0, len(refs))
for _, ref := range refs {
out = append(out, map[string]any{
"id": ref.ID,
"title": ref.Title,
"state": ref.State,
statusKey: ref.Status,
})
}
return out
}
// mapSidebarViews maps sidebar views to the SPA boot shape.
func mapSidebarViews(views []usermodels.SidebarView) []map[string]any {
if len(views) == 0 {
return []map[string]any{}
}
result := make([]map[string]any, 0, len(views))
for _, view := range views {
result = append(result, map[string]any{
"id": view.ID,
"name": view.Name,
"filters": view.Filters,
"sort": view.Sort,
"group": view.Group,
"collapsedGroups": stringSlice(view.CollapsedGroups),
"taskRow": mapSidebarTaskRow(view.TaskRow),
})
}
return result
}
// mapSidebarDraft maps the sidebar draft (nil-safe) to the boot shape.
func mapSidebarDraft(draft *usermodels.SidebarViewDraft) map[string]any {
if draft == nil {
return nil
}
return map[string]any{
"baseViewId": draft.BaseViewID,
"filters": draft.Filters,
"sort": draft.Sort,
"group": draft.Group,
"taskRow": mapSidebarTaskRow(draft.TaskRow),
}
}
func mapSidebarTaskRow(value *usermodels.SidebarTaskRowPresentation) map[string]any {
if value == nil {
return nil
}
return map[string]any{
"detailsEnabled": value.DetailsEnabled,
"detailOrder": stringSlice(value.DetailOrder),
"visibleDetails": stringSlice(value.VisibleDetails),
"trailing": value.Trailing,
}
}
// mapSidebarTaskPrefs maps sidebar task prefs to the boot shape.
func mapSidebarTaskPrefs(prefs usermodels.SidebarTaskPrefs) map[string]any {
return map[string]any{
"pinnedTaskIds": stringSlice(prefs.PinnedTaskIDs),
"orderedTaskIds": stringSlice(prefs.OrderedTaskIDs),
"subtaskOrderByParentId": stringSliceMap(prefs.SubtaskOrderByParentID),
}
}
// mapAppStatusBarOrder maps the status bar order to the boot shape.
func mapAppStatusBarOrder(order usermodels.AppStatusBarOrder) map[string]any {
return map[string]any{
"leftItemIds": stringSlice(order.LeftItemIDs),
"rightItemIds": stringSlice(order.RightItemIDs),
}
}
// mapTaskCreateLastUsed maps the last task-creation choices to the boot shape.
func mapTaskCreateLastUsed(value usermodels.TaskCreateLastUsed) map[string]any {
workflowIDsByWorkspace := value.WorkflowIDsByWorkspace
if workflowIDsByWorkspace == nil {
workflowIDsByWorkspace = map[string]string{}
}
return map[string]any{
"repositoryId": nullString(value.RepositoryID),
branchFieldKey: nullString(value.Branch),
"agentProfileId": nullString(value.AgentProfileID),
"executorProfileId": nullString(value.ExecutorProfileID),
"workflowIdsByWorkspace": workflowIDsByWorkspace,
"synced": value.RepositoryID != "" || value.Branch != "" || value.AgentProfileID != "" ||
value.ExecutorProfileID != "" || len(workflowIDsByWorkspace) > 0,
}
}
// resolveActiveOfficeWorkspaceID returns the first candidate — general cookie,
// office cookie, settings — that names a valid office workspace, otherwise the
// first office workspace id.
func resolveActiveOfficeWorkspaceID(
workspaces []taskdto.WorkspaceDTO,
generalCookieID string,
officeCookieID string,
settingsID string,
) string {
for _, candidate := range []string{generalCookieID, officeCookieID, settingsID} {
for _, workspace := range workspaces {
if workspace.ID == candidate {
return workspace.ID
}
}
}
if len(workspaces) > 0 {
return workspaces[0].ID
}
return ""
}
// firstValidID returns the first non-empty candidate present in the valid set.
func firstValidID(valid map[string]bool, candidates ...string) string {
for _, candidate := range candidates {
value := strings.TrimSpace(candidate)
if value != "" && valid[value] {
return value
}
}
return ""
}
// firstWorkspaceID returns the first workspace id in the list.
func firstWorkspaceID(workspaces []*taskmodels.Workspace) string {
for _, workspace := range workspaces {
if workspace != nil && workspace.ID != "" {
return workspace.ID
}
}
return ""
}
// firstWorkflowID returns the first workflow id in the list.
func firstWorkflowID(workflows []*taskmodels.Workflow) string {
for _, workflow := range workflows {
if workflow != nil && workflow.ID != "" {
return workflow.ID
}
}
return ""
}
// queryValue reads a trimmed query parameter, returning empty when absent.
func queryValue(req *http.Request, name string) string {
if req == nil || req.URL == nil {
return ""
}
if value := strings.TrimSpace(req.URL.Query().Get(name)); value != "" {
return value
}
routePath := strings.TrimSpace(req.URL.Query().Get("path"))
if routePath == "" {
return ""
}
parsed, err := url.Parse(routePath)
if err != nil {
return ""
}
return strings.TrimSpace(parsed.Query().Get(name))
}
// tasksListSortForRoute prefers a valid query sort, falling back to the saved settings value.
func tasksListSortForRoute(queryValue, settingsValue string) string {
if usermodels.IsValidTasksListSort(queryValue) {
return strings.TrimSpace(queryValue)
}
return usermodels.NormalizeTasksListSort(settingsValue)
}
// tasksListGroupForRoute prefers a valid query group, falling back to the saved settings value.
func tasksListGroupForRoute(queryValue, settingsValue string) string {
if usermodels.IsValidTasksListGroup(queryValue) {
return strings.TrimSpace(queryValue)
}
return usermodels.NormalizeTasksListGroup(settingsValue)
}
// readActiveWorkspaceCookie reads the active workspace id from the GENERAL
// cookie family only: the port-scoped name first, then the legacy unprefixed
// name as a read-only fallback (a pre-upgrade selection survives). The
// office-family cookies are deliberately not consulted — generic boot paths
// resolve settings/first when only an office cookie exists (parity with the
// frontend generic reader). The suffix derives from the request host
// (X-Forwarded-Host precedence), the same port the SPA derives from the API
// base URL.
func readActiveWorkspaceCookie(req *http.Request) string {
if req == nil {
return ""
}
// On a default-port host the scoped name IS the legacy name; reading the
// same cookie twice is a no-op, so only probe the legacy name when the
// port actually scopes it.
names := []string{httpcookie.ScopedName(req, activeWorkspaceCookie)}
if scoped := names[0]; scoped != activeWorkspaceCookie {
names = append(names, activeWorkspaceCookie)
}
for _, name := range names {
cookie, err := req.Cookie(name)
if err == nil {
if value := strings.TrimSpace(cookie.Value); value != "" {
return value
}
}
}
return ""
}
// readOfficeWorkspaceCookie reads the active workspace id from the OFFICE
// cookie family only: the port-scoped name first, then the legacy unprefixed
// name as a read-only fallback. The general cookie is never consulted here.
func readOfficeWorkspaceCookie(req *http.Request) string {
if req == nil {
return ""
}
// Same default-port dedupe as readActiveWorkspaceCookie: the scoped name
// equals the legacy name when the Host carries no (non-default) port.
names := []string{httpcookie.ScopedName(req, legacyOfficeWorkspaceCookie)}
if scoped := names[0]; scoped != legacyOfficeWorkspaceCookie {
names = append(names, legacyOfficeWorkspaceCookie)
}
for _, name := range names {
cookie, err := req.Cookie(name)
if err == nil {
if value := strings.TrimSpace(cookie.Value); value != "" {
return value
}
}
}
return ""
}
// nullString returns nil for an empty string (JSON null).
func nullString(value string) any {
if value == "" {
return nil
}
return value
}
// nullInt returns nil for a zero int (JSON null).
func nullInt(value int) any {
if value == 0 {
return nil
}
return value
}
// stringSlice returns an empty slice instead of nil.
func stringSlice(value []string) []string {
if value == nil {
return []string{}
}
return value
}
// stringSliceMap returns an empty map instead of nil.
func stringSliceMap(value map[string][]string) map[string][]string {
if value == nil {
return map[string][]string{}
}
return value
}
// mapStringAny returns an empty map instead of nil.
func mapStringAny(value map[string]any) map[string]any {
if value == nil {
return map[string]any{}
}
return value
}
// mapStringMap returns an empty map instead of nil.
func mapStringMap(value map[string]map[string]any) map[string]map[string]any {
if value == nil {
return map[string]map[string]any{}
}
return value
}
// defaultString returns the fallback when the value is empty.
func defaultString(value, fallback string) string {
if value == "" {
return fallback
}
return value
}
// terminalLinkBehavior normalizes the terminal link behavior to new_tab or browser_panel.
func terminalLinkBehavior(value string) string {
if value == "browser_panel" {
return "browser_panel"
}
return "new_tab"
}
// changesPanelLayout normalizes the changes panel layout to flat or tree.
func changesPanelLayout(value string) string {
if value == "flat" {
return "flat"
}
return "tree"
}
// lastSeenDisplay normalizes the last-seen display to absolute or relative.
func lastSeenDisplay(value string) string {
return usermodels.NormalizeLastSeenDisplay(value)
}
// logBootError logs a debug entry when optional boot data failed to load.
func (b bootStateBuilder) logBootError(operation string, err error) {
if err == nil || b.p.log == nil {
return
}
b.p.log.Debug("SPA boot state skipped optional data", zap.String("operation", operation), zap.Error(err))
}