diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 8a190882be..fefe606819 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -84,16 +84,15 @@ jobs: path: apps/backend/bin/ retention-days: 1 - # Tar the .next directory to preserve pnpm symlinks in the standalone build. - # upload-artifact dereferences symlinks which breaks the Node.js module resolution. + # Tar the Vite dist directory so shards can reuse the same static web build. - name: Package web build - run: tar -cf apps/web/next-build.tar -C apps/web .next + run: tar -cf apps/web/web-dist.tar -C apps/web dist - name: Upload web build uses: actions/upload-artifact@v5 with: name: e2e-web-build - path: apps/web/next-build.tar + path: apps/web/web-dist.tar retention-days: 1 e2e: @@ -122,15 +121,8 @@ jobs: defaults: run: shell: bash - # Node 18+'s `dns.lookup` defaults to `verbatim: true`, returning the OS - # resolver order. Inside this container `localhost` resolves to ::1 first, - # so `net.Server.listen({ host: "localhost" })` (used by Next.js standalone - # via $HOSTNAME) binds IPv6-only — and undici's `fetch("http://localhost…")` - # in the test runner only tries the first resolved address (IPv4), so the - # frontend health check times out and every spec fails. Forcing - # IPv4-first ordering aligns both the bind and the fetch and matches the - # effective default on the github-hosted ubuntu runner this workflow - # previously ran on. + # Keep localhost resolution deterministic for Node fetches used by the + # Playwright fixtures and backend readiness polling. env: NODE_OPTIONS: --dns-result-order=ipv4first strategy: @@ -173,7 +165,7 @@ jobs: - name: Extract web build working-directory: apps/web - run: tar -xf next-build.tar && rm next-build.tar + run: tar -xf web-dist.tar && rm web-dist.tar # Pinned to chromium + mobile-chrome so the dedicated `e2e-containers` # job below owns the real-Docker project (which covers both the Docker @@ -334,7 +326,7 @@ jobs: - name: Extract web build working-directory: apps/web - run: tar -xf next-build.tar && rm next-build.tar + run: tar -xf web-dist.tar && rm web-dist.tar - name: Run container-backed E2E tests shard ${{ matrix.shard }}/6 (Docker executor + SSH executor) working-directory: apps/web diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 32cfad0fbe..ec6d8f0416 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,7 +5,7 @@ name: release # # Flow (one click → everything below): # 1. prepare — bump version, regen CHANGELOG, open + merge release PR, push tag vX.Y.Z -# 2. build-web — build Next.js standalone bundle (shared across arches) +# 2. build-web — build Vite SPA bundle (shared across arches) # 3. build-cli — build + esbuild-bundle CLI (shared across arches) # 4. build-bundles — assemble bin/ + web/ + cli/ tarballs for 5 platforms # 5. docker-{amd64,arm64} — native per-arch image builds (no QEMU), consuming @@ -251,7 +251,7 @@ jobs: echo "DRY RUN: CHANGELOG.md regenerated (in-runner only)." build-web: - name: Build web standalone + name: Build web SPA needs: prepare if: ${{ !inputs.dry_run }} runs-on: ubuntu-latest @@ -269,16 +269,16 @@ jobs: cache-dependency-path: apps/pnpm-lock.yaml - name: Install dependencies run: pnpm -C apps install --frozen-lockfile - - name: Build web (standalone) + - name: Build web (Vite) env: KANDEV_VERSION: ${{ needs.prepare.outputs.tag }} + VITE_KANDEV_API_PORT: "" + VITE_KANDEV_DEBUG: "" run: pnpm -C apps --filter @kandev/web build - - name: Package web bundle - run: bash scripts/release/package-web.sh - uses: actions/upload-artifact@v5 with: name: web-bundle - path: dist/web + path: apps/web/dist include-hidden-files: true build-cli: @@ -351,7 +351,7 @@ jobs: - uses: actions/download-artifact@v5 with: name: web-bundle - path: dist/web + path: apps/backend/internal/webapp/embedded/generated - uses: actions/download-artifact@v5 with: name: cli-bundle @@ -377,6 +377,7 @@ jobs: CC: ${{ matrix.cc || '' }} KANDEV_VERSION: ${{ needs.prepare.outputs.tag }} run: | + test -f apps/backend/internal/webapp/embedded/generated/index.html cd apps/backend mkdir -p bin EXT=${{ matrix.goos == 'windows' && '.exe' || '' }} @@ -408,8 +409,6 @@ jobs: cp apps/backend/bin/kandev${EXT} dist/kandev/bin/ cp apps/backend/bin/agentctl${EXT} dist/kandev/bin/ cp apps/backend/bin/agentctl-linux-amd64 dist/kandev/bin/ - mkdir -p dist/kandev/web - cp -R dist/web/. dist/kandev/web/ # cli/ was already placed by the build-cli job (downloaded above). # actions/upload-artifact + download-artifact zip-roundtrip strips the # executable bit, so re-apply it. The Homebrew formula's wrapper at diff --git a/AGENTS.md b/AGENTS.md index 69740e22c7..dedfe38753 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,7 @@ ``` apps/ ├── backend/ # Go backend (orchestrator, lifecycle, agentctl, WS gateway) -├── web/ # Next.js frontend (SSR + WS + Zustand) +├── web/ # Vite/React SPA frontend (Go boot payload + WS + Zustand) ├── cli/ # CLI tool (TypeScript) ├── landing/ # Landing page └── packages/ # Shared packages/types @@ -17,7 +17,7 @@ apps/ - **Package manager**: `pnpm` workspace (run from `apps/`, not repo root) - **Backend**: Go with Make (`make -C apps/backend test|lint|build`) -- **Frontend**: Next.js (`cd apps && pnpm --filter @kandev/web dev|lint|typecheck`) +- **Frontend**: Vite/React SPA (`cd apps && pnpm --filter @kandev/web dev|build:vite|lint|typecheck`) - **UI**: Shadcn components via `@kandev/ui` - **E2E**: Playwright (`cd apps/web && pnpm e2e`). The `containers` project (gated on `KANDEV_E2E_CONTAINERS=1`, formerly `docker`) covers both the Docker executor and the SSH executor — anything that needs a real Docker daemon on the host lives there. See `apps/web/e2e/README.md`. - **GitHub repo**: `https://github.com/kdlbs/kandev` @@ -37,7 +37,7 @@ Architecture notes and per-area conventions live alongside the code they describ - `apps/backend/internal/agentctl/AGENTS.md` — agentctl HTTP server: route groups, adapter model, ACP protocol. - `apps/backend/internal/agentctl/server/api/AGENTS.md` — reverse-proxy body rewriting (`Accept-Encoding`), iframe-blocking header stripping. - `apps/backend/internal/integrations/AGENTS.md` — adding a new third-party integration (Jira/Linear pattern, both backend and frontend halves). The `/add-integration` skill mirrors this for scaffolding new integrations. -- `apps/web/AGENTS.md` — Next.js frontend: shadcn imports, SSR-hydrate-store data flow, store slice structure (incl. `office`), WS format, component conventions, TS lint limits. +- `apps/web/AGENTS.md` — Vite/React SPA frontend: shadcn imports, Go boot-payload hydration, store slice structure (incl. `office`), WS format, component conventions, TS lint limits. --- diff --git a/Makefile b/Makefile index a20ef8cdb8..ac3639d3dd 100644 --- a/Makefile +++ b/Makefile @@ -1,10 +1,11 @@ # Kandev Root Makefile -# Orchestrates both backend (Go) and web app (Next.js) +# Orchestrates both backend (Go) and web app (Vite/React) # Directories BACKEND_DIR := apps/backend WEB_DIR := apps/web APPS_DIR := apps +EMBEDDED_WEB_DIR := $(BACKEND_DIR)/internal/webapp/embedded/generated # Tools PNPM := pnpm @@ -194,7 +195,7 @@ dev-web: # .PHONY: build -build: build-backend build-web +build: build-web sync-embedded-web build-backend @printf "\n$(GREEN)$(BOLD)✓ Build complete!$(RESET)\n" # @@ -208,8 +209,9 @@ start: @$(MAKE) -s install-web $(call success,Dependencies installed) $(call phase,Building) - @$(MAKE) -s build-backend-quiet @$(MAKE) -s build-web-quiet + @$(MAKE) -s sync-embedded-web + @$(MAKE) -s build-backend-quiet $(call success,Build complete) $(call phase,Starting Server) @cd $(APPS_DIR) && $(PNPM) -C cli dev -- start $(if $(filter 1 true yes,$(VERBOSE)),--verbose,) $(if $(filter 1 true yes,$(DEBUG)),--debug,) @@ -229,7 +231,6 @@ start-debug: .PHONY: service-bundle service-bundle: install build $(call phase,Packaging Service Bundle) - @scripts/release/package-web.sh @scripts/release/package-cli.sh @test -n "$(SERVICE_BUNDLE_DIR)" || { echo "SERVICE_BUNDLE_DIR is empty; aborting."; exit 1; } @test "$(SERVICE_BUNDLE_DIR)" != "/" || { echo "SERVICE_BUNDLE_DIR must not be /; aborting."; exit 1; } @@ -299,12 +300,20 @@ build-backend-quiet: .PHONY: build-web build-web: @printf "$(CYAN)Building web app...$(RESET)\n" - @cd $(APPS_DIR) && $(PNPM) --filter @kandev/web build + @cd $(APPS_DIR) && VITE_KANDEV_API_PORT= VITE_KANDEV_DEBUG= $(PNPM) --filter @kandev/web build .PHONY: build-web-quiet build-web-quiet: @printf " $(DIM)Web app$(RESET)\n" - @cd $(APPS_DIR) && $(PNPM) --filter @kandev/web build 2>&1 | grep -v "Warning:" | grep -v "parseLineType" | grep -v "^$$" || true + @cd $(APPS_DIR) && VITE_KANDEV_API_PORT= VITE_KANDEV_DEBUG= $(PNPM) --filter @kandev/web build 2>&1 | grep -v "Warning:" | grep -v "parseLineType" | grep -v "^$$" || true + +.PHONY: sync-embedded-web +sync-embedded-web: + @test -f "$(WEB_DIR)/dist/index.html" || { echo "Missing $(WEB_DIR)/dist/index.html; run 'make build-web' first."; exit 1; } + @mkdir -p "$(EMBEDDED_WEB_DIR)" + @find "$(EMBEDDED_WEB_DIR)" -mindepth 1 ! -name .gitignore ! -name keep.txt -exec rm -rf {} + + @cp -R "$(WEB_DIR)/dist/." "$(EMBEDDED_WEB_DIR)/" + @printf " $(DIM)Embedded web assets$(RESET)\n" # # Installation @@ -480,7 +489,7 @@ clean-backend: .PHONY: clean-web clean-web: @printf "$(CYAN)Cleaning web artifacts...$(RESET)\n" - @$(RMDIR) $(WEB_DIR)/.next $(APPS_DIR)/node_modules + @$(RMDIR) $(WEB_DIR)/dist $(WEB_DIR)/.next $(APPS_DIR)/node_modules @$(RMDIR) $(APPS_DIR)/packages/*/node_modules .PHONY: clean-db diff --git a/apps/backend/cmd/kandev/adapters.go b/apps/backend/cmd/kandev/adapters.go index bbaee6b21f..7a414c8cf9 100644 --- a/apps/backend/cmd/kandev/adapters.go +++ b/apps/backend/cmd/kandev/adapters.go @@ -367,6 +367,11 @@ func (a *lifecycleAdapter) IsAgentRunningForSession(ctx context.Context, session return a.mgr.IsAgentRunningForSession(ctx, sessionID) } +// IsAgentReadyForPrompt checks if the session can accept a prompt immediately. +func (a *lifecycleAdapter) IsAgentReadyForPrompt(ctx context.Context, sessionID string) bool { + return a.mgr.IsAgentReadyForPrompt(ctx, sessionID) +} + // IsPassthroughSession checks if the given session is running in passthrough (PTY) mode. func (a *lifecycleAdapter) IsPassthroughSession(ctx context.Context, sessionID string) bool { return a.mgr.IsPassthroughSession(ctx, sessionID) diff --git a/apps/backend/cmd/kandev/boot_state.go b/apps/backend/cmd/kandev/boot_state.go new file mode 100644 index 0000000000..aef2e20794 --- /dev/null +++ b/apps/backend/cmd/kandev/boot_state.go @@ -0,0 +1,721 @@ +package main + +import ( + "context" + "net/http" + + agentsettingsdto "github.com/kandev/kandev/internal/agent/settings/dto" + taskdto "github.com/kandev/kandev/internal/task/dto" + taskmodels "github.com/kandev/kandev/internal/task/models" + taskservice "github.com/kandev/kandev/internal/task/service" + userdto "github.com/kandev/kandev/internal/user/dto" + "github.com/kandev/kandev/internal/webapp" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +const ( + activeWorkspaceCookie = "kandev-active-workspace" + legacyOfficeWorkspaceCookie = "office-active-workspace" +) + +func bootInitialState( + ctx context.Context, + req *http.Request, + p routeParams, + route webapp.RouteClassification, +) map[string]any { + builder := bootStateBuilder{p: p} + state := map[string]any{ + "features": p.features, + } + + if route.Route == webapp.RouteSettings { + builder.addWorkspaceState(ctx, state, nil) + builder.addUserSettingsState(ctx, state, "") + builder.addSettingsRouteState(ctx, state, route.Path) + } + if route.Route == webapp.RouteHome { + builder.addHomeKanbanRouteState(ctx, req, state) + } + if route.Route == webapp.RouteTasks { + tasksState, _ := builder.tasksPageBootData(ctx, req) + mergeBootState(state, tasksState) + } + if isLocalContextRoute(route.Route) { + contextState, _ := builder.routeContextBootData(ctx, req) + mergeBootState(state, contextState) + } + if route.Route == webapp.RouteOffice { + builder.addOfficeRouteState(ctx, req, state) + } + return state +} + +func bootRouteData( + ctx context.Context, + req *http.Request, + p routeParams, + route webapp.RouteClassification, +) map[string]any { + builder := bootStateBuilder{p: p} + switch route.Route { + case webapp.RouteTaskDetail: + return builder.taskDetailRouteData(ctx, route.Params["taskId"]) + case webapp.RouteTasks: + _, routeData := builder.tasksPageBootData(ctx, req) + if routeData == nil { + return nil + } + return map[string]any{"tasksPage": routeData} + case webapp.RouteGitHub, webapp.RouteGitLab, webapp.RouteJira, webapp.RouteLinear, webapp.RouteStats: + _, routeData := builder.routeContextBootData(ctx, req) + if routeData == nil { + return nil + } + return map[string]any{"routeContext": routeData} + default: + return nil + } +} + +func isLocalContextRoute(route webapp.RouteName) bool { + switch route { + case webapp.RouteGitHub, webapp.RouteGitLab, webapp.RouteJira, webapp.RouteLinear, webapp.RouteStats: + return true + default: + return false + } +} + +type bootStateBuilder struct { + p routeParams +} + +func (b bootStateBuilder) addWorkspaceState(ctx context.Context, state map[string]any, activeID *string) { + if b.p.taskSvc == nil { + return + } + workspaces, err := b.p.taskSvc.ListWorkspaces(ctx) + if err != nil { + b.logBootError("list workspaces", err) + return + } + items := make([]taskdto.WorkspaceDTO, 0, len(workspaces)) + for _, workspace := range workspaces { + if workspace == nil { + continue + } + items = append(items, taskdto.FromWorkspace(workspace)) + } + var active any + if activeID != nil { + active = *activeID + } + state["workspaces"] = map[string]any{ + "items": items, + "activeId": active, + } +} + +func (b bootStateBuilder) addUserSettingsState(ctx context.Context, state map[string]any, workspaceID string) { + if b.p.userCtrl == nil { + return + } + response, err := b.p.userCtrl.GetUserSettings(ctx) + if err != nil { + b.logBootError("get user settings", err) + return + } + state["userSettings"] = mapUserSettingsState(response, workspaceID) +} + +func (b bootStateBuilder) addSettingsRouteState(ctx context.Context, state map[string]any, path string) { + switch path { + case "/settings/prompts": + b.addPromptsState(ctx, state) + case "/settings/general/editors": + b.addEditorsState(ctx, state) + } +} + +func (b bootStateBuilder) addHomeKanbanRouteState(ctx context.Context, req *http.Request, state map[string]any) { + if b.p.taskSvc == nil { + return + } + workspaces, err := b.p.taskSvc.ListWorkspaces(ctx) + if err != nil { + b.logBootError("list home workspaces", err) + return + } + workspaceItems := make([]map[string]any, 0, len(workspaces)) + workspaceIDs := make(map[string]bool, len(workspaces)) + for _, workspace := range workspaces { + if workspace == nil { + continue + } + workspaceIDs[workspace.ID] = true + workspaceItems = append(workspaceItems, mapWorkspaceItemState(taskdto.FromWorkspace(workspace))) + } + + settings, hasSettings := b.userSettings(ctx) + settingsWorkspaceID := "" + settingsWorkflowID := "" + if hasSettings { + settingsWorkspaceID = settings.Settings.WorkspaceID + settingsWorkflowID = settings.Settings.WorkflowFilterID + } + activeWorkspaceID := firstValidID( + workspaceIDs, + queryValue(req, "workspaceId"), + readActiveWorkspaceCookie(req), + settingsWorkspaceID, + firstWorkspaceID(workspaces), + ) + state["workspaces"] = map[string]any{ + "items": workspaceItems, + "activeId": nullString(activeWorkspaceID), + } + if hasSettings { + state["userSettings"] = mapUserSettingsState(settings, activeWorkspaceID) + } + if activeWorkspaceID == "" { + return + } + + workflows, err := b.homeWorkflows(ctx, activeWorkspaceID) + if err != nil { + b.logBootError("list home workflows", err) + return + } + workflowItems := make([]map[string]any, 0, len(workflows)) + for _, workflow := range workflows { + if workflow == nil { + continue + } + workflowItems = append(workflowItems, mapWorkflowItemState(taskdto.FromWorkflow(workflow))) + } + activeWorkflowID := resolveHomeWorkflowID(workflows, queryValue(req, "workflowId"), settingsWorkflowID, hasSettings) + state["workflows"] = map[string]any{ + "items": workflowItems, + "activeId": nullString(activeWorkflowID), + } + if hasSettings { + state["userSettings"] = mapUserSettingsStateWithWorkflow(settings, activeWorkspaceID, activeWorkflowID) + } + b.addRepositoriesState(ctx, state, activeWorkspaceID) + b.addKanbanSnapshotsState(ctx, state, workflows, activeWorkflowID) +} + +func (b bootStateBuilder) userSettings(ctx context.Context) (userdto.UserSettingsResponse, bool) { + if b.p.userCtrl == nil { + return userdto.UserSettingsResponse{}, false + } + response, err := b.p.userCtrl.GetUserSettings(ctx) + if err != nil { + b.logBootError("get user settings", err) + return userdto.UserSettingsResponse{}, false + } + return response, true +} + +func (b bootStateBuilder) homeWorkflows(ctx context.Context, workspaceID string) ([]*taskmodels.Workflow, error) { + workflows, err := b.p.taskSvc.ListWorkflows(ctx, workspaceID, true) + if err != nil { + return nil, err + } + officeIDs := b.p.taskSvc.GetOfficeWorkflowIDs(ctx) + filtered := make([]*taskmodels.Workflow, 0, len(workflows)) + for _, workflow := range workflows { + if workflow == nil { + continue + } + if _, isOffice := officeIDs[workflow.ID]; isOffice { + continue + } + filtered = append(filtered, workflow) + } + return filtered, nil +} + +func (b bootStateBuilder) addRepositoriesState(ctx context.Context, state map[string]any, workspaceID string) { + repositories, err := b.p.taskSvc.ListRepositories(ctx, workspaceID) + if err != nil { + b.logBootError("list home repositories", err) + return + } + items := make([]taskdto.RepositoryDTO, 0, len(repositories)) + for _, repository := range repositories { + if repository == nil { + continue + } + items = append(items, taskdto.FromRepository(repository)) + } + state["repositories"] = map[string]any{ + "itemsByWorkspaceId": map[string]any{workspaceID: items}, + "loadingByWorkspaceId": map[string]any{ + workspaceID: false, + }, + "loadedByWorkspaceId": map[string]any{ + workspaceID: true, + }, + } +} + +func (b bootStateBuilder) addKanbanSnapshotsState( + ctx context.Context, + state map[string]any, + workflows []*taskmodels.Workflow, + activeWorkflowID string, +) { + snapshots := make(map[string]any, len(workflows)) + var active map[string]any + for _, workflow := range workflows { + if workflow == nil { + continue + } + snapshot, ok := b.workflowSnapshotState(ctx, workflow) + if !ok { + continue + } + snapshots[workflow.ID] = snapshot + if workflow.ID == activeWorkflowID { + active = snapshot + } + } + state["kanbanMulti"] = map[string]any{ + "snapshots": snapshots, + "isLoading": false, + } + if active != nil { + state["kanban"] = map[string]any{ + "workflowId": active["workflowId"], + "steps": active["steps"], + "tasks": active["tasks"], + "isLoading": false, + } + } +} + +func (b bootStateBuilder) workflowSnapshotState(ctx context.Context, workflow *taskmodels.Workflow) (map[string]any, bool) { + steps, err := b.workflowStepStates(ctx, workflow.ID) + if err != nil { + b.logBootError("list home workflow steps", err) + return nil, false + } + tasks, err := b.p.taskSvc.ListTasks(ctx, workflow.ID) + if err != nil { + b.logBootError("list home workflow tasks", err) + return nil, false + } + taskStates := make([]map[string]any, 0, len(tasks)) + for _, task := range tasks { + if task == nil || task.IsEphemeral || task.WorkflowStepID == "" { + continue + } + taskStates = append(taskStates, mapKanbanTaskState(taskdto.FromTask(task))) + } + return map[string]any{ + "workflowId": workflow.ID, + "workflowName": workflow.Name, + "steps": steps, + "tasks": taskStates, + }, true +} + +func (b bootStateBuilder) workflowStepStates(ctx context.Context, workflowID string) ([]map[string]any, error) { + if b.p.services == nil || b.p.services.Workflow == nil { + return []map[string]any{}, nil + } + steps, err := b.p.services.Workflow.ListStepsByWorkflow(ctx, workflowID) + if err != nil { + return nil, err + } + result := make([]map[string]any, 0, len(steps)) + for _, step := range steps { + if step == nil { + continue + } + result = append(result, mapKanbanStepState(taskdto.FromWorkflowStepWithTimestamps(step))) + } + return result, nil +} + +func (b bootStateBuilder) taskDetailRouteData(ctx context.Context, taskID string) map[string]any { + if b.p.taskSvc == nil || taskID == "" { + return nil + } + task, err := b.p.taskSvc.GetTask(ctx, taskID) + if err != nil { + b.logBootError("get task detail task", err) + return nil + } + sessions, err := b.p.taskSvc.ListTaskSessions(ctx, task.ID) + if err != nil { + b.logBootError("list task detail sessions", err) + sessions = nil + } + activeSessionID := resolveTaskDetailSessionID(task, sessions) + taskDTO := b.taskDTOWithSessionInfo(ctx, task) + initialState := b.taskDetailInitialState(ctx, task, taskDTO, sessions, activeSessionID) + return map[string]any{ + "taskDetail": map[string]any{ + "task": taskDTO, + "sessionId": nullString(activeSessionID), + "initialState": initialState, + "initialTerminals": []any{}, + }, + } +} + +func resolveTaskDetailSessionID(task *taskmodels.Task, sessions []*taskmodels.TaskSession) string { + if task != nil { + for _, session := range sessions { + if session != nil && session.IsPrimary { + return session.ID + } + } + } + for _, session := range sessions { + if session != nil && session.ID != "" { + return session.ID + } + } + return "" +} + +func (b bootStateBuilder) taskDTOWithSessionInfo(ctx context.Context, task *taskmodels.Task) taskdto.TaskDTO { + if task == nil { + return taskdto.TaskDTO{} + } + dtos := b.taskDTOsWithSessionInfo(ctx, []*taskmodels.Task{task}) + if len(dtos) == 0 { + return taskdto.FromTask(task) + } + return dtos[0] +} + +func (b bootStateBuilder) taskDTOsWithSessionInfo(ctx context.Context, tasks []*taskmodels.Task) []taskdto.TaskDTO { + if len(tasks) == 0 { + return []taskdto.TaskDTO{} + } + taskIDs := make([]string, 0, len(tasks)) + for _, task := range tasks { + if task != nil { + taskIDs = append(taskIDs, task.ID) + } + } + sessionsByTask, err := b.p.taskSvc.BatchGetSessionsForTasks(ctx, taskIDs) + if err != nil { + b.logBootError("batch task detail sessions", err) + return taskDTOs(tasks) + } + primaryInfoByTask, err := b.p.taskSvc.GetPrimarySessionInfoForTasks(ctx, taskIDs) + if err != nil { + b.logBootError("get task detail primary session info", err) + return taskDTOs(tasks) + } + result := make([]taskdto.TaskDTO, 0, len(tasks)) + for _, task := range tasks { + if task == nil { + continue + } + sessions := sessionsByTask[task.ID] + var primarySessionID *string + for _, session := range sessions { + if session != nil && session.IsPrimary { + id := session.ID + primarySessionID = &id + break + } + } + var sessionCount *int + if len(sessions) > 0 { + count := len(sessions) + sessionCount = &count + } + info := bootSessionInfo(primaryInfoByTask[task.ID]) + result = append(result, taskdto.FromTaskWithSessionInfo( + task, + primarySessionID, + sessionCount, + info.reviewStatus, + info.executorID, + info.executorType, + info.executorName, + info.agentName, + info.workingDirectory, + info.sessionState, + )) + } + return result +} + +func taskDTOs(tasks []*taskmodels.Task) []taskdto.TaskDTO { + result := make([]taskdto.TaskDTO, 0, len(tasks)) + for _, task := range tasks { + if task != nil { + result = append(result, taskdto.FromTask(task)) + } + } + return result +} + +type bootSessionInfoFields struct { + reviewStatus taskmodels.ReviewStatus + sessionState *string + executorID *string + executorType *string + executorName *string + agentName *string + workingDirectory *string +} + +func bootSessionInfo(session *taskmodels.TaskSession) bootSessionInfoFields { + var info bootSessionInfoFields + if session == nil { + return info + } + info.reviewStatus = session.ReviewStatus + if session.State != "" { + value := string(session.State) + info.sessionState = &value + } + if session.ExecutorID != "" { + value := session.ExecutorID + info.executorID = &value + } + if session.ExecutorSnapshot != nil { + if value, ok := session.ExecutorSnapshot["executor_type"].(string); ok && value != "" { + info.executorType = &value + } + if value, ok := session.ExecutorSnapshot["executor_name"].(string); ok && value != "" { + info.executorName = &value + } + } + if session.AgentProfileSnapshot != nil { + if value, ok := session.AgentProfileSnapshot["name"].(string); ok && value != "" { + info.agentName = &value + } + } + if session.RepositorySnapshot != nil { + if value, ok := session.RepositorySnapshot["path"].(string); ok && value != "" { + info.workingDirectory = &value + } + } + return info +} + +func (b bootStateBuilder) taskDetailInitialState( + ctx context.Context, + task *taskmodels.Task, + taskDTO taskdto.TaskDTO, + sessions []*taskmodels.TaskSession, + activeSessionID string, +) map[string]any { + state := map[string]any{} + b.addTaskDetailResourceState(ctx, state, task) + b.addTaskDetailKanbanState(ctx, state, task) + b.addTaskDetailActiveTaskState(ctx, state, taskDTO, activeSessionID) + b.addTaskDetailSessionsState(state, task.ID, sessions, activeSessionID) + b.addTaskDetailAgentsState(ctx, state) + return state +} + +func (b bootStateBuilder) addTaskDetailResourceState(ctx context.Context, state map[string]any, task *taskmodels.Task) { + b.addWorkspaceState(ctx, state, &task.WorkspaceID) + b.addUserSettingsState(ctx, state, task.WorkspaceID) + workflows, err := b.p.taskSvc.ListWorkflows(ctx, task.WorkspaceID, true) + if err != nil { + b.logBootError("list task detail workflows", err) + } else { + state["workflows"] = map[string]any{ + "items": workflowItemStates(workflows), + "activeId": nil, + } + } + b.addRepositoriesState(ctx, state, task.WorkspaceID) +} + +func workflowItemStates(workflows []*taskmodels.Workflow) []map[string]any { + items := make([]map[string]any, 0, len(workflows)) + for _, workflow := range workflows { + if workflow != nil { + items = append(items, mapWorkflowItemState(taskdto.FromWorkflow(workflow))) + } + } + return items +} + +func (b bootStateBuilder) addTaskDetailKanbanState(ctx context.Context, state map[string]any, task *taskmodels.Task) { + if task.WorkflowID == "" { + state["kanban"] = map[string]any{"workflowId": "", "steps": []any{}, "tasks": []any{}, "isLoading": false} + return + } + workflows, err := b.p.taskSvc.ListWorkflows(ctx, task.WorkspaceID, true) + if err != nil { + b.logBootError("list task detail kanban workflows", err) + return + } + for _, workflow := range workflows { + if workflow == nil || workflow.ID != task.WorkflowID { + continue + } + snapshot, ok := b.workflowSnapshotState(ctx, workflow) + if !ok { + return + } + state["kanban"] = map[string]any{ + "workflowId": snapshot["workflowId"], + "steps": snapshot["steps"], + "tasks": snapshot["tasks"], + "isLoading": false, + } + state["kanbanMulti"] = map[string]any{ + "snapshots": map[string]any{workflow.ID: snapshot}, + "isLoading": false, + } + return + } +} + +func (b bootStateBuilder) addTaskDetailActiveTaskState( + ctx context.Context, + state map[string]any, + task taskdto.TaskDTO, + activeSessionID string, +) { + state["tasks"] = map[string]any{ + "activeTaskId": task.ID, + "activeSessionId": nullString(activeSessionID), + "pinnedSessionId": nil, + "lastSessionByTaskId": lastSessionByTaskState(task.ID, activeSessionID), + } + if activeSessionID == "" { + return + } + messages, hasMore, err := b.p.taskSvc.ListMessagesPaginated(ctx, taskservice.ListMessagesRequest{ + TaskSessionID: activeSessionID, + Limit: 50, + Sort: "desc", + }) + if err != nil { + b.logBootError("list task detail messages", err) + return + } + apiMessages := make([]*v1.Message, 0, len(messages)) + for i := len(messages) - 1; i >= 0; i-- { + if messages[i] != nil { + apiMessages = append(apiMessages, messages[i].ToAPI()) + } + } + var oldest any + if len(apiMessages) > 0 { + oldest = apiMessages[0].ID + } + state["messages"] = map[string]any{ + "bySession": map[string]any{activeSessionID: apiMessages}, + "metaBySession": map[string]any{ + activeSessionID: map[string]any{ + "isLoading": false, + "hasMore": hasMore, + "oldestCursor": oldest, + }, + }, + } +} + +func lastSessionByTaskState(taskID, sessionID string) map[string]string { + if taskID == "" || sessionID == "" { + return map[string]string{} + } + return map[string]string{taskID: sessionID} +} + +func (b bootStateBuilder) addTaskDetailSessionsState( + state map[string]any, + taskID string, + sessions []*taskmodels.TaskSession, + activeSessionID string, +) { + sessionItems := make(map[string]taskdto.TaskSessionDTO, len(sessions)) + sessionList := make([]taskdto.TaskSessionDTO, 0, len(sessions)) + environmentBySession := make(map[string]string, len(sessions)) + worktrees := make(map[string]any) + worktreesBySession := make(map[string]any) + for _, session := range sessions { + if session == nil { + continue + } + dto := taskdto.FromTaskSession(session) + sessionItems[session.ID] = dto + sessionList = append(sessionList, dto) + if session.TaskEnvironmentID != "" { + environmentBySession[session.ID] = session.TaskEnvironmentID + } + if dto.WorktreeID != "" { + worktrees[dto.WorktreeID] = map[string]any{ + "id": dto.WorktreeID, + "sessionId": session.ID, + "repositoryId": nullString(dto.RepositoryID), + "path": nullString(dto.WorktreePath), + "branch": nullString(dto.WorktreeBranch), + } + worktreesBySession[session.ID] = []string{dto.WorktreeID} + } + } + state["taskSessions"] = map[string]any{"items": sessionItems} + state["taskSessionsByTask"] = map[string]any{ + "itemsByTaskId": map[string]any{taskID: sessionList}, + "loadingByTaskId": map[string]any{taskID: false}, + "loadedByTaskId": map[string]any{taskID: true}, + } + state["turns"] = map[string]any{ + "bySession": map[string]any{}, + "activeBySession": activeTurnBySessionState(activeSessionID), + } + state["environmentIdBySessionId"] = environmentBySession + state["worktrees"] = map[string]any{"items": worktrees} + state["sessionWorktreesBySessionId"] = map[string]any{"itemsBySessionId": worktreesBySession} +} + +func activeTurnBySessionState(sessionID string) map[string]any { + if sessionID == "" { + return map[string]any{} + } + return map[string]any{sessionID: nil} +} + +func (b bootStateBuilder) addTaskDetailAgentsState(ctx context.Context, state map[string]any) { + if b.p.agentSettingsController == nil { + return + } + response, err := b.p.agentSettingsController.ListAgents(ctx) + if err != nil { + b.logBootError("list task detail agents", err) + return + } + state["settingsAgents"] = map[string]any{"items": response.Agents} + state["settingsData"] = map[string]any{"agentsLoaded": true, "executorsLoaded": false} + state["agentProfiles"] = map[string]any{ + "items": agentProfileOptionStates(response.Agents), + "version": 0, + } +} + +func agentProfileOptionStates(agents []agentsettingsdto.AgentDTO) []map[string]any { + items := []map[string]any{} + for _, agent := range agents { + for _, profile := range agent.Profiles { + items = append(items, map[string]any{ + "id": profile.ID, + "label": profile.AgentDisplayName + " - " + profile.Name, + "agent_id": agent.ID, + "agent_name": agent.Name, + "cli_passthrough": profile.CLIPassthrough, + "capability_status": nullString(agent.CapabilityStatus), + "capability_error": nullString(agent.CapabilityError), + }) + } + } + return items +} diff --git a/apps/backend/cmd/kandev/boot_state_routes.go b/apps/backend/cmd/kandev/boot_state_routes.go new file mode 100644 index 0000000000..7cfc6ab327 --- /dev/null +++ b/apps/backend/cmd/kandev/boot_state_routes.go @@ -0,0 +1,714 @@ +package main + +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)) +} diff --git a/apps/backend/cmd/kandev/helpers.go b/apps/backend/cmd/kandev/helpers.go index c09720b2d6..660d95a72c 100644 --- a/apps/backend/cmd/kandev/helpers.go +++ b/apps/backend/cmd/kandev/helpers.go @@ -5,9 +5,9 @@ import ( "database/sql" "errors" "fmt" + "io/fs" "net/http" - "net/http/httputil" - "net/url" + "os" "strings" "time" @@ -75,6 +75,8 @@ import ( utilityhandlers "github.com/kandev/kandev/internal/utility/handlers" voicehandlers "github.com/kandev/kandev/internal/voice/handlers" "github.com/kandev/kandev/internal/voice/transcribe" + "github.com/kandev/kandev/internal/webapp" + webembedded "github.com/kandev/kandev/internal/webapp/embedded" workflowcontroller "github.com/kandev/kandev/internal/workflow/controller" workflowhandlers "github.com/kandev/kandev/internal/workflow/handlers" "github.com/kandev/kandev/internal/worktree" @@ -144,6 +146,9 @@ func appendAgentctlStatusMessage( if execution.TaskEnvironmentID != "" { payload["task_environment_id"] = execution.TaskEnvironmentID } + if execution.WorkspacePath != "" { + payload["worktree_path"] = execution.WorkspacePath + } action := ws.ActionSessionAgentctlStarting if execution.GetWorkspaceStream() != nil { action = ws.ActionSessionAgentctlReady @@ -558,21 +563,31 @@ func registerRoutes(p routeParams) { p.router.GET("/api/v1/features", func(c *gin.Context) { c.JSON(http.StatusOK, p.features) }) + p.router.GET("/api/v1/app-state", func(c *gin.Context) { + routePath := c.Query("path") + if routePath == "" { + routePath = c.Request.URL.Path + } + route := webapp.ClassifyRoute(routePath) + payload := bootPayload(c.Request.Context(), c.Request, p, route) + c.JSON(http.StatusOK, payload) + }) + + if p.webInternalURL == "" { + if handler, distDir, ok := newWebAppHandler(p); ok { + p.router.NoRoute(func(c *gin.Context) { + handler.ServeHTTP(c.Writer, c.Request) + }) + p.log.Info("Web SPA static serving enabled", zap.String("dist_dir", distDir)) + return + } + } if p.webInternalURL != "" { - target, err := url.Parse(p.webInternalURL) + handler, err := newWebDevHandler(p) if err != nil { - p.log.Error("Invalid web internal URL, skipping reverse proxy", zap.String("url", p.webInternalURL), zap.Error(err)) + p.log.Error("Invalid web internal URL, skipping web dev handler", zap.String("url", p.webInternalURL), zap.Error(err)) } else { - proxy := httputil.NewSingleHostReverseProxy(target) - proxy.FlushInterval = -1 - // ErrorHandler catches upstream failures (e.g., connection refused) - // that occur before any response headers are written, returning a - // clean 502 instead of letting the default handler log an error. - proxy.ErrorHandler = func(w http.ResponseWriter, _ *http.Request, err error) { - p.log.Debug("Web proxy error", zap.Error(err)) - w.WriteHeader(http.StatusBadGateway) - } p.router.NoRoute(func(c *gin.Context) { path := c.Request.URL.Path if strings.HasPrefix(path, "/api/") || path == "/ws" || path == "/health" { @@ -592,13 +607,70 @@ func registerRoutes(p routeParams) { panic(r) } }() - proxy.ServeHTTP(c.Writer, c.Request) + handler.ServeHTTP(c.Writer, c.Request) }) - p.log.Info("Web reverse proxy enabled", zap.String("target", p.webInternalURL)) + p.log.Info("Web dev handler enabled", zap.String("target", p.webInternalURL)) } } } +func newWebAppHandler(p routeParams) (*webapp.Handler, string, bool) { + assets, source, ok := webAssetsFS() + if !ok { + return nil, "", false + } + handler := webapp.NewHandler(assets, webAppHandlerOptions(p)...) + return handler, source, true +} + +func newWebDevHandler(p routeParams) (*webapp.DevHandler, error) { + return webapp.NewDevHandler(p.webInternalURL, webAppHandlerOptions(p)...) +} + +func webAppHandlerOptions(p routeParams) []webapp.HandlerOption { + return []webapp.HandlerOption{ + webapp.WithRuntimeConfig(webapp.RuntimeConfig{APIPrefix: "/api/v1", WebSocketPath: "/ws"}), + webapp.WithPayloadBuilder(func(req *http.Request, route webapp.RouteClassification) webapp.BootPayload { + return bootPayload(req.Context(), req, p, route) + }), + } +} + +func bootPayload(ctx context.Context, req *http.Request, p routeParams, route webapp.RouteClassification) webapp.BootPayload { + payload := webapp.NewBootPayload( + route, + webapp.RuntimeConfig{APIPrefix: "/api/v1", WebSocketPath: "/ws"}, + bootInitialState(ctx, req, p, route), + ) + payload.RouteData = bootRouteData(ctx, req, p, route) + return payload +} + +func webAssetsFS() (fs.FS, string, bool) { + distDir := os.Getenv("KANDEV_WEB_DIST_DIR") + if distDir == "" { + distDir = firstExistingDir("apps/web/dist", "../web/dist", "../../apps/web/dist") + } + if distDir != "" { + return os.DirFS(distDir), distDir, true + } + assets, err := webembedded.FS() + if err != nil { + return nil, "", false + } + return assets, "embedded", true +} + +func firstExistingDir(candidates ...string) string { + for _, candidate := range candidates { + info, err := os.Stat(candidate) + if err == nil && info.IsDir() { + return candidate + } + } + return "" +} + // resolvePrimaryTaskRepositoryID returns the primary (lowest-position) // task_repositories.repository_id for a task, or "" if none / on error. // Used by PR-import callbacks where the PR is associated with the task's diff --git a/apps/backend/cmd/kandev/helpers_test.go b/apps/backend/cmd/kandev/helpers_test.go index d529e5899d..4c6ed2cde3 100644 --- a/apps/backend/cmd/kandev/helpers_test.go +++ b/apps/backend/cmd/kandev/helpers_test.go @@ -1,14 +1,33 @@ package main import ( + "context" "encoding/json" "net/http" "net/http/httptest" + "path/filepath" + "strings" "testing" "time" "github.com/gin-gonic/gin" + "github.com/jmoiron/sqlx" + "github.com/kandev/kandev/internal/agent/runtime/lifecycle" + "github.com/kandev/kandev/internal/common/config" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/db" + "github.com/kandev/kandev/internal/events/bus" + taskdto "github.com/kandev/kandev/internal/task/dto" "github.com/kandev/kandev/internal/task/models" + taskrepo "github.com/kandev/kandev/internal/task/repository" + taskservice "github.com/kandev/kandev/internal/task/service" + usercontroller "github.com/kandev/kandev/internal/user/controller" + userservice "github.com/kandev/kandev/internal/user/service" + userstore "github.com/kandev/kandev/internal/user/store" + "github.com/kandev/kandev/internal/webapp" + workflowrepo "github.com/kandev/kandev/internal/workflow/repository" + workflowservice "github.com/kandev/kandev/internal/workflow/service" + "github.com/kandev/kandev/internal/worktree" ws "github.com/kandev/kandev/pkg/websocket" ) @@ -56,6 +75,707 @@ func TestAppendSessionStateMessage_IncludesTaskEnvironmentID(t *testing.T) { } } +func TestAppendAgentctlStatusMessage_IncludesWorkspacePathForReload(t *testing.T) { + log, err := logger.NewLogger(logger.LoggingConfig{ + Level: "error", + Format: "console", + OutputPath: "stdout", + }) + if err != nil { + t.Fatalf("NewLogger: %v", err) + } + mgr := lifecycle.NewManager(nil, nil, nil, nil, nil, nil, lifecycle.ExecutorFallbackDeny, t.TempDir(), log) + workspacePath := filepath.Join(t.TempDir(), "scratch") + if err := mgr.ExecutionStoreForTesting().Add(&lifecycle.AgentExecution{ + ID: "exec-1", + TaskID: "task-1", + SessionID: "sess-1", + TaskEnvironmentID: "env-1", + WorkspacePath: workspacePath, + }); err != nil { + t.Fatalf("add execution: %v", err) + } + + msgs := appendAgentctlStatusMessage(context.Background(), mgr, "sess-1", nil, log) + if len(msgs) != 1 { + t.Fatalf("expected 1 message, got %d", len(msgs)) + } + if msgs[0].Action != ws.ActionSessionAgentctlStarting { + t.Fatalf("expected action %q, got %q", ws.ActionSessionAgentctlStarting, msgs[0].Action) + } + + payload := decodePayload(t, msgs[0].Payload) + got, present := payload["worktree_path"] + if !present { + t.Fatalf("payload missing worktree_path — reload cannot hydrate repo-less scratch workspace path") + } + if got != workspacePath { + t.Fatalf("expected worktree_path=%q, got %v", workspacePath, got) + } +} + +func TestBootInitialStateIncludesFeatureFlags(t *testing.T) { + state := bootInitialState( + context.Background(), + nil, + routeParams{features: config.FeaturesConfig{Office: true}}, + webapp.ClassifyRoute("/"), + ) + + raw, err := json.Marshal(state) + if err != nil { + t.Fatalf("Marshal state: %v", err) + } + var decoded struct { + Features config.FeaturesConfig `json:"features"` + } + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatalf("Unmarshal state: %v", err) + } + if !decoded.Features.Office { + t.Fatal("features.office should hydrate true from the backend boot payload") + } +} + +func TestBootInitialStateHomeIncludesKanbanFirstPaintState(t *testing.T) { + taskSvc, workflowSvc := newBootStateTestServices(t) + ctx := context.Background() + + workspaces, err := taskSvc.ListWorkspaces(ctx) + if err != nil { + t.Fatalf("ListWorkspaces: %v", err) + } + if len(workspaces) == 0 { + t.Fatal("expected seeded default workspace") + } + workflows, err := taskSvc.ListWorkflows(ctx, workspaces[0].ID, true) + if err != nil { + t.Fatalf("ListWorkflows: %v", err) + } + if len(workflows) == 0 { + t.Fatal("expected seeded default workflow") + } + + state := bootInitialState( + ctx, + nil, + routeParams{ + taskSvc: taskSvc, + services: &Services{ + Workflow: workflowSvc, + }, + }, + webapp.ClassifyRoute("/"), + ) + + raw, err := json.Marshal(state) + if err != nil { + t.Fatalf("Marshal state: %v", err) + } + var decoded struct { + Workspaces struct { + Items []taskdto.WorkspaceDTO `json:"items"` + ActiveID string `json:"activeId"` + } `json:"workspaces"` + Workflows struct { + Items []struct { + ID string `json:"id"` + WorkspaceID string `json:"workspaceId"` + Name string `json:"name"` + } `json:"items"` + ActiveID string `json:"activeId"` + } `json:"workflows"` + KanbanMulti struct { + Snapshots map[string]struct { + WorkflowID string `json:"workflowId"` + WorkflowName string `json:"workflowName"` + Steps []struct { + ID string `json:"id"` + Title string `json:"title"` + } `json:"steps"` + Tasks []struct { + ID string `json:"id"` + WorkflowStepID string `json:"workflowStepId"` + } `json:"tasks"` + } `json:"snapshots"` + IsLoading bool `json:"isLoading"` + } `json:"kanbanMulti"` + Kanban struct { + WorkflowID string `json:"workflowId"` + Steps []struct { + ID string `json:"id"` + Title string `json:"title"` + } `json:"steps"` + Tasks []struct { + ID string `json:"id"` + WorkflowStepID string `json:"workflowStepId"` + } `json:"tasks"` + IsLoading bool `json:"isLoading"` + } `json:"kanban"` + } + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatalf("Unmarshal state: %v", err) + } + + if decoded.Workspaces.ActiveID != workspaces[0].ID { + t.Fatalf("active workspace = %q, want %q", decoded.Workspaces.ActiveID, workspaces[0].ID) + } + if len(decoded.Workflows.Items) == 0 || decoded.Workflows.ActiveID == "" { + t.Fatalf("expected workflows to hydrate first paint, got %+v", decoded.Workflows) + } + if _, ok := decoded.KanbanMulti.Snapshots[workflows[0].ID]; !ok { + t.Fatalf("expected snapshot for workflow %q in boot payload", workflows[0].ID) + } + if decoded.Kanban.WorkflowID != workflows[0].ID { + t.Fatalf("active kanban workflow = %q, want %q", decoded.Kanban.WorkflowID, workflows[0].ID) + } + if decoded.KanbanMulti.IsLoading || decoded.Kanban.IsLoading { + t.Fatal("boot payload should mark kanban data loaded") + } +} + +func TestBootInitialStateHomePreservesSavedAllWorkflowsFilter(t *testing.T) { + harness := newBootStateTestHarness(t) + ctx := context.Background() + + workspaces, err := harness.taskSvc.ListWorkspaces(ctx) + if err != nil { + t.Fatalf("ListWorkspaces: %v", err) + } + if len(workspaces) == 0 { + t.Fatal("expected seeded default workspace") + } + workflowB, err := harness.taskSvc.CreateWorkflow(ctx, &taskservice.CreateWorkflowRequest{ + WorkspaceID: workspaces[0].ID, + Name: "Workflow B", + }) + if err != nil { + t.Fatalf("CreateWorkflow: %v", err) + } + + allWorkflows := "" + repositoryIDs := []string{} + if _, err := harness.userSvc.UpdateUserSettings(ctx, &userservice.UpdateUserSettingsRequest{ + WorkspaceID: &workspaces[0].ID, + WorkflowFilterID: &allWorkflows, + RepositoryIDs: &repositoryIDs, + }); err != nil { + t.Fatalf("UpdateUserSettings: %v", err) + } + + state := bootInitialState( + ctx, + nil, + routeParams{ + taskSvc: harness.taskSvc, + userCtrl: harness.userCtrl, + services: &Services{ + Workflow: harness.workflowSvc, + }, + }, + webapp.ClassifyRoute("/"), + ) + + raw, err := json.Marshal(state) + if err != nil { + t.Fatalf("Marshal state: %v", err) + } + var decoded struct { + Workflows struct { + ActiveID *string `json:"activeId"` + } `json:"workflows"` + UserSettings struct { + WorkflowID *string `json:"workflowId"` + } `json:"userSettings"` + KanbanMulti struct { + Snapshots map[string]json.RawMessage `json:"snapshots"` + } `json:"kanbanMulti"` + } + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatalf("Unmarshal state: %v", err) + } + + if decoded.Workflows.ActiveID != nil { + t.Fatalf("active workflow = %q, want nil for All Workflows", *decoded.Workflows.ActiveID) + } + if decoded.UserSettings.WorkflowID != nil { + t.Fatalf("user settings workflow = %q, want nil for All Workflows", *decoded.UserSettings.WorkflowID) + } + if _, ok := decoded.KanbanMulti.Snapshots[workflowB.ID]; !ok { + t.Fatalf("expected boot snapshots to include second workflow %q", workflowB.ID) + } +} + +func TestBootRouteDataTaskDetailIncludesTaskPageData(t *testing.T) { + taskSvc, workflowSvc := newBootStateTestServices(t) + ctx := context.Background() + workspaces, err := taskSvc.ListWorkspaces(ctx) + if err != nil { + t.Fatalf("ListWorkspaces: %v", err) + } + workflows, err := taskSvc.ListWorkflows(ctx, workspaces[0].ID, true) + if err != nil { + t.Fatalf("ListWorkflows: %v", err) + } + steps, err := workflowSvc.ListStepsByWorkflow(ctx, workflows[0].ID) + if err != nil { + t.Fatalf("ListStepsByWorkflow: %v", err) + } + task, err := taskSvc.CreateTask(ctx, &taskservice.CreateTaskRequest{ + WorkspaceID: workspaces[0].ID, + WorkflowID: workflows[0].ID, + WorkflowStepID: steps[0].ID, + Title: "Boot detail task", + Description: "Should be present before React mounts", + }) + if err != nil { + t.Fatalf("CreateTask: %v", err) + } + + payload := webapp.NewBootPayload( + webapp.ClassifyRoute("/t/"+task.ID), + webapp.RuntimeConfig{APIPrefix: "/api/v1", WebSocketPath: "/ws"}, + bootInitialState(ctx, nil, routeParams{ + taskSvc: taskSvc, + services: &Services{ + Workflow: workflowSvc, + }, + }, webapp.ClassifyRoute("/t/"+task.ID)), + ) + payload.RouteData = bootRouteData(ctx, nil, routeParams{ + taskSvc: taskSvc, + services: &Services{ + Workflow: workflowSvc, + }, + }, webapp.ClassifyRoute("/t/"+task.ID)) + + raw, err := json.Marshal(payload) + if err != nil { + t.Fatalf("Marshal payload: %v", err) + } + var decoded struct { + RouteData struct { + TaskDetail struct { + Task struct { + ID string `json:"id"` + Title string `json:"title"` + } `json:"task"` + SessionID *string `json:"sessionId"` + InitialState map[string]interface{} `json:"initialState"` + } `json:"taskDetail"` + } `json:"routeData"` + } + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatalf("Unmarshal payload: %v", err) + } + + if decoded.RouteData.TaskDetail.Task.ID != task.ID { + t.Fatalf("route task id = %q, want %q", decoded.RouteData.TaskDetail.Task.ID, task.ID) + } + if decoded.RouteData.TaskDetail.Task.Title != "Boot detail task" { + t.Fatalf("route task title = %q", decoded.RouteData.TaskDetail.Task.Title) + } + if decoded.RouteData.TaskDetail.SessionID != nil { + t.Fatalf("new task should not have a boot session id, got %q", *decoded.RouteData.TaskDetail.SessionID) + } + if _, ok := decoded.RouteData.TaskDetail.InitialState["kanban"]; !ok { + t.Fatal("task detail route data should include initialState.kanban") + } +} + +func TestBootRouteDataTasksIncludesFirstPageRows(t *testing.T) { + taskSvc, workflowSvc := newBootStateTestServices(t) + ctx := context.Background() + workspaces, err := taskSvc.ListWorkspaces(ctx) + if err != nil { + t.Fatalf("ListWorkspaces: %v", err) + } + workflows, err := taskSvc.ListWorkflows(ctx, workspaces[0].ID, true) + if err != nil { + t.Fatalf("ListWorkflows: %v", err) + } + steps, err := workflowSvc.ListStepsByWorkflow(ctx, workflows[0].ID) + if err != nil { + t.Fatalf("ListStepsByWorkflow: %v", err) + } + created, err := taskSvc.CreateTask(ctx, &taskservice.CreateTaskRequest{ + WorkspaceID: workspaces[0].ID, + WorkflowID: workflows[0].ID, + WorkflowStepID: steps[0].ID, + Title: "Tasks table row", + Description: "Visible on first paint", + }) + if err != nil { + t.Fatalf("CreateTask: %v", err) + } + + route := webapp.ClassifyRoute("/tasks") + payload := bootPayload(ctx, nil, routeParams{ + taskSvc: taskSvc, + services: &Services{ + Workflow: workflowSvc, + }, + }, route) + + raw, err := json.Marshal(payload) + if err != nil { + t.Fatalf("Marshal payload: %v", err) + } + var decoded struct { + InitialState struct { + Workspaces struct { + ActiveID string `json:"activeId"` + } `json:"workspaces"` + Workflows struct { + ActiveID *string `json:"activeId"` + Items []struct { + ID string `json:"id"` + } `json:"items"` + } `json:"workflows"` + } `json:"initialState"` + RouteData struct { + TasksPage struct { + ActiveWorkspaceID string `json:"activeWorkspaceId"` + Tasks []struct { + ID string `json:"id"` + Title string `json:"title"` + } `json:"tasks"` + Total int `json:"total"` + } `json:"tasksPage"` + } `json:"routeData"` + } + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatalf("Unmarshal payload: %v", err) + } + + if decoded.InitialState.Workspaces.ActiveID != workspaces[0].ID { + t.Fatalf("active workspace = %q, want %q", decoded.InitialState.Workspaces.ActiveID, workspaces[0].ID) + } + if len(decoded.InitialState.Workflows.Items) == 0 { + t.Fatal("tasks route should hydrate workflow items") + } + if decoded.RouteData.TasksPage.ActiveWorkspaceID != workspaces[0].ID { + t.Fatalf("route active workspace = %q, want %q", decoded.RouteData.TasksPage.ActiveWorkspaceID, workspaces[0].ID) + } + if decoded.RouteData.TasksPage.Total != 1 { + t.Fatalf("tasks total = %d, want 1", decoded.RouteData.TasksPage.Total) + } + if len(decoded.RouteData.TasksPage.Tasks) != 1 || decoded.RouteData.TasksPage.Tasks[0].ID != created.ID { + t.Fatalf("route tasks = %+v, want task %q", decoded.RouteData.TasksPage.Tasks, created.ID) + } +} + +func TestBootRouteDataTasksUsesActiveWorkspaceCookie(t *testing.T) { + taskSvc, workflowSvc := newBootStateTestServices(t) + ctx := context.Background() + if _, err := taskSvc.CreateWorkspace(ctx, &taskservice.CreateWorkspaceRequest{ + Name: "Cookie Workspace", + }); err != nil { + t.Fatalf("CreateWorkspace: %v", err) + } + workspace := nonFallbackWorkspace(t, ctx, taskSvc) + if _, err := taskSvc.CreateWorkflow(ctx, &taskservice.CreateWorkflowRequest{ + WorkspaceID: workspace.ID, + Name: "Cookie Workflow", + }); err != nil { + t.Fatalf("CreateWorkflow: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/v1/app-state?path=%2Ftasks", nil) + req.AddCookie(&http.Cookie{Name: "kandev-active-workspace", Value: workspace.ID}) + payload := bootPayload(ctx, req, routeParams{ + taskSvc: taskSvc, + services: &Services{ + Workflow: workflowSvc, + }, + }, webapp.ClassifyRoute("/tasks")) + + raw, err := json.Marshal(payload) + if err != nil { + t.Fatalf("Marshal payload: %v", err) + } + var decoded struct { + InitialState struct { + Workspaces struct { + ActiveID string `json:"activeId"` + } `json:"workspaces"` + } `json:"initialState"` + RouteData struct { + TasksPage struct { + ActiveWorkspaceID string `json:"activeWorkspaceId"` + } `json:"tasksPage"` + } `json:"routeData"` + } + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatalf("Unmarshal payload: %v", err) + } + if decoded.InitialState.Workspaces.ActiveID != workspace.ID { + t.Fatalf("initial active workspace = %q, want %q", decoded.InitialState.Workspaces.ActiveID, workspace.ID) + } + if decoded.RouteData.TasksPage.ActiveWorkspaceID != workspace.ID { + t.Fatalf("route active workspace = %q, want %q", decoded.RouteData.TasksPage.ActiveWorkspaceID, workspace.ID) + } +} + +func TestBootRouteDataIntegrationRouteIncludesOnlyLocalContext(t *testing.T) { + taskSvc, workflowSvc := newBootStateTestServices(t) + ctx := context.Background() + workspaces, err := taskSvc.ListWorkspaces(ctx) + if err != nil { + t.Fatalf("ListWorkspaces: %v", err) + } + + payload := bootPayload(ctx, nil, routeParams{ + taskSvc: taskSvc, + services: &Services{ + Workflow: workflowSvc, + }, + }, webapp.ClassifyRoute("/github")) + + raw, err := json.Marshal(payload) + if err != nil { + t.Fatalf("Marshal payload: %v", err) + } + var decoded struct { + RouteData map[string]json.RawMessage `json:"routeData"` + } + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatalf("Unmarshal payload: %v", err) + } + if _, ok := decoded.RouteData["github"]; ok { + t.Fatal("integration boot data must not include provider GitHub results") + } + var contextData struct { + ActiveWorkspaceID string `json:"activeWorkspaceId"` + Workflows []struct { + ID string `json:"id"` + } `json:"workflows"` + Steps []struct { + ID string `json:"id"` + } `json:"steps"` + Repositories []struct { + ID string `json:"id"` + } `json:"repositories"` + } + if err := json.Unmarshal(decoded.RouteData["routeContext"], &contextData); err != nil { + t.Fatalf("Unmarshal routeContext: %v", err) + } + if contextData.ActiveWorkspaceID != workspaces[0].ID { + t.Fatalf("active workspace = %q, want %q", contextData.ActiveWorkspaceID, workspaces[0].ID) + } + if len(contextData.Workflows) == 0 || len(contextData.Steps) == 0 { + t.Fatalf("expected local workflow context, got %+v", contextData) + } +} + +func TestBootRouteDataIntegrationRouteUsesActiveWorkspaceCookie(t *testing.T) { + taskSvc, workflowSvc := newBootStateTestServices(t) + ctx := context.Background() + if _, err := taskSvc.CreateWorkspace(ctx, &taskservice.CreateWorkspaceRequest{ + Name: "Cookie Workspace", + }); err != nil { + t.Fatalf("CreateWorkspace: %v", err) + } + workspace := nonFallbackWorkspace(t, ctx, taskSvc) + if _, err := taskSvc.CreateWorkflow(ctx, &taskservice.CreateWorkflowRequest{ + WorkspaceID: workspace.ID, + Name: "Cookie Workflow", + }); err != nil { + t.Fatalf("CreateWorkflow: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/v1/app-state?path=%2Fgithub", nil) + req.AddCookie(&http.Cookie{Name: "kandev-active-workspace", Value: workspace.ID}) + payload := bootPayload(ctx, req, routeParams{ + taskSvc: taskSvc, + services: &Services{ + Workflow: workflowSvc, + }, + }, webapp.ClassifyRoute("/github")) + + raw, err := json.Marshal(payload) + if err != nil { + t.Fatalf("Marshal payload: %v", err) + } + var decoded struct { + RouteData struct { + RouteContext struct { + ActiveWorkspaceID string `json:"activeWorkspaceId"` + } `json:"routeContext"` + } `json:"routeData"` + } + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatalf("Unmarshal payload: %v", err) + } + if decoded.RouteData.RouteContext.ActiveWorkspaceID != workspace.ID { + t.Fatalf("route active workspace = %q, want %q", decoded.RouteData.RouteContext.ActiveWorkspaceID, workspace.ID) + } +} + +func nonFallbackWorkspace(t *testing.T, ctx context.Context, taskSvc *taskservice.Service) *models.Workspace { + t.Helper() + workspaces, err := taskSvc.ListWorkspaces(ctx) + if err != nil { + t.Fatalf("ListWorkspaces: %v", err) + } + if len(workspaces) < 2 { + t.Fatalf("expected at least two workspaces, got %d", len(workspaces)) + } + fallbackID := workspaces[0].ID + for _, workspace := range workspaces { + if workspace != nil && workspace.ID != fallbackID { + return workspace + } + } + t.Fatal("expected a workspace different from the default fallback") + return nil +} + +func TestBootInitialStateSettingsWithNilServicesDoesNotPanic(t *testing.T) { + state := bootInitialState( + context.Background(), + nil, + routeParams{features: config.FeaturesConfig{Office: true}}, + webapp.ClassifyRoute("/settings/prompts"), + ) + + if _, ok := state["features"]; !ok { + t.Fatal("features should always be present even when optional services are unavailable") + } + if _, ok := state["prompts"]; ok { + t.Fatal("prompts should not be marked loaded when the prompts controller is unavailable") + } +} + +func TestQueryValueReadsRouteQueryFromAppStatePath(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/app-state?path=%2F%3FworkspaceId%3Dws-1%26workflowId%3Dwf-1", nil) + + if got := queryValue(req, "workspaceId"); got != "ws-1" { + t.Fatalf("workspaceId = %q, want ws-1", got) + } + if got := queryValue(req, "workflowId"); got != "wf-1" { + t.Fatalf("workflowId = %q, want wf-1", got) + } +} + +type bootStateTestHarness struct { + taskSvc *taskservice.Service + workflowSvc *workflowservice.Service + userCtrl *usercontroller.Controller + userSvc *userservice.Service +} + +func newBootStateTestServices(t *testing.T) (*taskservice.Service, *workflowservice.Service) { + harness := newBootStateTestHarness(t) + return harness.taskSvc, harness.workflowSvc +} + +func newBootStateTestHarness(t *testing.T) bootStateTestHarness { + t.Helper() + dbConn, err := db.OpenSQLite(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatalf("OpenSQLite: %v", err) + } + sqlxDB := sqlx.NewDb(dbConn, "sqlite3") + taskRepo, cleanup, err := taskrepo.Provide(sqlxDB, sqlxDB, nil) + if err != nil { + t.Fatalf("task repository: %v", err) + } + if _, err := worktree.NewSQLiteStore(sqlxDB, sqlxDB); err != nil { + t.Fatalf("worktree store: %v", err) + } + workflowRepo, err := workflowrepo.NewWithDB(sqlxDB, sqlxDB, nil) + if err != nil { + t.Fatalf("workflow repository: %v", err) + } + userRepo, userCleanup, err := userstore.Provide(sqlxDB, sqlxDB) + if err != nil { + t.Fatalf("user repository: %v", err) + } + t.Cleanup(func() { + if err := sqlxDB.Close(); err != nil { + t.Errorf("close db: %v", err) + } + if err := cleanup(); err != nil { + t.Errorf("close task repo: %v", err) + } + if err := userCleanup(); err != nil { + t.Errorf("close user repo: %v", err) + } + }) + + log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json", OutputPath: "stdout"}) + eventBus := bus.NewMemoryEventBus(log) + userSvc := userservice.NewService(userRepo, eventBus, log) + workflowSvc := workflowservice.NewService(workflowRepo, log) + taskSvc := taskservice.NewService( + taskservice.Repos{ + Workspaces: taskRepo, + Tasks: taskRepo, + TaskRepos: taskRepo, + Workflows: taskRepo, + Messages: taskRepo, + Turns: taskRepo, + Sessions: taskRepo, + GitSnapshots: taskRepo, + RepoEntities: taskRepo, + Executors: taskRepo, + Environments: taskRepo, + TaskEnvironments: taskRepo, + Reviews: taskRepo, + }, + eventBus, + log, + taskservice.RepositoryDiscoveryConfig{}, + ) + taskSvc.SetWorkflowStepCreator(workflowSvc) + taskSvc.SetWorkflowStepGetter(&workflowStepGetterAdapter{svc: workflowSvc}) + taskSvc.SetStartStepResolver(&startStepResolverAdapter{svc: workflowSvc}) + workflowSvc.SetWorkflowProvider(&workflowProviderAdapter{svc: taskSvc}) + return bootStateTestHarness{ + taskSvc: taskSvc, + workflowSvc: workflowSvc, + userCtrl: usercontroller.NewController(userSvc), + userSvc: userSvc, + } +} + +func TestNewWebAppHandlerUsesEmbeddedAssetsWithoutDistDir(t *testing.T) { + t.Setenv("KANDEV_WEB_DIST_DIR", "") + t.Chdir(t.TempDir()) + + handler, source, ok := newWebAppHandler(routeParams{}) + if !ok { + t.Fatal("expected embedded web app handler when no filesystem dist dir exists") + } + if source != "embedded" { + t.Fatalf("source = %q, want embedded", source) + } + + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + if !strings.Contains(rec.Body.String(), "window.__KANDEV_BOOT_PAYLOAD__") { + t.Fatal("embedded shell should receive boot payload injection") + } +} + +func TestResolveActiveOfficeWorkspaceIDPrefersCookie(t *testing.T) { + workspaces := []taskdto.WorkspaceDTO{ + {ID: "ws-a", OfficeWorkflowID: "office-a"}, + {ID: "ws-b", OfficeWorkflowID: "office-b"}, + } + + got := resolveActiveOfficeWorkspaceID(workspaces, "ws-b") + if got != "ws-b" { + t.Fatalf("expected cookie workspace to win, got %q", got) + } +} + func TestAppendSessionStateMessage_IncludesUpdatedAt(t *testing.T) { updatedAt := time.Date(2026, 1, 2, 12, 0, 0, 0, time.UTC) session := &models.TaskSession{ diff --git a/apps/backend/cmd/kandev/middleware.go b/apps/backend/cmd/kandev/middleware.go index d2e81c1c3e..a6151bf992 100644 --- a/apps/backend/cmd/kandev/middleware.go +++ b/apps/backend/cmd/kandev/middleware.go @@ -1,7 +1,10 @@ package main import ( + "net" "net/http" + "net/url" + "strings" "github.com/gin-gonic/gin" ) @@ -9,10 +12,25 @@ import ( // corsMiddleware returns a CORS middleware for HTTP and WebSocket connections. func corsMiddleware() gin.HandlerFunc { return func(c *gin.Context) { - c.Header("Access-Control-Allow-Origin", "*") + if origin := c.Request.Header.Get("Origin"); origin != "" { + if !isAllowedCORSOrigin(origin, c.Request.Host) { + if c.Request.Method == http.MethodOptions { + c.AbortWithStatus(http.StatusForbidden) + return + } + + c.Next() + return + } + + c.Header("Access-Control-Allow-Origin", origin) + c.Header("Vary", "Origin") + c.Header("Access-Control-Allow-Credentials", "true") + } else { + c.Header("Access-Control-Allow-Origin", "*") + } c.Header("Access-Control-Allow-Methods", "GET, POST, PATCH, PUT, DELETE, OPTIONS") c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Authorization, Upgrade, Connection, Sec-WebSocket-Key, Sec-WebSocket-Version, Sec-WebSocket-Protocol") - c.Header("Access-Control-Allow-Credentials", "true") if c.Request.Method == "OPTIONS" { c.AbortWithStatus(http.StatusNoContent) @@ -22,3 +40,38 @@ func corsMiddleware() gin.HandlerFunc { c.Next() } } + +func isAllowedCORSOrigin(origin string, requestHost string) bool { + parsed, err := url.Parse(origin) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return false + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return false + } + + originHost := normalizeCORSHost(parsed.Hostname()) + host := normalizeCORSHost(requestHost) + if originHost == "" || host == "" { + return false + } + + return originHost == host || (isLoopbackCORSHost(originHost) && isLoopbackCORSHost(host)) +} + +func normalizeCORSHost(host string) string { + if parsedHost, _, err := net.SplitHostPort(host); err == nil { + host = parsedHost + } + + return strings.ToLower(strings.Trim(host, "[]")) +} + +func isLoopbackCORSHost(host string) bool { + if host == "localhost" { + return true + } + + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} diff --git a/apps/backend/cmd/kandev/middleware_test.go b/apps/backend/cmd/kandev/middleware_test.go new file mode 100644 index 0000000000..de40988bf6 --- /dev/null +++ b/apps/backend/cmd/kandev/middleware_test.go @@ -0,0 +1,75 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestCORSMiddlewareEchoesOriginForCredentialedRequests(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(corsMiddleware()) + router.GET("/ping", func(c *gin.Context) { + c.String(http.StatusOK, "pong") + }) + + req := httptest.NewRequest(http.MethodGet, "/ping", nil) + req.Host = "localhost:38429" + req.Header.Set("Origin", "http://localhost:37429") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "http://localhost:37429" { + t.Fatalf("Access-Control-Allow-Origin = %q, want request origin", got) + } + if got := rec.Header().Get("Access-Control-Allow-Credentials"); got != "true" { + t.Fatalf("Access-Control-Allow-Credentials = %q, want true", got) + } +} + +func TestCORSMiddlewareAllowsLoopbackAliasOriginForCredentialedRequests(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(corsMiddleware()) + router.GET("/ping", func(c *gin.Context) { + c.String(http.StatusOK, "pong") + }) + + req := httptest.NewRequest(http.MethodGet, "/ping", nil) + req.Host = "127.0.0.1:38429" + req.Header.Set("Origin", "http://localhost:37429") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "http://localhost:37429" { + t.Fatalf("Access-Control-Allow-Origin = %q, want loopback origin", got) + } + if got := rec.Header().Get("Access-Control-Allow-Credentials"); got != "true" { + t.Fatalf("Access-Control-Allow-Credentials = %q, want true", got) + } +} + +func TestCORSMiddlewareRejectsCredentialedRequestsFromUnrelatedOrigins(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(corsMiddleware()) + router.GET("/ping", func(c *gin.Context) { + c.String(http.StatusOK, "pong") + }) + + req := httptest.NewRequest(http.MethodGet, "/ping", nil) + req.Host = "localhost:38429" + req.Header.Set("Origin", "https://example.invalid") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" { + t.Fatalf("Access-Control-Allow-Origin = %q, want no credentialed CORS", got) + } + if got := rec.Header().Get("Access-Control-Allow-Credentials"); got != "" { + t.Fatalf("Access-Control-Allow-Credentials = %q, want no credentialed CORS", got) + } +} diff --git a/apps/backend/cmd/preview/build.go b/apps/backend/cmd/preview/build.go index 93cf7aea6a..8de100522d 100644 --- a/apps/backend/cmd/preview/build.go +++ b/apps/backend/cmd/preview/build.go @@ -114,7 +114,7 @@ func copyFile(src, dst string, mode fs.FileMode) error { return nil } -// buildWeb runs the Next.js production build. When skipInstall is true the +// buildWeb runs the Vite production build. When skipInstall is true the // pnpm install step is skipped (CI already runs it before invoking the CLI). func buildWeb(ctx context.Context, skipInstall bool) error { steps := []struct { @@ -157,9 +157,7 @@ func buildCLI(ctx context.Context) error { // packageBundle creates a tar.gz matching the Docker container layout: // // app/apps/backend/bin/{kandev,agentctl,mock-agent} -// app/apps/web/.next/standalone/ (Next.js server + node_modules) -// app/apps/web/.next/standalone/web/.next/static/ (static assets inside standalone) -// app/apps/web/.next/standalone/web/public/ (public assets inside standalone) +// app/apps/web/dist/ (Vite SPA assets) // usr/local/lib/kandev-cli/ (CLI launcher bundle) func packageBundle(binDir, tarPath string) error { f, err := os.Create(tarPath) @@ -184,24 +182,11 @@ func packageBundle(binDir, tarPath string) error { } } - // Add Next.js standalone output. - standaloneDir := filepath.Join(appsDir, "web", ".next", "standalone") - if err := addDirToTar(tw, standaloneDir, filepath.Join("app", "apps", "web", ".next", "standalone")); err != nil { - return fmt.Errorf("add standalone: %w", err) - } - - // Add static assets into the standalone web dir (mirrors the Dockerfile COPY). - staticDir := filepath.Join(appsDir, "web", ".next", "static") - staticDst := filepath.Join("app", "apps", "web", ".next", "standalone", "web", ".next", "static") - if err := addDirToTar(tw, staticDir, staticDst); err != nil { - return fmt.Errorf("add static: %w", err) - } - - // Add public directory into the standalone web dir. - publicDir := filepath.Join(appsDir, "web", "public") - publicDst := filepath.Join("app", "apps", "web", ".next", "standalone", "web", "public") - if err := addDirToTar(tw, publicDir, publicDst); err != nil { - return fmt.Errorf("add public: %w", err) + // Add Vite SPA assets. `kandev start` resolves these from /app/apps/web/dist + // and passes KANDEV_WEB_DIST_DIR to the backend. + webDistDir := filepath.Join(appsDir, "web", "dist") + if err := addDirToTar(tw, webDistDir, filepath.Join("app", "apps", "web", "dist")); err != nil { + return fmt.Errorf("add web dist: %w", err) } cliDir := filepath.Join(repoRootDir, "dist", "kandev", "cli") @@ -249,7 +234,7 @@ func addDirToTar(tw *tar.Writer, srcDir, dstPrefix string) error { return err } - // Preserve symlinks as-is (Next.js standalone relies on them for node_modules). + // Preserve symlinks as-is. if info.Mode()&fs.ModeSymlink != 0 { target, err := os.Readlink(path) if err != nil { diff --git a/apps/backend/cmd/preview/sprite_ops.go b/apps/backend/cmd/preview/sprite_ops.go index 73df392a11..18d80545e3 100644 --- a/apps/backend/cmd/preview/sprite_ops.go +++ b/apps/backend/cmd/preview/sprite_ops.go @@ -122,9 +122,8 @@ mkdir -p /data # Kill any agentctl orphans from previous runs. pkill -f agentctl || true -# Kill any stale web (node) process from older preview deployments, where the -# web server was nohup'd outside the service process group. -pkill -f '/app/apps/web/.next/standalone/web/server.js' || true +# Kill any stale web (node) process from older preview deployments. +pkill -f '/app/apps/web/.*/server.js' || true sleep 1 cd /app @@ -280,7 +279,7 @@ func fetchSpriteLogs(ctx context.Context, sprite *sprites.Sprite) string { logCtx, cancel := context.WithTimeout(ctx, 15*time.Second) defer cancel() - script := `echo "=== /var/log/kandev.log ==="; tail -50 /var/log/kandev.log 2>/dev/null || echo "(empty)"; echo "=== /var/log/kandev-web.log ==="; tail -20 /var/log/kandev-web.log 2>/dev/null || echo "(empty)"` + script := `echo "=== /var/log/kandev.log ==="; tail -50 /var/log/kandev.log 2>/dev/null || echo "(empty)"` out, err := sprite.CommandContext(logCtx, "bash", "-c", script).CombinedOutput() if err != nil { return fmt.Sprintf("[log fetch error: %v]\n%s", err, string(out)) diff --git a/apps/backend/cmd/preview/sprite_ops_test.go b/apps/backend/cmd/preview/sprite_ops_test.go index 70232b534b..998767288f 100644 --- a/apps/backend/cmd/preview/sprite_ops_test.go +++ b/apps/backend/cmd/preview/sprite_ops_test.go @@ -29,6 +29,9 @@ func TestBuildExtractScript(t *testing.T) { if strings.Contains(script, "nohup node") { t.Errorf("script should not start web outside the CLI supervisor") } + if strings.Contains(script, ".next") { + t.Errorf("script should not refer to Next.js build output") + } if strings.Contains(script, "/app/apps/backend/bin/kandev >") { t.Errorf("script should not launch the backend binary directly") } diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go b/apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go index 29bcdc0951..5f3848839d 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go @@ -963,6 +963,29 @@ func (m *Manager) IsAgentRunningForSession(ctx context.Context, sessionID string return status.IsAgentRunning() } +// IsAgentReadyForPrompt reports whether the session can accept an ACP prompt +// immediately. A process can be "running" while its update stream is still +// reconnecting after resume; PromptAgent needs the stream-backed request path. +func (m *Manager) IsAgentReadyForPrompt(ctx context.Context, sessionID string) bool { + execution, exists := m.GetExecutionBySessionID(sessionID) + if !exists { + return false + } + + if execution.PassthroughProcessID != "" || execution.IsPassthrough { + return m.IsAgentRunningForSession(ctx, sessionID) + } + + if execution.Status != v1.AgentStatusReady || execution.agentctl == nil { + return false + } + if !execution.sessionInitialized || execution.ACPSessionID == "" { + return false + } + + return execution.agentctl.HasAgentStream() +} + // UpdateStatus updates the status of an execution func (m *Manager) UpdateStatus(executionID string, status v1.AgentStatus) error { if err := m.executionStore.WithLock(executionID, func(execution *AgentExecution) { diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_interaction_test.go b/apps/backend/internal/agent/runtime/lifecycle/manager_interaction_test.go index 20339fcb73..3151533d99 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_interaction_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_interaction_test.go @@ -734,6 +734,72 @@ func TestIsAgentRunningForSession(t *testing.T) { }) } +func TestIsAgentReadyForPrompt(t *testing.T) { + t.Run("ACP execution requires ready status, initialized session, and update stream", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + mock := newMockAgentServer(t) + t.Cleanup(mock.Close) + + client := createTestClient(t, mock.server.URL) + t.Cleanup(client.Close) + + store := NewExecutionStore() + execution := &AgentExecution{ + ID: "exec-acp-ready", + SessionID: "session-acp-ready", + ACPSessionID: "acp-session-1", + Status: v1.AgentStatusReady, + agentctl: client, + } + err := store.Add(execution) + require.NoError(t, err) + + mgr := &Manager{ + executionStore: store, + logger: newTestLogger().WithFields(), + } + require.False(t, mgr.IsAgentReadyForPrompt(ctx, "session-acp-ready")) + + err = client.StreamUpdates(ctx, func(agentctl.AgentEvent) {}, nil, nil) + require.NoError(t, err) + + select { + case <-mock.wsConnected: + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for updates stream") + } + + require.False(t, mgr.IsAgentReadyForPrompt(ctx, "session-acp-ready")) + + err = store.WithLock("exec-acp-ready", func(exec *AgentExecution) { + exec.sessionInitialized = true + }) + require.NoError(t, err) + require.True(t, mgr.IsAgentReadyForPrompt(ctx, "session-acp-ready")) + + err = store.WithLock("exec-acp-ready", func(exec *AgentExecution) { + exec.ACPSessionID = "" + }) + require.NoError(t, err) + require.False(t, mgr.IsAgentReadyForPrompt(ctx, "session-acp-ready")) + + err = store.WithLock("exec-acp-ready", func(exec *AgentExecution) { + exec.ACPSessionID = "acp-session-1" + }) + require.NoError(t, err) + + store.UpdateStatus("exec-acp-ready", v1.AgentStatusRunning) + require.False(t, mgr.IsAgentReadyForPrompt(ctx, "session-acp-ready")) + }) + + t.Run("missing execution returns false", func(t *testing.T) { + mgr := &Manager{executionStore: NewExecutionStore(), logger: newTestLogger().WithFields()} + require.False(t, mgr.IsAgentReadyForPrompt(context.Background(), "missing")) + }) +} + // TestEffectiveSessionMode covers the fresh-launch mode propagation for issue // #1183: a persisted session_mode (e.g. from a set_session_mode workflow step) // must override the profile default at ACP session init, while a missing diff --git a/apps/backend/internal/agent/runtime/lifecycle/session.go b/apps/backend/internal/agent/runtime/lifecycle/session.go index caf59e9632..b64d8630c1 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/session.go +++ b/apps/backend/internal/agent/runtime/lifecycle/session.go @@ -686,18 +686,46 @@ func (sm *SessionManager) retryPromptAfterReconnect( prompt string, attachments []v1.MessageAttachment, ) error { - ready := make(chan struct{}) - sm.streamManager.connectUpdatesStreamAsync(execution, ready) + reconnectCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() - select { - case <-ready: - case <-ctx.Done(): - return ctx.Err() - case <-time.After(5 * time.Second): - return fmt.Errorf("timed out waiting for updates stream reconnect") + var lastErr error + for { + if !execution.agentctl.HasAgentStream() { + ready := make(chan struct{}) + sm.streamManager.connectUpdatesStreamAsync(execution, ready) + + select { + case <-ready: + case <-reconnectCtx.Done(): + if lastErr != nil { + return fmt.Errorf("timed out waiting for updates stream reconnect: %w", lastErr) + } + return reconnectCtx.Err() + } + } + + if execution.agentctl.HasAgentStream() { + if err := execution.agentctl.Prompt(reconnectCtx, prompt, attachments); err == nil { + return nil + } else if !isAgentStreamNotConnectedErr(err) { + return err + } else { + lastErr = err + } + } else { + lastErr = fmt.Errorf("agent stream not connected") + } + + select { + case <-reconnectCtx.Done(): + if lastErr != nil { + return fmt.Errorf("timed out waiting for updates stream reconnect: %w", lastErr) + } + return reconnectCtx.Err() + case <-time.After(100 * time.Millisecond): + } } - - return execution.agentctl.Prompt(ctx, prompt, attachments) } // jsonRPCMethodNotFound is the JSON-RPC 2.0 error code for "Method not found". diff --git a/apps/backend/internal/agent/runtime/lifecycle/session_test.go b/apps/backend/internal/agent/runtime/lifecycle/session_test.go index e0e97482eb..9e4cf59e8d 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/session_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/session_test.go @@ -43,12 +43,13 @@ func newTestStopCh(t *testing.T) chan struct{} { // mockAgentServer creates a test WebSocket server simulating agentctl. // It responds to agent stream requests and tracks which actions were called and in what order. type mockAgentServer struct { - server *httptest.Server - mu sync.Mutex - actionLog []string // ordered log of actions received - upgrader websocket.Upgrader - handler func(msg ws.Message) *ws.Message - wsConnected chan struct{} // closed when WS stream connects + server *httptest.Server + mu sync.Mutex + actionLog []string // ordered log of actions received + rejectStreamAttempts int + upgrader websocket.Upgrader + handler func(msg ws.Message) *ws.Message + wsConnected chan struct{} // closed when WS stream connects } func newMockAgentServer(t *testing.T) *mockAgentServer { @@ -64,6 +65,15 @@ func newMockAgentServer(t *testing.T) *mockAgentServer { // Agent stream WebSocket endpoint mux.HandleFunc("/api/v1/agent/stream", func(w http.ResponseWriter, r *http.Request) { + m.mu.Lock() + if m.rejectStreamAttempts > 0 { + m.rejectStreamAttempts-- + m.mu.Unlock() + http.Error(w, "stream temporarily unavailable", http.StatusServiceUnavailable) + return + } + m.mu.Unlock() + conn, err := m.upgrader.Upgrade(w, r, nil) if err != nil { return @@ -140,6 +150,12 @@ func (m *mockAgentServer) buildResponse(msg ws.Message) *ws.Message { return m.defaultHandler(msg) } +func (m *mockAgentServer) rejectNextStreamAttempts(count int) { + m.mu.Lock() + defer m.mu.Unlock() + m.rejectStreamAttempts = count +} + func (m *mockAgentServer) defaultHandler(msg ws.Message) *ws.Message { switch msg.Action { case "agent.initialize": @@ -761,6 +777,50 @@ func waitForWSConnected(t *testing.T, mock *mockAgentServer) { } } +func TestSendPrompt_RetriesUntilUpdateStreamReconnects(t *testing.T) { + mock := newMockAgentServer(t) + defer mock.Close() + mock.rejectNextStreamAttempts(1) + + log := newSessionTestLogger() + stopCh := newTestStopCh(t) + sm := NewSessionManager(log, stopCh) + + streamMgr := NewStreamManager(log, StreamCallbacks{ + OnAgentEvent: func(execution *AgentExecution, event agentctl.AgentEvent) {}, + }, nil, stopCh) + cleanupStreamManager(t, stopCh, streamMgr) + sm.SetDependencies(nil, streamMgr, nil, nil) + + client := createTestClient(t, mock.server.URL) + defer client.Close() + + execution := &AgentExecution{ + ID: "test-exec", + TaskID: "test-task", + SessionID: "test-session", + WorkspacePath: "/workspace", + agentctl: client, + promptDoneCh: make(chan PromptCompletionSignal, 1), + } + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + result, err := sm.SendPrompt(ctx, execution, "hello after resume", false, nil, true) + if err != nil { + t.Fatalf("SendPrompt should retry after a transient stream reconnect failure: %v", err) + } + if result == nil || result.StopReason != PromptStopReasonDispatched { + t.Fatalf("expected StopReason=%q, got %+v", PromptStopReasonDispatched, result) + } + + actions := mock.getActionLog() + if len(actions) != 1 || actions[0] != "agent.prompt" { + t.Fatalf("expected one prompt after reconnect, got actions: %v", actions) + } +} + // TestSendPrompt_DispatchOnlyReturnsWithoutWaiting verifies that dispatch-only // mode returns immediately after agentctl.Prompt succeeds, without blocking on // the agent's complete event. This is what message_task_kandev relies on so the diff --git a/apps/backend/internal/common/ports/ports.go b/apps/backend/internal/common/ports/ports.go index de0d6ae13e..5f5fbca564 100644 --- a/apps/backend/internal/common/ports/ports.go +++ b/apps/backend/internal/common/ports/ports.go @@ -11,7 +11,7 @@ const ( // Backend is the default HTTP port for the kandev Go backend. Backend = 38429 - // Web is the default HTTP port for the Next.js web server. + // Web is the default HTTP port for the optional Vite dev server. Web = 37429 // AgentCtl is the default control port for the agentctl sidecar. diff --git a/apps/backend/internal/github/mock_controller.go b/apps/backend/internal/github/mock_controller.go index 8064bfb36d..ab41dc885a 100644 --- a/apps/backend/internal/github/mock_controller.go +++ b/apps/backend/internal/github/mock_controller.go @@ -339,21 +339,22 @@ func (c *MockController) ensureMockPRForRequest(ctx context.Context, req *associ return } c.mock.AddPR(&PR{ - Number: req.PRNumber, - Title: req.PRTitle, - URL: req.PRURL, - HTMLURL: req.PRURL, - State: req.State, - HeadBranch: req.HeadBranch, - HeadSHA: mockHeadSHA(req.Owner, req.Repo, req.PRNumber), - BaseBranch: req.BaseBranch, - AuthorLogin: req.AuthorLogin, - RepoOwner: req.Owner, - RepoName: req.Repo, - Additions: req.Additions, - Deletions: req.Deletions, - CreatedAt: now, - UpdatedAt: now, + Number: req.PRNumber, + Title: req.PRTitle, + URL: req.PRURL, + HTMLURL: req.PRURL, + State: req.State, + HeadBranch: req.HeadBranch, + HeadSHA: mockHeadSHA(req.Owner, req.Repo, req.PRNumber), + BaseBranch: req.BaseBranch, + AuthorLogin: req.AuthorLogin, + MergeableState: req.MergeableState, + RepoOwner: req.Owner, + RepoName: req.Repo, + Additions: req.Additions, + Deletions: req.Deletions, + CreatedAt: now, + UpdatedAt: now, }) } diff --git a/apps/backend/internal/github/mock_controller_test.go b/apps/backend/internal/github/mock_controller_test.go new file mode 100644 index 0000000000..c36aafd655 --- /dev/null +++ b/apps/backend/internal/github/mock_controller_test.go @@ -0,0 +1,37 @@ +package github + +import ( + "context" + "testing" + "time" +) + +func TestEnsureMockPRForRequestCopiesMergeableState(t *testing.T) { + mock := NewMockClient() + controller := &MockController{mock: mock} + req := &associateTaskPRRequest{ + Owner: "testorg", + Repo: "testrepo", + PRNumber: 102, + PRURL: "https://github.com/testorg/testrepo/pull/102", + PRTitle: "Ready to ship", + HeadBranch: "feat/ready", + BaseBranch: "main", + AuthorLogin: "test-user", + State: "open", + MergeableState: "clean", + } + + controller.ensureMockPRForRequest(context.Background(), req, time.Now().UTC()) + + pr, err := mock.GetPR(context.Background(), req.Owner, req.Repo, req.PRNumber) + if err != nil { + t.Fatalf("GetPR: %v", err) + } + if pr == nil { + t.Fatal("expected synthetic PR") + } + if pr.MergeableState != "clean" { + t.Fatalf("MergeableState = %q, want clean", pr.MergeableState) + } +} diff --git a/apps/backend/internal/integration/simulated_agent_manager_test.go b/apps/backend/internal/integration/simulated_agent_manager_test.go index 98dfcf6430..cd2745c54b 100644 --- a/apps/backend/internal/integration/simulated_agent_manager_test.go +++ b/apps/backend/internal/integration/simulated_agent_manager_test.go @@ -396,6 +396,10 @@ func (s *SimulatedAgentManagerClient) IsAgentRunningForSession(ctx context.Conte return false } +func (s *SimulatedAgentManagerClient) IsAgentReadyForPrompt(ctx context.Context, sessionID string) bool { + return s.IsAgentRunningForSession(ctx, sessionID) +} + // CancelAgent cancels the current agent turn for a session. // Returns lifecycle.ErrNoExecutionForSession when no live execution exists for the session // (for example, after CrashAgentForSession has been used to simulate a crash), matching the diff --git a/apps/backend/internal/office/dashboard/dto.go b/apps/backend/internal/office/dashboard/dto.go index f6b9698f34..18274d6cb5 100644 --- a/apps/backend/internal/office/dashboard/dto.go +++ b/apps/backend/internal/office/dashboard/dto.go @@ -80,6 +80,64 @@ type DashboardResponse struct { AgentSummaries []AgentSummary `json:"agent_summaries"` } +// NewDashboardResponse maps the internal dashboard model to the public JSON +// shape consumed by both the HTTP endpoint and the Go-rendered SPA boot payload. +func NewDashboardResponse(data *models.DashboardData, summaries []AgentSummary) DashboardResponse { + if summaries == nil { + summaries = []AgentSummary{} + } + if data == nil { + return DashboardResponse{AgentSummaries: summaries} + } + + runActivity := make([]RunActivityDay, len(data.RunActivity)) + for i, d := range data.RunActivity { + runActivity[i] = RunActivityDay{ + Date: d.Date, + Succeeded: d.Succeeded, + Failed: d.Failed, + Other: d.Other, + } + } + + recentTasks := make([]RecentTaskDTO, len(data.RecentTasks)) + for i, t := range data.RecentTasks { + recentTasks[i] = RecentTaskDTO{ + ID: t.ID, + Identifier: t.Identifier, + Title: t.Title, + Status: dbStateToOfficeStatus(t.Status), + AssigneeAgentProfileID: t.AssigneeAgentProfileID, + UpdatedAt: t.UpdatedAt, + } + } + + return DashboardResponse{ + AgentCount: data.AgentCount, + RunningCount: data.RunningCount, + PausedCount: data.PausedCount, + ErrorCount: data.ErrorCount, + TasksInProgress: data.TasksInProgress, + OpenTasks: data.OpenTasks, + BlockedTasks: data.BlockedTasks, + MonthSpendSubcents: data.MonthSpendSubcents, + PendingApprovals: data.PendingApprovals, + RecentActivity: data.RecentActivity, + TaskCount: data.TaskCount, + SkillCount: data.SkillCount, + RoutineCount: data.RoutineCount, + RunActivity: runActivity, + TaskBreakdown: TaskBreakdown{ + Open: data.TaskBreakdown.Open, + InProgress: data.TaskBreakdown.InProgress, + Blocked: data.TaskBreakdown.Blocked, + Done: data.TaskBreakdown.Done, + }, + RecentTasks: recentTasks, + AgentSummaries: summaries, + } +} + // InboxResponse wraps inbox items. TotalCount mirrors len(Items) and is // included so the sidebar badge can read its count from the same payload // that drives the inbox page (Stream F of office optimization), avoiding a diff --git a/apps/backend/internal/office/dashboard/dto_test.go b/apps/backend/internal/office/dashboard/dto_test.go new file mode 100644 index 0000000000..2af37f1065 --- /dev/null +++ b/apps/backend/internal/office/dashboard/dto_test.go @@ -0,0 +1,78 @@ +package dashboard + +import ( + "encoding/json" + "testing" + + "github.com/kandev/kandev/internal/office/models" +) + +func TestNewDashboardResponseUsesPublicJSONShape(t *testing.T) { + resp := NewDashboardResponse(&models.DashboardData{ + AgentCount: 2, + RunningCount: 1, + PausedCount: 1, + ErrorCount: 0, + TasksInProgress: 3, + OpenTasks: 4, + BlockedTasks: 5, + MonthSpendSubcents: 123, + PendingApprovals: 6, + TaskCount: 7, + SkillCount: 8, + RoutineCount: 9, + RunActivity: []models.RunActivityDay{{ + Date: "2026-06-16", + Succeeded: 2, + Failed: 1, + Other: 0, + }}, + TaskBreakdown: models.TaskBreakdown{Open: 1, InProgress: 2, Blocked: 3, Done: 4}, + RecentTasks: []models.RecentTask{{ + ID: "task-1", + Identifier: "KAN-1", + Title: "Ship dashboard", + Status: stateCompleted, + UpdatedAt: "2026-06-16T12:00:00Z", + }}, + }, nil) + + raw, err := json.Marshal(resp) + if err != nil { + t.Fatalf("Marshal response: %v", err) + } + var decoded map[string]json.RawMessage + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatalf("Unmarshal response: %v", err) + } + for _, key := range []string{ + "agent_count", + "task_count", + "skill_count", + "routine_count", + "run_activity", + "task_breakdown", + "recent_tasks", + "agent_summaries", + } { + if _, ok := decoded[key]; !ok { + t.Fatalf("expected %q in dashboard response: %s", key, raw) + } + } + + var recent []RecentTaskDTO + if err := json.Unmarshal(decoded["recent_tasks"], &recent); err != nil { + t.Fatalf("Unmarshal recent_tasks: %v", err) + } + if len(recent) != 1 || recent[0].Status != statusDoneLowercase { + t.Fatalf("recent task status = %#v, want %q", recent, statusDoneLowercase) + } + + var summaries []AgentSummary + if err := json.Unmarshal(decoded["agent_summaries"], &summaries); err != nil { + t.Fatalf("Unmarshal agent_summaries: %v", err) + } + if summaries == nil { + t.Fatal("agent_summaries should encode as an empty array, not null") + } +} diff --git a/apps/backend/internal/office/dashboard/handler.go b/apps/backend/internal/office/dashboard/handler.go index 6f6981e2b0..c3c4947d34 100644 --- a/apps/backend/internal/office/dashboard/handler.go +++ b/apps/backend/internal/office/dashboard/handler.go @@ -144,49 +144,7 @@ func (h *Handler) getDashboard(c *gin.Context) { summaries = []AgentSummary{} } - runActivity := make([]RunActivityDay, len(data.RunActivity)) - for i, d := range data.RunActivity { - runActivity[i] = RunActivityDay{ - Date: d.Date, - Succeeded: d.Succeeded, - Failed: d.Failed, - Other: d.Other, - } - } - - recentTasks := make([]RecentTaskDTO, len(data.RecentTasks)) - for i, t := range data.RecentTasks { - recentTasks[i] = RecentTaskDTO{ - ID: t.ID, - Identifier: t.Identifier, - Title: t.Title, - Status: dbStateToOfficeStatus(t.Status), - AssigneeAgentProfileID: t.AssigneeAgentProfileID, - UpdatedAt: t.UpdatedAt, - } - } - - c.JSON(http.StatusOK, DashboardResponse{ - AgentCount: data.AgentCount, - RunningCount: data.RunningCount, - PausedCount: data.PausedCount, - ErrorCount: data.ErrorCount, - MonthSpendSubcents: data.MonthSpendSubcents, - PendingApprovals: data.PendingApprovals, - RecentActivity: data.RecentActivity, - TaskCount: data.TaskCount, - SkillCount: data.SkillCount, - RoutineCount: data.RoutineCount, - RunActivity: runActivity, - TaskBreakdown: TaskBreakdown{ - Open: data.TaskBreakdown.Open, - InProgress: data.TaskBreakdown.InProgress, - Blocked: data.TaskBreakdown.Blocked, - Done: data.TaskBreakdown.Done, - }, - RecentTasks: recentTasks, - AgentSummaries: summaries, - }) + c.JSON(http.StatusOK, NewDashboardResponse(data, summaries)) } // -- Live runs -- diff --git a/apps/backend/internal/office/dashboard/handler_settings.go b/apps/backend/internal/office/dashboard/handler_settings.go index 904c1ba3ac..19f57e9fb4 100644 --- a/apps/backend/internal/office/dashboard/handler_settings.go +++ b/apps/backend/internal/office/dashboard/handler_settings.go @@ -104,7 +104,13 @@ func (h *Handler) gitStatus(c *gin.Context) { // -- Meta -- func (h *Handler) getMeta(c *gin.Context) { - c.JSON(http.StatusOK, MetaResponse{ + c.JSON(http.StatusOK, BuildMetaResponse()) +} + +// BuildMetaResponse returns the static Office metadata payload used by both +// the HTTP endpoint and the SPA boot payload. +func BuildMetaResponse() MetaResponse { + return MetaResponse{ Statuses: models.AllStatuses(), Priorities: models.AllPriorities(), Roles: models.AllRoles(), @@ -116,7 +122,7 @@ func (h *Handler) getMeta(c *gin.Context) { InboxItemTypes: models.AllInboxItemTypes(), Permissions: allPermissionMeta(), PermissionDefaults: allPermissionDefaults(), - }) + } } func allPermissionMeta() []PermissionMeta { diff --git a/apps/backend/internal/orchestrator/event_handlers_pending_move_test.go b/apps/backend/internal/orchestrator/event_handlers_pending_move_test.go index 5d74accbb3..5f72e5f474 100644 --- a/apps/backend/internal/orchestrator/event_handlers_pending_move_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_pending_move_test.go @@ -285,6 +285,15 @@ func seedReviewSession(t *testing.T, repo *sqliterepo.Repository, now time.Time) // outside the LaunchAgent call; mirroring that timing here lets the resume // path complete in unit tests without spawning a real subprocess. func wireBootReadySimulator(svc *Service, agentMgr *mockAgentManager, newExecID string) { + promptReady := make(chan struct{}) + agentMgr.isAgentReadyFn = func(_ context.Context, _ string) bool { + select { + case <-promptReady: + return true + default: + return false + } + } agentMgr.launchAgentFunc = func(_ context.Context, req *executor.LaunchAgentRequest) (*executor.LaunchAgentResponse, error) { // Simulate the lifecycle manager's persistExecutorRunning: in production // the row is upserted in lockstep with executionStore.Add; here we mirror @@ -308,6 +317,7 @@ func wireBootReadySimulator(svc *Service, agentMgr *mockAgentManager, newExecID AgentExecutionID: newExecID, AgentProfileID: req.AgentProfileID, }) + close(promptReady) }() return &executor.LaunchAgentResponse{ AgentExecutionID: newExecID, diff --git a/apps/backend/internal/orchestrator/event_handlers_test.go b/apps/backend/internal/orchestrator/event_handlers_test.go index 9bbddcce25..086e30ddca 100644 --- a/apps/backend/internal/orchestrator/event_handlers_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_test.go @@ -150,6 +150,7 @@ type mockAgentManager struct { // IsAgentRunningForSession. Lets tests model state changes mid-sequence // (e.g. stream disconnect between PromptAgent call and queue write). isAgentRunningFn func(context.Context, string) bool + isAgentReadyFn func(context.Context, string) bool resolveProfileErr error restartProcessCalls []string // tracks execution IDs passed to RestartAgentProcess restartProcessErr error @@ -305,6 +306,12 @@ func (m *mockAgentManager) IsAgentRunningForSession(ctx context.Context, session } return m.isAgentRunning } +func (m *mockAgentManager) IsAgentReadyForPrompt(ctx context.Context, sessionID string) bool { + if m.isAgentReadyFn != nil { + return m.isAgentReadyFn(ctx, sessionID) + } + return m.IsAgentRunningForSession(ctx, sessionID) +} func (m *mockAgentManager) ResolveAgentProfile(_ context.Context, _ string) (*executor.AgentProfileInfo, error) { if m.resolveProfileErr != nil { return nil, m.resolveProfileErr diff --git a/apps/backend/internal/orchestrator/executor/executor.go b/apps/backend/internal/orchestrator/executor/executor.go index 513b0c6837..9ed24bf09c 100644 --- a/apps/backend/internal/orchestrator/executor/executor.go +++ b/apps/backend/internal/orchestrator/executor/executor.go @@ -113,6 +113,11 @@ type AgentManagerClient interface { // This probes the actual agent (Docker container or standalone process) rather than relying on cached state IsAgentRunningForSession(ctx context.Context, sessionID string) bool + // IsAgentReadyForPrompt checks if the session can accept an immediate prompt. + // This is stricter than IsAgentRunningForSession because ACP sessions can be + // running before session initialization and stream setup have completed. + IsAgentReadyForPrompt(ctx context.Context, sessionID string) bool + // ResolveAgentProfile resolves an agent profile ID to profile information ResolveAgentProfile(ctx context.Context, profileID string) (*AgentProfileInfo, error) diff --git a/apps/backend/internal/orchestrator/executor/executor_mocks_test.go b/apps/backend/internal/orchestrator/executor/executor_mocks_test.go index e76e737d2c..25003313e1 100644 --- a/apps/backend/internal/orchestrator/executor/executor_mocks_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_mocks_test.go @@ -128,6 +128,10 @@ func (m *mockAgentManager) IsAgentRunningForSession(ctx context.Context, session return false } +func (m *mockAgentManager) IsAgentReadyForPrompt(ctx context.Context, sessionID string) bool { + return m.IsAgentRunningForSession(ctx, sessionID) +} + func (m *mockAgentManager) WasSessionInitialized(_ string) bool { return false } func (m *mockAgentManager) GetSessionAuthMethods(_ string) []streams.AuthMethodInfo { return nil diff --git a/apps/backend/internal/orchestrator/prompt_idle_session_test.go b/apps/backend/internal/orchestrator/prompt_idle_session_test.go index 83cd325ba6..52486b4a25 100644 --- a/apps/backend/internal/orchestrator/prompt_idle_session_test.go +++ b/apps/backend/internal/orchestrator/prompt_idle_session_test.go @@ -172,6 +172,9 @@ func TestEnsureSessionRunning_IdleSessionTriggersResume(t *testing.T) { agentMgr := &mockAgentManager{ isAgentRunning: false, repoForExecutionLookup: repo, + isAgentReadyFn: func(_ context.Context, _ string) bool { + return true + }, launchAgentFunc: func(_ context.Context, req *executor.LaunchAgentRequest) (*executor.LaunchAgentResponse, error) { select { case launchCalled <- struct{}{}: @@ -230,3 +233,170 @@ func TestEnsureSessionRunning_IdleSessionTriggersResume(t *testing.T) { t.Fatal("expected ResumeSession to call LaunchAgent on IDLE session, but it never fired") } } + +func TestEnsureSessionRunning_WaitsForPromptReadyAfterResume(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + + seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateIdle) + session, err := repo.GetTaskSession(ctx, "session1") + if err != nil { + t.Fatalf("failed to load session: %v", err) + } + session.AgentExecutionID = "exec-idle-1" + session.AgentProfileID = "profile1" + if err := repo.UpdateTaskSession(ctx, session); err != nil { + t.Fatalf("failed to update session: %v", err) + } + seedExecutorRunning(t, repo, session.ID, session.TaskID, "exec-idle-1") + + ready := make(chan struct{}) + checked := make(chan struct{}, 1) + agentMgr := &mockAgentManager{ + isAgentRunning: false, + repoForExecutionLookup: repo, + isAgentReadyFn: func(_ context.Context, _ string) bool { + select { + case checked <- struct{}{}: + default: + } + select { + case <-ready: + return true + default: + return false + } + }, + launchAgentFunc: func(_ context.Context, req *executor.LaunchAgentRequest) (*executor.LaunchAgentResponse, error) { + go func(sessID string) { + tick := time.NewTicker(5 * time.Millisecond) + defer tick.Stop() + timeout := time.After(5 * time.Second) + for { + select { + case <-tick.C: + sess, err := repo.GetTaskSession(context.Background(), sessID) + if err == nil && sess != nil && sess.State == models.TaskSessionStateStarting { + sess.State = models.TaskSessionStateWaitingForInput + sess.UpdatedAt = time.Now().UTC() + _ = repo.UpdateTaskSession(context.Background(), sess) + return + } + case <-timeout: + return + } + } + }(req.SessionID) + return &executor.LaunchAgentResponse{AgentExecutionID: "exec-resumed-1"}, nil + }, + } + + taskRepo := newMockTaskRepo() + taskRepo.tasks["task1"] = &v1.Task{ + ID: "task1", + Title: "Test Task", + State: v1.TaskStateInProgress, + } + svc := createTestServiceWithAgent(repo, newMockStepGetter(), taskRepo, agentMgr) + svc.executor = executor.NewExecutor(agentMgr, repo, testLogger(), executor.ExecutorConfig{}) + + session, err = repo.GetTaskSession(ctx, "session1") + if err != nil { + t.Fatalf("reload: %v", err) + } + + done := make(chan error, 1) + go func() { + done <- svc.ensureSessionRunning(ctx, "session1", session) + }() + + select { + case <-checked: + case <-time.After(3 * time.Second): + t.Fatal("expected ensureSessionRunning to check prompt readiness after resume") + } + + select { + case err := <-done: + t.Fatalf("ensureSessionRunning returned before prompt readiness: %v", err) + default: + } + + close(ready) + + select { + case err := <-done: + if err != nil { + t.Fatalf("ensureSessionRunning failed after prompt readiness: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("ensureSessionRunning did not return after prompt readiness") + } +} + +func TestEnsureSessionRunning_WaitsForPromptReadyWhenExecutionExists(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + + seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateWaitingForInput) + session, err := repo.GetTaskSession(ctx, "session1") + if err != nil { + t.Fatalf("failed to load session: %v", err) + } + session.AgentExecutionID = "exec-ready-race-1" + session.AgentProfileID = "profile1" + if err := repo.UpdateTaskSession(ctx, session); err != nil { + t.Fatalf("failed to update session: %v", err) + } + seedExecutorRunning(t, repo, session.ID, session.TaskID, "exec-ready-race-1") + + ready := make(chan struct{}) + checked := make(chan struct{}, 1) + agentMgr := &mockAgentManager{ + isAgentRunning: true, + repoForExecutionLookup: repo, + isAgentReadyFn: func(_ context.Context, _ string) bool { + select { + case checked <- struct{}{}: + default: + } + select { + case <-ready: + return true + default: + return false + } + }, + } + + svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), agentMgr) + svc.executor = executor.NewExecutor(agentMgr, repo, testLogger(), executor.ExecutorConfig{}) + + done := make(chan error, 1) + go func() { + done <- svc.ensureSessionRunning(ctx, "session1", session) + }() + + select { + case <-checked: + case <-time.After(2 * time.Second): + t.Fatal("expected ensureSessionRunning to check prompt readiness") + } + + select { + case err := <-done: + t.Fatalf("ensureSessionRunning returned before prompt readiness: %v", err) + default: + } + + close(ready) + + select { + case err := <-done: + if err != nil { + t.Fatalf("ensureSessionRunning failed after prompt readiness: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("ensureSessionRunning did not return after prompt readiness") + } +} diff --git a/apps/backend/internal/orchestrator/scheduler/scheduler_test.go b/apps/backend/internal/orchestrator/scheduler/scheduler_test.go index 2d71f0dfa6..11f406c18b 100644 --- a/apps/backend/internal/orchestrator/scheduler/scheduler_test.go +++ b/apps/backend/internal/orchestrator/scheduler/scheduler_test.go @@ -99,6 +99,10 @@ func (m *mockAgentManager) IsAgentRunningForSession(ctx context.Context, session return false } +func (m *mockAgentManager) IsAgentReadyForPrompt(ctx context.Context, sessionID string) bool { + return m.IsAgentRunningForSession(ctx, sessionID) +} + func (m *mockAgentManager) WasSessionInitialized(_ string) bool { return false } func (m *mockAgentManager) GetSessionAuthMethods(_ string) []streams.AuthMethodInfo { return nil diff --git a/apps/backend/internal/orchestrator/session_updating_agent_manager_test.go b/apps/backend/internal/orchestrator/session_updating_agent_manager_test.go new file mode 100644 index 0000000000..bd0ebe8c30 --- /dev/null +++ b/apps/backend/internal/orchestrator/session_updating_agent_manager_test.go @@ -0,0 +1,7 @@ +package orchestrator + +import "context" + +func (m *sessionUpdatingAgentManager) IsAgentReadyForPrompt(_ context.Context, _ string) bool { + return m.onStartCalled != nil && *m.onStartCalled +} diff --git a/apps/backend/internal/orchestrator/task_operations.go b/apps/backend/internal/orchestrator/task_operations.go index 5795b3688a..9c65ccc20b 100644 --- a/apps/backend/internal/orchestrator/task_operations.go +++ b/apps/backend/internal/orchestrator/task_operations.go @@ -47,6 +47,11 @@ var ErrSessionResetInProgress = errors.New("session reset in progress") // the two misleads the UI and any caller doing errors.Is checks. var ErrSessionNotPromptable = errors.New("session not promptable") +const ( + agentPromptReadyTimeout = 10 * time.Second + agentPromptReadyInterval = 100 * time.Millisecond +) + func isAgentPromptInProgressError(err error) bool { return err != nil && errors.Is(err, ErrAgentPromptInProgress) } @@ -298,6 +303,7 @@ func (s *Service) StartCreatedSession(ctx context.Context, taskID, sessionID, ag "model": profileInfo.Model, } } + s.promoteSessionIfTaskHasNoPrimary(ctx, taskID, session) if err := s.repo.UpdateTaskSession(ctx, session); err != nil { s.logger.Warn("failed to update session agent profile", zap.String("session_id", sessionID), @@ -400,6 +406,33 @@ func (s *Service) StartCreatedSession(ctx context.Context, taskID, sessionID, ag return execution, nil } +func (s *Service) promoteSessionIfTaskHasNoPrimary(ctx context.Context, taskID string, session *models.TaskSession) { + if session == nil || session.IsPrimary { + return + } + sessions, err := s.repo.ListTaskSessions(ctx, taskID) + if err != nil { + s.logger.Warn("failed to inspect task sessions for missing primary", + zap.String("task_id", taskID), + zap.String("session_id", session.ID), + zap.Error(err)) + return + } + for _, existing := range sessions { + if existing.IsPrimary { + return + } + } + if err := s.SetPrimarySession(ctx, session.ID); err != nil { + s.logger.Warn("failed to promote workflow session as primary", + zap.String("task_id", taskID), + zap.String("session_id", session.ID), + zap.Error(err)) + return + } + session.IsPrimary = true +} + // postLaunchCreated handles post-launch bookkeeping for a created session: // records the initial user message (unless skipped) and sets plan mode metadata. // It does NOT modify session state — the executor sets STARTING, and event handlers @@ -975,7 +1008,11 @@ func (s *Service) ResumeTaskSession(ctx context.Context, taskID, sessionID strin // If the execution is already running (duplicate resume request), return it as success. if errors.Is(err, executor.ErrExecutionAlreadyRunning) { if existing, ok := s.executor.GetExecutionBySession(sessionID); ok && existing != nil { - existing.SessionState = v1.TaskSessionState(session.State) + readySession, waitErr := s.waitForResumedSessionReady(ctx, sessionID) + if waitErr != nil { + return nil, waitErr + } + existing.SessionState = v1.TaskSessionState(readySession.State) return existing, nil } } @@ -1003,8 +1040,11 @@ func (s *Service) ResumeTaskSession(ctx context.Context, taskID, sessionID strin } return nil, err } - // Preserve persisted task/session state; resume should not mutate state/columns. - execution.SessionState = v1.TaskSessionState(session.State) + readySession, err := s.waitForResumedSessionReady(ctx, sessionID) + if err != nil { + return nil, err + } + execution.SessionState = v1.TaskSessionState(readySession.State) // Backfill the initial user message when a prior failed launch never got // to recordInitialMessage. Without this, the resume can succeed and the @@ -1035,6 +1075,38 @@ func (s *Service) ResumeTaskSession(ctx context.Context, taskID, sessionID strin return execution, nil } +func (s *Service) waitForResumedSessionReady(ctx context.Context, sessionID string) (*models.TaskSession, error) { + return s.waitForSessionAndAgentReady(ctx, sessionID, "after resume") +} + +func (s *Service) waitForSessionAndAgentReady(ctx context.Context, sessionID, waitContext string) (*models.TaskSession, error) { + if err := s.waitForSessionReady(ctx, sessionID); err != nil { + return nil, fmt.Errorf("session not ready %s: %w", waitContext, err) + } + if err := s.waitForAgentPromptReady(ctx, sessionID); err != nil { + return nil, fmt.Errorf("agent not ready %s: %w", waitContext, err) + } + session, err := s.repo.GetTaskSession(ctx, sessionID) + if err != nil { + return nil, fmt.Errorf("failed to reload session %s: %w", waitContext, err) + } + return session, nil +} + +func (s *Service) waitForStartingSessionPromptable(ctx context.Context, taskID, sessionID string) (*models.TaskSession, error) { + s.logger.Debug("waiting for starting session to become promptable", + zap.String("task_id", taskID), + zap.String("session_id", sessionID)) + session, err := s.waitForSessionAndAgentReady(ctx, sessionID, "for prompt") + if err != nil { + return nil, err + } + if err := s.checkSessionPromptable(taskID, sessionID, session.State); err != nil { + return nil, err + } + return session, nil +} + // StartSessionForWorkflowStep starts an existing session with a workflow step's prompt configuration. // If the session is not running, it will be resumed first. Then a prompt is sent using the // step's prompt_prefix, prompt_suffix, and plan_mode settings combined with the task description. @@ -1125,6 +1197,9 @@ func (s *Service) advanceTaskWorkflowStep(ctx context.Context, task *models.Task func (s *Service) ensureSessionRunning(ctx context.Context, sessionID string, session *models.TaskSession) error { // Check if agent is genuinely running (in-memory execution store, not just DB state) if exec, ok := s.executor.GetExecutionBySession(sessionID); ok && exec != nil { + if err := s.waitForAgentPromptReady(ctx, sessionID); err != nil { + return err + } return nil } @@ -1177,11 +1252,38 @@ func (s *Service) ensureSessionRunning(ctx context.Context, sessionID string, se if err := s.waitForSessionReady(ctx, sessionID); err != nil { return fmt.Errorf("session not ready after resume: %w", err) } + if err := s.waitForAgentPromptReady(ctx, sessionID); err != nil { + return fmt.Errorf("agent not ready after resume: %w", err) + } s.logger.Debug("session resumed and ready for prompt") return nil } +func (s *Service) waitForAgentPromptReady(ctx context.Context, sessionID string) error { + if s.agentManager == nil { + return nil + } + + readyCtx, cancel := context.WithTimeout(ctx, agentPromptReadyTimeout) + defer cancel() + + ticker := time.NewTicker(agentPromptReadyInterval) + defer ticker.Stop() + + for { + if s.agentManager.IsAgentReadyForPrompt(readyCtx, sessionID) { + return nil + } + + select { + case <-readyCtx.Done(): + return fmt.Errorf("agent not ready for prompt: %w", readyCtx.Err()) + case <-ticker.C: + } + } +} + // startAgentOnPreparedWorkspace starts the agent subprocess on a session whose workspace // was prepared (agentctl running) but whose agent process was never started. This avoids // the "session already has an agent running" error from ResumeSession which tries a full @@ -1210,6 +1312,9 @@ func (s *Service) startAgentOnPreparedWorkspace(ctx context.Context, sessionID s if err := s.waitForSessionReady(ctx, sessionID); err != nil { return fmt.Errorf("session not ready after starting agent: %w", err) } + if err := s.waitForAgentPromptReady(ctx, sessionID); err != nil { + return fmt.Errorf("agent not ready after starting agent: %w", err) + } s.logger.Debug("agent started on prepared workspace and ready for prompt") return nil } @@ -1967,7 +2072,14 @@ func (s *Service) PromptTask(ctx context.Context, taskID, sessionID string, prom return nil, fmt.Errorf("failed to get session: %w", err) } if err := s.checkSessionPromptable(taskID, sessionID, session.State); err != nil { - return nil, err + if !errors.Is(err, ErrSessionNotPromptable) || session.State != models.TaskSessionStateStarting { + return nil, err + } + readySession, waitErr := s.waitForStartingSessionPromptable(ctx, taskID, sessionID) + if waitErr != nil { + return nil, waitErr + } + session = readySession } // Inject config context for config-mode sessions (dedicated settings chat, not plan mode) diff --git a/apps/backend/internal/orchestrator/task_operations_resume_test.go b/apps/backend/internal/orchestrator/task_operations_resume_test.go index 5cde0a57c0..aa7a6f3e5d 100644 --- a/apps/backend/internal/orchestrator/task_operations_resume_test.go +++ b/apps/backend/internal/orchestrator/task_operations_resume_test.go @@ -321,3 +321,111 @@ func TestGetTaskSessionStatus_NoAutoResumeWithResumeTokenOnError(t *testing.T) { t.Fatalf("expected ResumeReason=%q, got %q", resumeReasonErrorRecovery, resp.ResumeReason) } } + +func TestResumeTaskSession_WaitsForPromptReady(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateWaitingForInput) + + session, err := repo.GetTaskSession(ctx, "session1") + if err != nil { + t.Fatalf("failed to load session: %v", err) + } + session.AgentExecutionID = "exec-old-1" + session.AgentProfileID = "profile1" + if err := repo.UpdateTaskSession(ctx, session); err != nil { + t.Fatalf("failed to update session: %v", err) + } + seedExecutorRunning(t, repo, session.ID, session.TaskID, "exec-old-1") + + ready := make(chan struct{}) + checked := make(chan struct{}, 1) + agentMgr := &mockAgentManager{ + isAgentRunning: false, + repoForExecutionLookup: repo, + isAgentReadyFn: func(_ context.Context, _ string) bool { + select { + case checked <- struct{}{}: + default: + } + select { + case <-ready: + return true + default: + return false + } + }, + launchAgentFunc: func(_ context.Context, req *executor.LaunchAgentRequest) (*executor.LaunchAgentResponse, error) { + go func(sessID string) { + tick := time.NewTicker(5 * time.Millisecond) + defer tick.Stop() + timeout := time.After(5 * time.Second) + for { + select { + case <-tick.C: + sess, err := repo.GetTaskSession(context.Background(), sessID) + if err == nil && sess != nil && sess.State == models.TaskSessionStateStarting { + sess.State = models.TaskSessionStateWaitingForInput + sess.UpdatedAt = time.Now().UTC() + _ = repo.UpdateTaskSession(context.Background(), sess) + return + } + case <-timeout: + return + } + } + }(req.SessionID) + return &executor.LaunchAgentResponse{AgentExecutionID: "exec-resumed-1"}, nil + }, + } + + taskRepo := newMockTaskRepo() + taskRepo.tasks["task1"] = &v1.Task{ + ID: "task1", + Title: "Test Task", + State: v1.TaskStateInProgress, + } + svc := createTestServiceWithAgent(repo, newMockStepGetter(), taskRepo, agentMgr) + svc.executor = executor.NewExecutor(agentMgr, repo, testLogger(), executor.ExecutorConfig{}) + + done := make(chan struct { + exec *executor.TaskExecution + err error + }, 1) + go func() { + exec, err := svc.ResumeTaskSession(ctx, "task1", "session1") + done <- struct { + exec *executor.TaskExecution + err error + }{exec: exec, err: err} + }() + + select { + case <-checked: + case <-time.After(3 * time.Second): + t.Fatal("expected ResumeTaskSession to check prompt readiness") + } + + select { + case result := <-done: + t.Fatalf("ResumeTaskSession returned before prompt readiness: %v", result.err) + default: + } + + close(ready) + + select { + case result := <-done: + if result.err != nil { + t.Fatalf("ResumeTaskSession failed after prompt readiness: %v", result.err) + } + if result.exec == nil { + t.Fatal("ResumeTaskSession returned nil execution") + } + if result.exec.SessionState != v1.TaskSessionStateWaitingForInput { + t.Fatalf("expected WAITING_FOR_INPUT response state, got %s", result.exec.SessionState) + } + case <-time.After(2 * time.Second): + t.Fatal("ResumeTaskSession did not return after prompt readiness") + } +} diff --git a/apps/backend/internal/orchestrator/task_operations_test.go b/apps/backend/internal/orchestrator/task_operations_test.go index f344f4c167..cadcc15fc9 100644 --- a/apps/backend/internal/orchestrator/task_operations_test.go +++ b/apps/backend/internal/orchestrator/task_operations_test.go @@ -20,6 +20,7 @@ import ( "github.com/kandev/kandev/internal/orchestrator/scheduler" "github.com/kandev/kandev/internal/sysprompt" "github.com/kandev/kandev/internal/task/models" + wfmodels "github.com/kandev/kandev/internal/workflow/models" v1 "github.com/kandev/kandev/pkg/api/v1" ) @@ -83,6 +84,116 @@ func TestPromptTask_SessionAlreadyRunning(t *testing.T) { } } +func TestPromptTask_WaitsForStartingSessionBeforePrompt(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateStarting) + + session, err := repo.GetTaskSession(ctx, "session1") + if err != nil { + t.Fatalf("failed to load session: %v", err) + } + session.AgentExecutionID = "exec-resumed-1" + session.AgentProfileID = "profile1" + if err := repo.UpdateTaskSession(ctx, session); err != nil { + t.Fatalf("failed to update session: %v", err) + } + seedExecutorRunning(t, repo, session.ID, session.TaskID, "exec-resumed-1") + + promptReady := make(chan struct{}) + readinessChecked := make(chan struct{}, 1) + agentMgr := &mockAgentManager{ + isAgentRunning: true, + repoForExecutionLookup: repo, + promptResult: &executor.PromptResult{ + StopReason: "end_turn", + AgentMessage: "simple mock response", + }, + isAgentReadyFn: func(_ context.Context, _ string) bool { + select { + case readinessChecked <- struct{}{}: + default: + } + select { + case <-promptReady: + return true + default: + return false + } + }, + } + taskRepo := newMockTaskRepo() + taskRepo.tasks["task1"] = &v1.Task{ID: "task1", State: v1.TaskStateInProgress} + svc := createTestServiceWithAgent(repo, newMockStepGetter(), taskRepo, agentMgr) + svc.executor = executor.NewExecutor(agentMgr, repo, testLogger(), executor.ExecutorConfig{}) + + done := make(chan struct { + result *PromptResult + err error + }, 1) + go func() { + result, err := svc.PromptTask(ctx, "task1", "session1", "/e2e:simple-message", "", false, nil, false) + done <- struct { + result *PromptResult + err error + }{result: result, err: err} + }() + + go func() { + time.Sleep(25 * time.Millisecond) + readySession, err := repo.GetTaskSession(context.Background(), "session1") + if err != nil || readySession == nil { + return + } + readySession.State = models.TaskSessionStateWaitingForInput + readySession.UpdatedAt = time.Now().UTC() + _ = repo.UpdateTaskSession(context.Background(), readySession) + }() + + select { + case <-readinessChecked: + case <-time.After(2 * time.Second): + t.Fatal("expected PromptTask to wait for agent prompt readiness") + } + + select { + case result := <-done: + t.Fatalf("PromptTask returned before prompt readiness: result=%#v err=%v", result.result, result.err) + default: + } + + close(promptReady) + + select { + case result := <-done: + if result.err != nil { + t.Fatalf("PromptTask failed after prompt readiness: %v", result.err) + } + if result.result == nil { + t.Fatal("PromptTask returned nil result") + } + if result.result.AgentMessage != "simple mock response" { + t.Fatalf("unexpected agent message: %q", result.result.AgentMessage) + } + case <-time.After(2 * time.Second): + t.Fatal("PromptTask did not return after prompt readiness") + } + + agentMgr.mu.Lock() + prompts := append([]string(nil), agentMgr.capturedPrompts...) + calls := append([]promptCall(nil), agentMgr.capturedPromptCalls...) + agentMgr.mu.Unlock() + if len(prompts) != 1 { + t.Fatalf("expected one prompt after readiness, got %d", len(prompts)) + } + if prompts[0] != "/e2e:simple-message" { + t.Fatalf("unexpected prompt: %q", prompts[0]) + } + if len(calls) != 1 || calls[0].ExecutionID != "exec-resumed-1" { + t.Fatalf("unexpected prompt calls: %#v", calls) + } +} + func TestTrySwitchModelUpdatesRuntimeModelCache(t *testing.T) { repo := setupTestRepo(t) agentMgr := &mockAgentManager{ @@ -552,6 +663,97 @@ func TestStartCreatedSession_NotInCreatedState(t *testing.T) { } } +func TestStartCreatedSession_WorkflowOverridePromotesPreparedWhenTaskHasNoPrimary(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + now := time.Now().UTC() + + if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws1", Name: "Test", CreatedAt: now, UpdatedAt: now}); err != nil { + t.Fatalf("create workspace: %v", err) + } + if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf1", WorkspaceID: "ws1", Name: "Workflow", CreatedAt: now, UpdatedAt: now}); err != nil { + t.Fatalf("create workflow: %v", err) + } + if err := repo.CreateTask(ctx, &models.Task{ + ID: "task1", + WorkspaceID: "ws1", + WorkflowID: "wf1", + WorkflowStepID: "step1", + Title: "Task", + Description: "desc", + State: v1.TaskStateInProgress, + Metadata: map[string]interface{}{models.MetaKeyAgentProfileID: "profile-a"}, + CreatedAt: now, + UpdatedAt: now, + }); err != nil { + t.Fatalf("create task: %v", err) + } + + stepGetter := newMockStepGetter() + stepGetter.steps["step1"] = &wfmodels.WorkflowStep{ + ID: "step1", + WorkflowID: "wf1", + Name: "Step 1", + AgentProfileID: "profile-b", + } + taskRepo := newMockTaskRepo() + taskRepo.tasks["task1"] = &v1.Task{ + ID: "task1", + WorkspaceID: "ws1", + WorkflowID: "wf1", + Title: "Task", + Description: "desc", + State: v1.TaskStateInProgress, + Metadata: map[string]interface{}{models.MetaKeyAgentProfileID: "profile-a"}, + } + + var launchedProfile string + agentMgr := &mockAgentManager{ + launchAgentFunc: func(_ context.Context, req *executor.LaunchAgentRequest) (*executor.LaunchAgentResponse, error) { + launchedProfile = req.AgentProfileID + return &executor.LaunchAgentResponse{AgentExecutionID: "exec-1"}, nil + }, + } + svc := createTestServiceWithScheduler(repo, stepGetter, taskRepo, agentMgr) + + sessionID, err := svc.PrepareTaskSession(ctx, "task1", "profile-a", "", "", "step1", false) + if err != nil { + t.Fatalf("PrepareTaskSession: %v", err) + } + prepared, err := repo.GetTaskSession(ctx, sessionID) + if err != nil { + t.Fatalf("GetTaskSession after prepare: %v", err) + } + if !prepared.IsPrimary { + t.Fatal("prepared first session should start as primary") + } + prepared.IsPrimary = false + if err := repo.UpdateTaskSession(ctx, prepared); err != nil { + t.Fatalf("clear prepared primary flag: %v", err) + } + + if _, err := svc.StartCreatedSession(ctx, "task1", sessionID, "profile-a", "desc", true, false, true, nil); err != nil { + t.Fatalf("StartCreatedSession: %v", err) + } + + updated, err := repo.GetTaskSession(ctx, sessionID) + if err != nil { + t.Fatalf("GetTaskSession after start: %v", err) + } + if updated.AgentProfileID != "profile-b" { + t.Fatalf("agent profile = %q, want profile-b", updated.AgentProfileID) + } + if !updated.IsPrimary { + t.Fatal("workflow profile override must promote prepared session when task has no primary") + } + if got := updated.Metadata[models.SessionMetaKeyCreatedBy]; got != models.SessionCreatedByWorkflowSwitch { + t.Fatalf("created_by metadata = %v, want %q", got, models.SessionCreatedByWorkflowSwitch) + } + if launchedProfile != "profile-b" { + t.Fatalf("launched profile = %q, want profile-b", launchedProfile) + } +} + // --- recordInitialMessage --- // mockMessageCreator implements MessageCreator for testing. @@ -1125,13 +1327,20 @@ func TestResumeTaskSession_FailedKeepsResumeToken(t *testing.T) { // Agent launch succeeds so the resume path does not unwind and mark the task // FAILED, which would exercise a separate state-mutation code path. - agentMgr := &mockAgentManager{ - launchAgentFunc: func(_ context.Context, _ *executor.LaunchAgentRequest) (*executor.LaunchAgentResponse, error) { - return &executor.LaunchAgentResponse{ - AgentExecutionID: "exec-new", - Status: v1.AgentStatusStarting, - }, nil + startAgentProcessCalled := false + agentMgr := &sessionUpdatingAgentManager{ + mockAgentManager: &mockAgentManager{ + launchAgentFunc: func(_ context.Context, _ *executor.LaunchAgentRequest) (*executor.LaunchAgentResponse, error) { + return &executor.LaunchAgentResponse{ + AgentExecutionID: "exec-new", + Status: v1.AgentStatusStarting, + }, nil + }, }, + repo: repo, + sessionID: "session1", + taskID: "task1", + onStartCalled: &startAgentProcessCalled, } svc := createTestServiceWithAgent(repo, newMockStepGetter(), taskRepo, agentMgr) svc.executor = executor.NewExecutor(agentMgr, repo, testLogger(), executor.ExecutorConfig{}) diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index 5fa794f407..24a87b4545 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "maps" + "strings" "time" "github.com/kandev/kandev/internal/agentruntime" @@ -137,6 +138,25 @@ func (e LastAgentError) Stamp() string { return e.OccurredAt.UTC().Format(time.RFC3339Nano) + ":" + e.Message } +func (e LastAgentError) MatchesStamp(stamp string) bool { + if stamp == e.Stamp() { + return true + } + suffix := ":" + e.Message + if !strings.HasSuffix(stamp, suffix) { + return false + } + rawOccurredAt := strings.TrimSuffix(stamp, suffix) + if rawOccurredAt == "" { + return e.OccurredAt.IsZero() + } + occurredAt, err := time.Parse(time.RFC3339Nano, rawOccurredAt) + if err != nil { + return false + } + return occurredAt.Equal(e.OccurredAt) +} + func (e LastAgentError) IsDismissed() bool { return e.DismissedAt != nil && !e.DismissedAt.IsZero() } diff --git a/apps/backend/internal/task/repository/sqlite/session.go b/apps/backend/internal/task/repository/sqlite/session.go index e8f03706be..f1ecb32d79 100644 --- a/apps/backend/internal/task/repository/sqlite/session.go +++ b/apps/backend/internal/task/repository/sqlite/session.go @@ -647,8 +647,11 @@ func (r *Repository) DismissLastAgentError(ctx context.Context, sessionID string updated_at = ? WHERE id = ? AND json_extract(metadata, '$.last_agent_error.message') = ? - AND json_extract(metadata, '$.last_agent_error.occurred_at') = ? - `), string(valueJSON), now, sessionID, expected.Message, expected.OccurredAt.UTC().Format(time.RFC3339Nano)) + AND ( + json_extract(metadata, '$.last_agent_error.occurred_at') = ? + OR julianday(json_extract(metadata, '$.last_agent_error.occurred_at')) = julianday(?) + ) + `), string(valueJSON), now, sessionID, expected.Message, expected.OccurredAt.UTC().Format(time.RFC3339Nano), expected.OccurredAt.UTC().Format(time.RFC3339Nano)) if err != nil { return false, err } diff --git a/apps/backend/internal/task/repository/sqlite/session_test.go b/apps/backend/internal/task/repository/sqlite/session_test.go index 50fad030f0..2273b4a219 100644 --- a/apps/backend/internal/task/repository/sqlite/session_test.go +++ b/apps/backend/internal/task/repository/sqlite/session_test.go @@ -551,6 +551,46 @@ func TestDismissLastAgentErrorDoesNotOverwriteNewerError(t *testing.T) { } } +func TestDismissLastAgentErrorMatchesEquivalentTimestampText(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + + seedForMsgTest(t, repo, "task-error", "sess-error", "turn-error") + occurredAt, err := time.Parse(time.RFC3339Nano, "2026-06-14T12:00:00.310Z") + if err != nil { + t.Fatalf("parse occurred_at: %v", err) + } + lastErr := models.LastAgentError{ + Message: "peer disconnected before response", + OccurredAt: occurredAt, + } + if err := repo.SetSessionMetadataKey(ctx, "sess-error", models.SessionMetaKeyLastAgentError, map[string]any{ + "message": lastErr.Message, + "occurred_at": "2026-06-14T12:00:00.310Z", + }); err != nil { + t.Fatalf("seed last agent error: %v", err) + } + + updated, err := repo.DismissLastAgentError(ctx, "sess-error", lastErr, time.Now().UTC()) + if err != nil { + t.Fatalf("dismiss last agent error: %v", err) + } + if !updated { + t.Fatalf("expected equivalent timestamp text to match") + } + session, err := repo.GetTaskSession(ctx, "sess-error") + if err != nil { + t.Fatalf("get session: %v", err) + } + got, ok := models.LoadLastAgentError(session.Metadata) + if !ok { + t.Fatalf("expected last agent error metadata") + } + if !got.IsDismissed() { + t.Fatalf("last agent error = %#v, want dismissed", got) + } +} + func sessionCancellationMetadata(t *testing.T, repo *Repository, sessionID string) (string, sql.NullTime, time.Time) { t.Helper() var errorMessage string diff --git a/apps/backend/internal/task/service/service_sessions.go b/apps/backend/internal/task/service/service_sessions.go index 4b3280a8d5..20924e52f2 100644 --- a/apps/backend/internal/task/service/service_sessions.go +++ b/apps/backend/internal/task/service/service_sessions.go @@ -72,7 +72,7 @@ func (s *Service) DismissLastAgentError(ctx context.Context, sessionID, stamp st if !ok { return session, nil } - if stamp != "" && lastErr.Stamp() != stamp { + if stamp != "" && !lastErr.MatchesStamp(stamp) { return session, nil } now := time.Now().UTC() diff --git a/apps/backend/internal/task/service/service_test.go b/apps/backend/internal/task/service/service_test.go index bdc3135a54..4514596de6 100644 --- a/apps/backend/internal/task/service/service_test.go +++ b/apps/backend/internal/task/service/service_test.go @@ -691,6 +691,36 @@ func TestService_DismissLastAgentError(t *testing.T) { } } +func TestService_DismissLastAgentErrorAcceptsBrowserTimestampStamp(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + setupTestTask(t, repo) + sessionID := setupTestSession(t, repo) + occurredAt, err := time.Parse(time.RFC3339Nano, "2026-06-14T12:00:00.310Z") + if err != nil { + t.Fatalf("parse occurred_at: %v", err) + } + lastErr := models.LastAgentError{ + Message: "peer disconnected before response", + OccurredAt: occurredAt, + } + if err := repo.SetSessionMetadataKey(ctx, sessionID, models.SessionMetaKeyLastAgentError, lastErr); err != nil { + t.Fatalf("seed last agent error: %v", err) + } + + session, err := svc.DismissLastAgentError(ctx, sessionID, "2026-06-14T12:00:00.310Z:"+lastErr.Message) + if err != nil { + t.Fatalf("dismiss last agent error: %v", err) + } + got, ok := models.LoadLastAgentError(session.Metadata) + if !ok { + t.Fatalf("expected last agent error metadata after dismiss") + } + if !got.IsDismissed() { + t.Fatalf("expected dismissed last agent error, got %#v", got) + } +} + func TestService_DismissLastAgentErrorIgnoresStaleStamp(t *testing.T) { svc, _, repo := createTestService(t) ctx := context.Background() diff --git a/apps/backend/internal/webapp/dev_handler.go b/apps/backend/internal/webapp/dev_handler.go new file mode 100644 index 0000000000..e44b257569 --- /dev/null +++ b/apps/backend/internal/webapp/dev_handler.go @@ -0,0 +1,156 @@ +package webapp + +import ( + "fmt" + "io" + "net/http" + "net/http/httputil" + "net/url" + "strings" +) + +// DevHandler fronts a Vite dev server with the same Go boot-payload shell used +// in production. Vite remains responsible for module transforms and HMR. +type DevHandler struct { + viteIndexURL string + proxy *httputil.ReverseProxy + client *http.Client + payloadFor PayloadBuilder +} + +func NewDevHandler(viteBaseURL string, opts ...HandlerOption) (*DevHandler, error) { + target, err := url.Parse(viteBaseURL) + if err != nil { + return nil, fmt.Errorf("parse Vite dev server URL: %w", err) + } + if target.Scheme == "" || target.Host == "" { + return nil, fmt.Errorf("parse Vite dev server URL: missing scheme or host") + } + proxy := httputil.NewSingleHostReverseProxy(target) + proxy.FlushInterval = -1 + proxy.ErrorHandler = func(w http.ResponseWriter, _ *http.Request, _ error) { + w.WriteHeader(http.StatusBadGateway) + } + + config := newHandlerDefaults(nil) + for _, opt := range opts { + opt(config) + } + + indexURL := *target + indexURL.Path = "/" + indexURL.RawQuery = "" + indexURL.Fragment = "" + + return &DevHandler{ + viteIndexURL: indexURL.String(), + proxy: proxy, + client: http.DefaultClient, + payloadFor: config.payloadFor, + }, nil +} + +func (h *DevHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if h == nil { + http.NotFound(w, r) + return + } + + if shouldProxyToVite(r) { + h.proxy.ServeHTTP(w, r) + return + } + + route := ClassifyRoute(r.URL.Path) + if route.Kind != RouteKindSPA { + if route.Kind == RouteKindStatic { + h.proxy.ServeHTTP(w, r) + return + } + http.NotFound(w, r) + return + } + + if !acceptsHTML(r) { + h.proxy.ServeHTTP(w, r) + return + } + + indexHTML, err := h.fetchViteIndex(r) + if err != nil { + http.Error(w, "Vite dev server unavailable", http.StatusBadGateway) + return + } + payload := h.payloadFor(r, route) + html, err := RenderShellHTML(indexHTML, payload) + if err != nil { + http.Error(w, "web app shell unavailable", http.StatusServiceUnavailable) + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(html) +} + +func (h *DevHandler) fetchViteIndex(r *http.Request) ([]byte, error) { + req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, h.viteIndexURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "text/html") + + resp, err := h.client.Do(req) + if err != nil { + return nil, err + } + defer func() { + _ = resp.Body.Close() + }() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("vite index status %d", resp.StatusCode) + } + + return io.ReadAll(resp.Body) +} + +func shouldProxyToVite(r *http.Request) bool { + if isWebSocketUpgrade(r) { + return true + } + if r.Method != http.MethodGet && r.Method != http.MethodHead { + return true + } + return isViteDevPath(r.URL.Path) +} + +func acceptsHTML(r *http.Request) bool { + accept := r.Header.Get("Accept") + return accept == "" || strings.Contains(accept, "text/html") +} + +func isWebSocketUpgrade(r *http.Request) bool { + return strings.EqualFold(r.Header.Get("Upgrade"), "websocket") && + strings.Contains(strings.ToLower(r.Header.Get("Connection")), "upgrade") +} + +func isViteDevPath(requestPath string) bool { + switch { + case strings.HasPrefix(requestPath, "/@vite/"), + strings.HasPrefix(requestPath, "/@id/"), + strings.HasPrefix(requestPath, "/@react-refresh"), + strings.HasPrefix(requestPath, "/@fs/"), + strings.HasPrefix(requestPath, "/__vite"), + strings.HasPrefix(requestPath, "/src/"), + strings.HasPrefix(requestPath, "/app/"), + strings.HasPrefix(requestPath, "/components/"), + strings.HasPrefix(requestPath, "/hooks/"), + strings.HasPrefix(requestPath, "/lib/"), + strings.HasPrefix(requestPath, "/generated/"), + strings.HasPrefix(requestPath, "/node_modules/"): + return true + default: + return false + } +} diff --git a/apps/backend/internal/webapp/dev_handler_test.go b/apps/backend/internal/webapp/dev_handler_test.go new file mode 100644 index 0000000000..fdcc6fc242 --- /dev/null +++ b/apps/backend/internal/webapp/dev_handler_test.go @@ -0,0 +1,83 @@ +package webapp + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestDevHandlerInjectsBootPayloadIntoViteIndexForSPARoutes(t *testing.T) { + t.Parallel() + + vite := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + t.Fatalf("vite received path %q, want /", r.URL.Path) + } + w.Header().Set("Content-Type", "text/html") + _, _ = w.Write([]byte(`
`)) + })) + t.Cleanup(vite.Close) + + handler, err := NewDevHandler(vite.URL, WithPayloadBuilder(func(_ *http.Request, route RouteClassification) BootPayload { + payload := NewBootPayload(route, RuntimeConfig{APIPrefix: "/api/v1", WebSocketPath: "/ws"}, nil) + payload.RouteData = map[string]any{"from": "go"} + return payload + })) + if err != nil { + t.Fatalf("NewDevHandler: %v", err) + } + + resp := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/tasks", nil) + req.Header.Set("Accept", "text/html") + handler.ServeHTTP(resp, req) + + body := resp.Body.String() + if resp.Code != http.StatusOK { + t.Fatalf("status = %d, want %d body=%s", resp.Code, http.StatusOK, body) + } + if !strings.Contains(body, bootPayloadGlobal) { + t.Fatalf("body missing boot payload: %s", body) + } + if !strings.Contains(body, `"route":"tasks"`) { + t.Fatalf("body missing route metadata: %s", body) + } + if !strings.Contains(body, `"from":"go"`) { + t.Fatalf("body missing route data: %s", body) + } + if got := resp.Header().Get("Cache-Control"); got != "no-store" { + t.Fatalf("Cache-Control = %q, want no-store", got) + } +} + +func TestDevHandlerProxiesViteModuleRequests(t *testing.T) { + t.Parallel() + + vite := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/src/main.tsx" { + t.Fatalf("vite received path %q, want /src/main.tsx", r.URL.Path) + } + w.Header().Set("Content-Type", "application/javascript") + _, _ = w.Write([]byte(`console.log("vite module")`)) + })) + t.Cleanup(vite.Close) + + handler, err := NewDevHandler(vite.URL) + if err != nil { + t.Fatalf("NewDevHandler: %v", err) + } + + resp := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/src/main.tsx", nil) + req.Header.Set("Accept", "*/*") + handler.ServeHTTP(resp, req) + + body := resp.Body.String() + if resp.Code != http.StatusOK { + t.Fatalf("status = %d, want %d body=%s", resp.Code, http.StatusOK, body) + } + if got := strings.TrimSpace(body); got != `console.log("vite module")` { + t.Fatalf("body = %q, want proxied module", got) + } +} diff --git a/apps/backend/internal/webapp/embedded/assets.go b/apps/backend/internal/webapp/embedded/assets.go new file mode 100644 index 0000000000..f8bcf0f103 --- /dev/null +++ b/apps/backend/internal/webapp/embedded/assets.go @@ -0,0 +1,19 @@ +package embedded + +import ( + "embed" + "io/fs" +) + +//go:embed fallback generated +var assets embed.FS + +// FS returns embedded Vite web assets. Generated assets are embedded when the +// root build syncs apps/web/dist before compiling the backend; backend-only +// builds fall back to a minimal shell so the binary remains buildable. +func FS() (fs.FS, error) { + if _, err := assets.Open("generated/index.html"); err == nil { + return fs.Sub(assets, "generated") + } + return fs.Sub(assets, "fallback") +} diff --git a/apps/backend/internal/webapp/embedded/fallback/index.html b/apps/backend/internal/webapp/embedded/fallback/index.html new file mode 100644 index 0000000000..8159180db8 --- /dev/null +++ b/apps/backend/internal/webapp/embedded/fallback/index.html @@ -0,0 +1,10 @@ + + + + +