Files
kandev/apps/backend/internal/backendapp/boot_state_routes.go
T
2026-06-20 19:40:15 +01:00

715 lines
22 KiB
Go

package backendapp
import (
"context"
"net/http"
"net/url"
"strings"
"go.uber.org/zap"
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"
)
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 := ""
if hasSettings {
settingsWorkspaceID = settings.Settings.WorkspaceID
settingsWorkflowID = settings.Settings.WorkflowFilterID
if len(settings.Settings.RepositoryIDs) > 0 {
settingsRepositoryID = settings.Settings.RepositoryIDs[0]
}
}
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": workspaceItemStates(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{}, "tasks": []any{}, "total": 0}
}
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)
steps := b.workflowStepsForWorkspace(ctx, activeWorkspaceID)
tasks, total := b.tasksForWorkspace(ctx, activeWorkspaceID, activeWorkflowID, settingsRepositoryID)
routeData := map[string]any{
"activeWorkspaceId": activeWorkspaceID,
"workflows": workflowsToDTOs(workflows),
"steps": steps,
"repositories": repositories,
"tasks": tasks,
"total": total,
}
return state, routeData
}
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": workspaceItemStates(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{}}
}
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)
steps := b.workflowStepsForWorkspace(ctx, activeWorkspaceID)
return state, map[string]any{
"activeWorkspaceId": activeWorkspaceID,
"workflows": workflowsToDTOs(workflows),
"steps": steps,
"repositories": repositories,
}
}
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
}
func workspaceItemStates(workspaces []*taskmodels.Workspace) []map[string]any {
items := make([]map[string]any, 0, len(workspaces))
for _, workspace := range workspaces {
if workspace != nil {
items = append(items, mapWorkspaceItemState(taskdto.FromWorkspace(workspace)))
}
}
return items
}
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)
}
func validWorkflowOrEmpty(workflows []*taskmodels.Workflow, workflowID string) string {
for _, workflow := range workflows {
if workflow != nil && workflow.ID == workflowID {
return workflowID
}
}
return ""
}
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
}
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
}
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
}
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
}
func (b bootStateBuilder) tasksForWorkspace(ctx context.Context, workspaceID, workflowID, repositoryID string) ([]taskdto.TaskDTO, int) {
tasks, total, err := b.p.taskSvc.ListTasksByWorkspace(ctx, workspaceID, workflowID, repositoryID, "", 1, 25, false, false, false, false)
if err != nil {
b.logBootError("list tasks page tasks", err)
return []taskdto.TaskDTO{}, 0
}
return b.taskDTOsWithSessionInfo(ctx, tasks), total
}
func mergeBootState(dst map[string]any, src map[string]any) {
for key, value := range src {
dst[key] = value
}
}
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,
}
}
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,
}
}
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)
}
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)
}
}
return items, resolveActiveOfficeWorkspaceID(officeItems, readActiveWorkspaceCookie(req)), nil
}
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)
return map[string]any{
"agentProfiles": agents,
"skills": []any{},
"projects": projects,
"approvals": []any{},
"activity": []any{},
"costSummary": nil,
"budgetPolicies": []any{},
"routines": []any{},
"inboxItems": inboxItems,
"inboxCount": inboxCount,
"runs": []any{},
"dashboard": 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{}},
}
}
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
}
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
}
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)
}
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)
}
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{
"workspaceId": effectiveWorkspaceID,
"kanbanViewMode": nullString(settings.KanbanViewMode),
"workflowId": nullString(settings.WorkflowFilterID),
"repositoryIds": stringSlice(settings.RepositoryIDs),
"preferredShell": nullString(settings.PreferredShell),
"shellOptions": response.ShellOptions,
"defaultEditorId": nullString(settings.DefaultEditorID),
"enablePreviewOnClick": settings.EnablePreviewOnClick,
"chatSubmitKey": defaultString(settings.ChatSubmitKey, "cmd_enter"),
"reviewAutoMarkOnScroll": settings.ReviewAutoMarkOnScroll,
"showReleaseNotification": settings.ShowReleaseNotification,
"releaseNotesLastSeenVersion": nullString(settings.ReleaseNotesLastSeenVersion),
"lspAutoStartLanguages": stringSlice(settings.LspAutoStartLanguages),
"lspAutoInstallLanguages": stringSlice(settings.LspAutoInstallLanguages),
"lspServerConfigs": mapStringMap(settings.LspServerConfigs),
"savedLayouts": settings.SavedLayouts,
"sidebarViews": mapSidebarViews(settings.SidebarViews),
"defaultUtilityAgentId": nullString(settings.DefaultUtilityAgentID),
"keyboardShortcuts": mapStringAny(settings.KeyboardShortcuts),
"terminalLinkBehavior": terminalLinkBehavior(settings.TerminalLinkBehavior),
"terminalFontFamily": nullString(settings.TerminalFontFamily),
"terminalFontSize": nullInt(settings.TerminalFontSize),
"changesPanelLayout": changesPanelLayout(settings.ChangesPanelLayout),
"systemMetricsDisplay": map[string]any{"showInTopbar": settings.SystemMetricsDisplay.ShowInTopbar},
"voiceMode": mapVoiceMode(settings.VoiceMode),
"loaded": true,
}
}
func mapUserSettingsStateWithWorkflow(response userdto.UserSettingsResponse, workspaceID, workflowID string) map[string]any {
state := mapUserSettingsState(response, workspaceID)
state["workflowId"] = nullString(workflowID)
return state
}
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,
"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,
}
}
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,
}
}
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),
"stage_type": nullString(step.StageType),
}
}
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,
"sessionCount": task.SessionCount,
"reviewStatus": nullString(string(task.ReviewStatus)),
"parentTaskId": nullString(task.ParentID),
"updatedAt": task.UpdatedAt,
"createdAt": task.CreatedAt,
}
}
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),
})
}
return result
}
func mapVoiceMode(value usermodels.VoiceModeSettings) map[string]any {
return map[string]any{
"enabled": value.Enabled,
"engine": defaultString(value.Engine, "auto"),
"language": defaultString(value.Language, "auto"),
"mode": defaultString(value.Mode, "toggle"),
"autoSend": value.AutoSend,
"whisperWebModel": defaultString(value.WhisperWebModel, "base"),
}
}
func resolveActiveOfficeWorkspaceID(workspaces []taskdto.WorkspaceDTO, cookieWorkspaceID string) string {
for _, workspace := range workspaces {
if workspace.ID == cookieWorkspaceID {
return workspace.ID
}
}
if len(workspaces) > 0 {
return workspaces[0].ID
}
return ""
}
func firstValidID(valid map[string]bool, candidates ...string) string {
for _, candidate := range candidates {
value := strings.TrimSpace(candidate)
if value != "" && valid[value] {
return value
}
}
return ""
}
func firstWorkspaceID(workspaces []*taskmodels.Workspace) string {
for _, workspace := range workspaces {
if workspace != nil && workspace.ID != "" {
return workspace.ID
}
}
return ""
}
func firstWorkflowID(workflows []*taskmodels.Workflow) string {
for _, workflow := range workflows {
if workflow != nil && workflow.ID != "" {
return workflow.ID
}
}
return ""
}
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))
}
func readActiveWorkspaceCookie(req *http.Request) string {
if req == nil {
return ""
}
for _, name := range []string{activeWorkspaceCookie, legacyOfficeWorkspaceCookie} {
cookie, err := req.Cookie(name)
if err == nil {
if value := strings.TrimSpace(cookie.Value); value != "" {
return value
}
}
}
return ""
}
func nullString(value string) any {
if value == "" {
return nil
}
return value
}
func nullInt(value int) any {
if value == 0 {
return nil
}
return value
}
func stringSlice(value []string) []string {
if value == nil {
return []string{}
}
return value
}
func mapStringAny(value map[string]any) map[string]any {
if value == nil {
return map[string]any{}
}
return value
}
func mapStringMap(value map[string]map[string]any) map[string]map[string]any {
if value == nil {
return map[string]map[string]any{}
}
return value
}
func defaultString(value, fallback string) string {
if value == "" {
return fallback
}
return value
}
func terminalLinkBehavior(value string) string {
if value == "browser_panel" {
return "browser_panel"
}
return "new_tab"
}
func changesPanelLayout(value string) string {
if value == "flat" {
return "flat"
}
return "tree"
}
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))
}