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 @@ + + + + + Kandev + + +
+ + diff --git a/apps/backend/internal/webapp/embedded/generated/.gitignore b/apps/backend/internal/webapp/embedded/generated/.gitignore new file mode 100644 index 0000000000..d31f66c9a5 --- /dev/null +++ b/apps/backend/internal/webapp/embedded/generated/.gitignore @@ -0,0 +1,3 @@ +* +!.gitignore +!keep.txt diff --git a/apps/backend/internal/webapp/embedded/generated/keep.txt b/apps/backend/internal/webapp/embedded/generated/keep.txt new file mode 100644 index 0000000000..153d37511e --- /dev/null +++ b/apps/backend/internal/webapp/embedded/generated/keep.txt @@ -0,0 +1 @@ +Generated Vite assets are copied here by `make sync-embedded-web`. diff --git a/apps/backend/internal/webapp/handler.go b/apps/backend/internal/webapp/handler.go new file mode 100644 index 0000000000..3baeb23657 --- /dev/null +++ b/apps/backend/internal/webapp/handler.go @@ -0,0 +1,132 @@ +package webapp + +import ( + "io" + "io/fs" + "mime" + "net/http" + "path" + "strings" +) + +const IndexHTML = "index.html" + +type PayloadBuilder func(*http.Request, RouteClassification) BootPayload + +type Handler struct { + assets fs.FS + payloadFor PayloadBuilder + runtime RuntimeConfig + indexPath string + cacheControl string +} + +type HandlerOption func(*Handler) + +func NewHandler(assets fs.FS, opts ...HandlerOption) *Handler { + h := newHandlerDefaults(assets) + for _, opt := range opts { + opt(h) + } + return h +} + +func newHandlerDefaults(assets fs.FS) *Handler { + h := &Handler{ + assets: assets, + runtime: RuntimeConfig{APIPrefix: "/api/v1", WebSocketPath: "/ws"}, + indexPath: IndexHTML, + cacheControl: "public, max-age=31536000, immutable", + } + h.payloadFor = func(_ *http.Request, route RouteClassification) BootPayload { + return NewBootPayload(route, h.runtime, nil) + } + return h +} + +func WithRuntimeConfig(runtime RuntimeConfig) HandlerOption { + return func(h *Handler) { + h.runtime = runtime + } +} + +func WithPayloadBuilder(builder PayloadBuilder) HandlerOption { + return func(h *Handler) { + if builder != nil { + h.payloadFor = builder + } + } +} + +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if h == nil || h.assets == nil { + http.NotFound(w, r) + return + } + + if h.serveAsset(w, r) { + return + } + + route := ClassifyRoute(r.URL.Path) + if route.Kind != RouteKindSPA { + http.NotFound(w, r) + return + } + + payload := h.payloadFor(r, route) + html, err := RenderShell(h.assets, h.indexPath, 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 *Handler) serveAsset(w http.ResponseWriter, r *http.Request) bool { + name := assetName(r.URL.Path) + if name == "" || name == h.indexPath { + return false + } + + file, err := h.assets.Open(name) + if err != nil { + return false + } + defer func() { + _ = file.Close() + }() + + stat, err := file.Stat() + if err != nil || stat.IsDir() { + return false + } + + if contentType := mime.TypeByExtension(path.Ext(name)); contentType != "" { + w.Header().Set("Content-Type", contentType) + } + w.Header().Set("Cache-Control", h.cacheControl) + seeker, ok := file.(io.ReadSeeker) + if !ok { + data, readErr := io.ReadAll(file) + if readErr != nil { + http.Error(w, "read web asset", http.StatusInternalServerError) + return true + } + _, _ = w.Write(data) + return true + } + http.ServeContent(w, r, stat.Name(), stat.ModTime(), seeker) + return true +} + +func assetName(requestPath string) string { + cleanPath := path.Clean("/" + strings.TrimPrefix(requestPath, "/")) + if cleanPath == "/" { + return "" + } + return strings.TrimPrefix(cleanPath, "/") +} diff --git a/apps/backend/internal/webapp/handler_test.go b/apps/backend/internal/webapp/handler_test.go new file mode 100644 index 0000000000..0902f8a761 --- /dev/null +++ b/apps/backend/internal/webapp/handler_test.go @@ -0,0 +1,72 @@ +package webapp + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "testing/fstest" +) + +func TestHandlerServesStaticAssetBeforeRouteClassification(t *testing.T) { + t.Parallel() + + handler := NewHandler(fstest.MapFS{ + "index.html": {Data: []byte("")}, + "fonts/seti/seti.woff": {Data: []byte("font")}, + "assets/index-abc123.js": {Data: []byte("console.log('ok')")}, + }) + + resp := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/fonts/seti/seti.woff", nil) + handler.ServeHTTP(resp, req) + + if resp.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", resp.Code, http.StatusOK) + } + if got := strings.TrimSpace(resp.Body.String()); got != "font" { + t.Fatalf("body = %q, want font", got) + } +} + +func TestHandlerInjectsBootPayloadForSPARoutes(t *testing.T) { + t.Parallel() + + handler := NewHandler(fstest.MapFS{ + "index.html": {Data: []byte("
")}, + }) + + resp := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/t/task-1", nil) + 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, `"taskId":"task-1"`) { + t.Fatalf("body missing task route params: %s", body) + } + if got := resp.Header().Get("Cache-Control"); got != "no-store" { + t.Fatalf("Cache-Control = %q, want no-store", got) + } +} + +func TestHandlerReturnsNotFoundForAPIMiss(t *testing.T) { + t.Parallel() + + handler := NewHandler(fstest.MapFS{ + "index.html": {Data: []byte("")}, + }) + + resp := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/unknown", nil) + handler.ServeHTTP(resp, req) + + if resp.Code != http.StatusNotFound { + t.Fatalf("status = %d, want %d", resp.Code, http.StatusNotFound) + } +} diff --git a/apps/backend/internal/webapp/payload.go b/apps/backend/internal/webapp/payload.go new file mode 100644 index 0000000000..93e1788ac2 --- /dev/null +++ b/apps/backend/internal/webapp/payload.go @@ -0,0 +1,43 @@ +package webapp + +const BootPayloadVersion = 1 + +// BootPayload is the JSON-safe data blob the Go server will embed in the SPA +// shell before React hydrates. +type BootPayload struct { + Version int `json:"version"` + Route RouteClassification `json:"route"` + Runtime RuntimeConfig `json:"runtime"` + InitialState map[string]any `json:"initialState"` + RouteData map[string]any `json:"routeData,omitempty"` + Errors []BootError `json:"errors,omitempty"` +} + +// RuntimeConfig contains browser-facing runtime endpoints for the SPA. +type RuntimeConfig struct { + APIPrefix string `json:"apiPrefix"` + WebSocketPath string `json:"webSocketPath"` +} + +// BootError is a serializable non-fatal boot-data error for partial hydration. +type BootError struct { + Code string `json:"code"` + Message string `json:"message"` +} + +func NewBootPayload( + route RouteClassification, + runtime RuntimeConfig, + initialState map[string]any, +) BootPayload { + if initialState == nil { + initialState = map[string]any{} + } + + return BootPayload{ + Version: BootPayloadVersion, + Route: route, + Runtime: runtime, + InitialState: initialState, + } +} diff --git a/apps/backend/internal/webapp/routes.go b/apps/backend/internal/webapp/routes.go new file mode 100644 index 0000000000..7d819a5c02 --- /dev/null +++ b/apps/backend/internal/webapp/routes.go @@ -0,0 +1,154 @@ +package webapp + +import ( + pathpkg "path" + "strings" +) + +// RouteKind describes how the backend should handle an incoming request path. +type RouteKind string + +const ( + RouteKindSPA RouteKind = "spa" + RouteKindAPI RouteKind = "api" + RouteKindWebSocket RouteKind = "websocket" + RouteKindStatic RouteKind = "static" + RouteKindHealth RouteKind = "health" +) + +// RouteName identifies SPA routes that need route-aware boot data. +type RouteName string + +const ( + RouteUnknown RouteName = "unknown" + RouteHome RouteName = "home" + RouteTasks RouteName = "tasks" + RouteTaskDetail RouteName = "taskDetail" + RouteOffice RouteName = "office" + RouteSettings RouteName = "settings" + RouteGitHub RouteName = "github" + RouteGitLab RouteName = "gitlab" + RouteJira RouteName = "jira" + RouteLinear RouteName = "linear" + RouteStats RouteName = "stats" +) + +// RouteClassification is the backend contract for SPA-vs-backend routing. +type RouteClassification struct { + Kind RouteKind `json:"kind"` + Route RouteName `json:"route,omitempty"` + Path string `json:"path"` + Params map[string]string `json:"params,omitempty"` +} + +// ClassifyRoute returns the future webapp handling class for a request path. +func ClassifyRoute(rawPath string) RouteClassification { + requestPath := normalizePath(rawPath) + if kind, ok := classifyNonSPA(requestPath); ok { + return RouteClassification{Kind: kind, Path: requestPath} + } + + route, params := classifySPARoute(requestPath) + return RouteClassification{ + Kind: RouteKindSPA, + Route: route, + Path: requestPath, + Params: params, + } +} + +// IsSPARoute reports whether rawPath should serve the SPA shell. +func IsSPARoute(rawPath string) bool { + return ClassifyRoute(rawPath).Kind == RouteKindSPA +} + +func classifyNonSPA(requestPath string) (RouteKind, bool) { + switch { + case requestPath == "/api" || strings.HasPrefix(requestPath, "/api/"): + return RouteKindAPI, true + case requestPath == "/ws" || strings.HasPrefix(requestPath, "/ws/"): + return RouteKindWebSocket, true + case requestPath == "/health": + return RouteKindHealth, true + case isStaticPath(requestPath): + return RouteKindStatic, true + default: + return "", false + } +} + +func classifySPARoute(requestPath string) (RouteName, map[string]string) { + switch { + case requestPath == "/": + return RouteHome, nil + case requestPath == "/tasks": + return RouteTasks, nil + case requestPath == "/github": + return RouteGitHub, nil + case requestPath == "/gitlab": + return RouteGitLab, nil + case requestPath == "/jira": + return RouteJira, nil + case requestPath == "/linear": + return RouteLinear, nil + case requestPath == "/stats": + return RouteStats, nil + case requestPath == "/office" || strings.HasPrefix(requestPath, "/office/"): + return RouteOffice, nil + case requestPath == "/settings" || strings.HasPrefix(requestPath, "/settings/"): + return RouteSettings, nil + default: + return classifyTaskRoute(requestPath) + } +} + +func classifyTaskRoute(requestPath string) (RouteName, map[string]string) { + taskID, ok := cutSingleSegment(requestPath, "/t/") + if !ok { + taskID, ok = cutSingleSegment(requestPath, "/tasks/") + } + if !ok { + return RouteUnknown, nil + } + return RouteTaskDetail, map[string]string{"taskId": taskID} +} + +func cutSingleSegment(requestPath, prefix string) (string, bool) { + value, ok := strings.CutPrefix(requestPath, prefix) + if !ok || value == "" || strings.Contains(value, "/") { + return "", false + } + return value, true +} + +func isStaticPath(requestPath string) bool { + if strings.HasPrefix(requestPath, "/assets/") || strings.HasPrefix(requestPath, "/_next/") { + return true + } + + switch requestPath { + case "/favicon.ico", "/robots.txt", "/manifest.json", "/manifest.webmanifest", "/vite.svg": + return true + default: + return false + } +} + +func normalizePath(rawPath string) string { + if rawPath == "" { + return "/" + } + cleanInput := stripSuffix(rawPath, "?") + cleanInput = stripSuffix(cleanInput, "#") + if !strings.HasPrefix(cleanInput, "/") { + cleanInput = "/" + cleanInput + } + return pathpkg.Clean(cleanInput) +} + +func stripSuffix(value, marker string) string { + if idx := strings.Index(value, marker); idx >= 0 { + return value[:idx] + } + return value +} diff --git a/apps/backend/internal/webapp/routes_test.go b/apps/backend/internal/webapp/routes_test.go new file mode 100644 index 0000000000..9fd7a9c9f8 --- /dev/null +++ b/apps/backend/internal/webapp/routes_test.go @@ -0,0 +1,158 @@ +package webapp + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestClassifyRouteSPARoutes(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + path string + wantRoute RouteName + wantParams map[string]string + }{ + {name: "home", path: "/", wantRoute: RouteHome}, + {name: "tasks", path: "/tasks", wantRoute: RouteTasks}, + {name: "task detail", path: "/t/task-123", wantRoute: RouteTaskDetail, wantParams: map[string]string{"taskId": "task-123"}}, + {name: "task detail compat", path: "/tasks/task-123", wantRoute: RouteTaskDetail, wantParams: map[string]string{"taskId": "task-123"}}, + {name: "office root", path: "/office", wantRoute: RouteOffice}, + {name: "office nested", path: "/office/agents/agent-1", wantRoute: RouteOffice}, + {name: "settings root", path: "/settings", wantRoute: RouteSettings}, + {name: "settings nested", path: "/settings/integrations/github", wantRoute: RouteSettings}, + {name: "github", path: "/github", wantRoute: RouteGitHub}, + {name: "gitlab", path: "/gitlab", wantRoute: RouteGitLab}, + {name: "jira", path: "/jira", wantRoute: RouteJira}, + {name: "linear", path: "/linear", wantRoute: RouteLinear}, + {name: "stats", path: "/stats", wantRoute: RouteStats}, + {name: "query stripped", path: "/tasks?view=all", wantRoute: RouteTasks}, + {name: "trailing slash normalized", path: "/settings/", wantRoute: RouteSettings}, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got := ClassifyRoute(tc.path) + if got.Kind != RouteKindSPA { + t.Fatalf("Kind = %q, want %q", got.Kind, RouteKindSPA) + } + if got.Route != tc.wantRoute { + t.Fatalf("Route = %q, want %q", got.Route, tc.wantRoute) + } + assertParams(t, got.Params, tc.wantParams) + }) + } +} + +func TestClassifyRouteNonSPARoutes(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + path string + wantKind RouteKind + }{ + {name: "api root", path: "/api", wantKind: RouteKindAPI}, + {name: "api nested", path: "/api/v1/tasks", wantKind: RouteKindAPI}, + {name: "websocket", path: "/ws", wantKind: RouteKindWebSocket}, + {name: "websocket nested", path: "/ws/tasks", wantKind: RouteKindWebSocket}, + {name: "health", path: "/health", wantKind: RouteKindHealth}, + {name: "vite assets", path: "/assets/app.js", wantKind: RouteKindStatic}, + {name: "favicon", path: "/favicon.ico", wantKind: RouteKindStatic}, + {name: "robots", path: "/robots.txt", wantKind: RouteKindStatic}, + {name: "manifest", path: "/manifest.webmanifest", wantKind: RouteKindStatic}, + {name: "legacy next static", path: "/_next/static/chunk.js", wantKind: RouteKindStatic}, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got := ClassifyRoute(tc.path) + if got.Kind != tc.wantKind { + t.Fatalf("Kind = %q, want %q", got.Kind, tc.wantKind) + } + if got.Route != "" { + t.Fatalf("Route = %q, want empty for non-SPA route", got.Route) + } + }) + } +} + +func TestClassifyRouteUnknownPathFallsThroughToSPA(t *testing.T) { + t.Parallel() + + got := ClassifyRoute("/future/client/route") + if got.Kind != RouteKindSPA { + t.Fatalf("Kind = %q, want %q", got.Kind, RouteKindSPA) + } + if got.Route != RouteUnknown { + t.Fatalf("Route = %q, want %q", got.Route, RouteUnknown) + } +} + +func TestBootPayloadMarshalShapeAndScriptSafety(t *testing.T) { + t.Parallel() + + payload := NewBootPayload( + ClassifyRoute("/t/task-123"), + RuntimeConfig{APIPrefix: "/api/v1", WebSocketPath: "/ws"}, + map[string]any{ + "task": map[string]any{ + "id": "task-123", + "title": "", + }, + }, + ) + + data, err := json.Marshal(payload) + if err != nil { + t.Fatalf("Marshal BootPayload: %v", err) + } + if strings.Contains(string(data), "") { + t.Fatalf("payload contains raw script terminator: %s", data) + } + + var decoded struct { + Version int `json:"version"` + Route json.RawMessage `json:"route"` + Runtime json.RawMessage `json:"runtime"` + InitialState json.RawMessage `json:"initialState"` + } + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("Unmarshal BootPayload: %v", err) + } + if decoded.Version != BootPayloadVersion { + t.Fatalf("Version = %d, want %d", decoded.Version, BootPayloadVersion) + } + assertRawJSONPresent(t, "route", decoded.Route) + assertRawJSONPresent(t, "runtime", decoded.Runtime) + assertRawJSONPresent(t, "initialState", decoded.InitialState) +} + +func assertParams(t *testing.T, got, want map[string]string) { + t.Helper() + + if len(got) != len(want) { + t.Fatalf("Params = %#v, want %#v", got, want) + } + for key, wantValue := range want { + if got[key] != wantValue { + t.Fatalf("Params[%q] = %q, want %q", key, got[key], wantValue) + } + } +} + +func assertRawJSONPresent(t *testing.T, name string, raw json.RawMessage) { + t.Helper() + + if len(raw) == 0 || string(raw) == "null" { + t.Fatalf("%s missing from payload", name) + } +} diff --git a/apps/backend/internal/webapp/shell.go b/apps/backend/internal/webapp/shell.go new file mode 100644 index 0000000000..d24d2b403b --- /dev/null +++ b/apps/backend/internal/webapp/shell.go @@ -0,0 +1,79 @@ +package webapp + +import ( + "bytes" + "encoding/json" + "fmt" + "io/fs" +) + +const bootPayloadGlobal = "window.__KANDEV_BOOT_PAYLOAD__" +const maxInt = int(^uint(0) >> 1) + +var headCloseTag = []byte("") + +// RenderShell reads indexPath from assets and injects the boot payload before +// . The assets FS can be an embedded Vite dist in production or an +// in-memory filesystem in tests. +func RenderShell(assets fs.FS, indexPath string, payload BootPayload) ([]byte, error) { + indexHTML, err := fs.ReadFile(assets, indexPath) + if err != nil { + return nil, fmt.Errorf("read web shell: %w", err) + } + + return RenderShellHTML(indexHTML, payload) +} + +// RenderShellHTML injects the boot payload into an already-loaded HTML shell. +// Dev mode uses this with Vite's in-memory index.html. +func RenderShellHTML(indexHTML []byte, payload BootPayload) ([]byte, error) { + script, err := BootPayloadScript(payload) + if err != nil { + return nil, err + } + return injectBeforeHeadClose(indexHTML, script), nil +} + +// BootPayloadScript serializes payload into an inline script safe to embed in +// HTML. encoding/json escapes '<', '>', and '&', which prevents a state value +// from closing the script tag. +func BootPayloadScript(payload BootPayload) ([]byte, error) { + data, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("marshal boot payload: %w", err) + } + script := make([]byte, 0, bytesCapacity(len(data), len(bootPayloadGlobal), 32)) + script = append(script, ""...) + return script, nil +} + +func injectBeforeHeadClose(indexHTML, script []byte) []byte { + idx := bytes.Index(indexHTML, headCloseTag) + if idx < 0 { + out := make([]byte, 0, bytesCapacity(len(indexHTML), len(script))) + out = append(out, script...) + out = append(out, indexHTML...) + return out + } + + out := make([]byte, 0, bytesCapacity(len(indexHTML), len(script))) + out = append(out, indexHTML[:idx]...) + out = append(out, script...) + out = append(out, indexHTML[idx:]...) + return out +} + +func bytesCapacity(parts ...int) int { + total := 0 + for _, part := range parts { + if part > maxInt-total { + return 0 + } + total += part + } + return total +} diff --git a/apps/backend/internal/webapp/shell_test.go b/apps/backend/internal/webapp/shell_test.go new file mode 100644 index 0000000000..55d1f0e42e --- /dev/null +++ b/apps/backend/internal/webapp/shell_test.go @@ -0,0 +1,89 @@ +package webapp + +import ( + "strings" + "testing" + "testing/fstest" +) + +func TestRenderShellInjectsBootPayloadBeforeHeadClose(t *testing.T) { + t.Parallel() + + assets := fstest.MapFS{ + "index.html": { + Data: []byte("Kandev"), + }, + } + payload := NewBootPayload( + ClassifyRoute("/t/task-1"), + RuntimeConfig{APIPrefix: "/api/v1", WebSocketPath: "/ws"}, + map[string]any{"title": "Task"}, + ) + + html, err := RenderShell(assets, "index.html", payload) + if err != nil { + t.Fatalf("RenderShell: %v", err) + } + + got := string(html) + scriptIdx := strings.Index(got, bootPayloadGlobal) + headCloseIdx := strings.Index(got, "") + if scriptIdx < 0 { + t.Fatalf("rendered shell missing boot payload script: %s", got) + } + if headCloseIdx < 0 || scriptIdx > headCloseIdx { + t.Fatalf("boot payload script should be injected before : %s", got) + } + if !strings.Contains(got, `"taskId":"task-1"`) { + t.Fatalf("rendered shell missing route params: %s", got) + } +} + +func TestBootPayloadScriptEscapesScriptTerminators(t *testing.T) { + t.Parallel() + + payload := NewBootPayload( + ClassifyRoute("/"), + RuntimeConfig{}, + map[string]any{"title": ""}, + ) + + script, err := BootPayloadScript(payload) + if err != nil { + t.Fatalf("BootPayloadScript: %v", err) + } + if strings.Contains(string(script), " + + diff --git a/apps/web/lib/api/domains/settings-api.ts b/apps/web/lib/api/domains/settings-api.ts index 34f461745f..07f09e3c9d 100644 --- a/apps/web/lib/api/domains/settings-api.ts +++ b/apps/web/lib/api/domains/settings-api.ts @@ -357,7 +357,7 @@ export async function resizeAgentLogin( * Build the bi-directional WS URL for streaming a login session. * Derives the host from the backend config (NOT window.location) so dev mode * — where the browser is on :37429 and the API is on :38429 — routes to the - * Go backend, not the Next dev server. + * Go backend, not the web dev server. */ export function agentLoginStreamUrl(sessionID: string): string { const { apiBaseUrl } = getBackendConfig(); diff --git a/apps/web/lib/api/server/office.ts b/apps/web/lib/api/server/office.ts index 7c91520b58..e93f70fd39 100644 --- a/apps/web/lib/api/server/office.ts +++ b/apps/web/lib/api/server/office.ts @@ -1,13 +1,12 @@ /** - * Server-side typed fetch for Next.js Server Components rendering + * Server-side typed fetch for legacy page loaders that still render * office pages. Mirrors the client-side `fetchJson` helper but * resolves the backend base URL from env on the server (no - * `window`-based fallback) and skips Next.js's data cache so each - * navigation produces fresh data — matching the live-data nature - * of the office surface. + * `window`-based fallback) and defaults to no-store so each navigation + * produces fresh data, matching the live-data nature of the office surface. * - * Use this in `page.tsx` files (Server Components) only. Client - * Components stick with `apps/web/lib/api/domains/*` helpers. + * Use this in `page.tsx` route loaders only. Client Components stick + * with `apps/web/lib/api/domains/*` helpers. */ import { getBackendConfig } from "@/lib/config"; @@ -20,9 +19,9 @@ export type ServerFetchOptions = { /** Extra request headers (Content-Type is set automatically). */ headers?: Record; /** - * Pass `true` to opt into Next.js caching of this request. Default - * is `false` because office data is live; pages should re-fetch - * on every server render. + * Pass `true` to opt into the platform fetch cache for this request. + * Default is `false` because office data is live; pages should re-fetch + * on every route render. */ cache?: boolean; }; @@ -45,8 +44,8 @@ const OFFICE_BASE = "/api/v1/office"; * relative to `/api/v1/office` — pass e.g. `"/agents/123/summary"`. * Returns the parsed JSON body, typed by the caller's generic. * - * Throws `ServerFetchError` on non-2xx responses; let it bubble so - * Next.js renders an error boundary rather than swallowing it. + * Throws `ServerFetchError` on non-2xx responses; callers should let it + * bubble to the route error surface rather than swallowing it. */ export async function serverFetchOfficeJson( path: string, diff --git a/apps/web/lib/config.test.ts b/apps/web/lib/config.test.ts index 67ed1567aa..d73e2d4526 100644 --- a/apps/web/lib/config.test.ts +++ b/apps/web/lib/config.test.ts @@ -11,21 +11,21 @@ describe("isDebugUI", () => { }); it("is true when window.__KANDEV_DEBUG is set at runtime", async () => { - vi.stubEnv("NEXT_PUBLIC_KANDEV_DEBUG", ""); + vi.stubEnv("VITE_KANDEV_DEBUG", ""); vi.stubGlobal("window", { __KANDEV_DEBUG: true }); const { isDebugUI } = await import("./config"); expect(isDebugUI()).toBe(true); }); it("is false in production with no flags", async () => { - vi.stubEnv("NEXT_PUBLIC_KANDEV_DEBUG", ""); + vi.stubEnv("VITE_KANDEV_DEBUG", ""); vi.stubGlobal("window", {}); const { isDebugUI } = await import("./config"); expect(isDebugUI()).toBe(false); }); - it("is true when NEXT_PUBLIC_KANDEV_DEBUG=true", async () => { - vi.stubEnv("NEXT_PUBLIC_KANDEV_DEBUG", "true"); + it("is true when VITE_KANDEV_DEBUG=true", async () => { + vi.stubEnv("VITE_KANDEV_DEBUG", "true"); vi.stubGlobal("window", {}); const { isDebugUI } = await import("./config"); expect(isDebugUI()).toBe(true); diff --git a/apps/web/lib/config.ts b/apps/web/lib/config.ts index effb337279..17c87ed65d 100644 --- a/apps/web/lib/config.ts +++ b/apps/web/lib/config.ts @@ -2,7 +2,7 @@ export type AppConfig = { apiBaseUrl: string; }; -// Server-side default (used during SSR - always localhost since SSR runs on same machine) +// Non-browser default used by route/action helpers running beside the backend. const DEFAULT_API_BASE_URL = "http://localhost:38429"; let debugUiCached: boolean | undefined; @@ -10,8 +10,11 @@ let debugUiCached: boolean | undefined; /** Whether debug-only UI (poll badges, debug overlay) should render. */ export function isDebugUI(): boolean { if (debugUiCached !== undefined) return debugUiCached; + const env = getViteEnv(); debugUiCached = - process.env.NEXT_PUBLIC_KANDEV_DEBUG === "true" || + env.VITE_KANDEV_DEBUG === "true" || + (typeof process !== "undefined" && + (process.env.KANDEV_DEBUG === "true" || process.env.VITE_KANDEV_DEBUG === "true")) || (typeof window !== "undefined" && window.__KANDEV_DEBUG === true); return debugUiCached; } @@ -26,15 +29,25 @@ export function getBackendConfig(): AppConfig { // Client-side URL resolution: // 1. Port-based URL via __KANDEV_API_PORT (dev mode: browser on :37429, API on :38429) - // 2. Same-origin (production: Go reverse-proxies Next.js on single port) + // 2. Vite env port (Vite dev server injects VITE_* env at build time) + // 3. Same-origin (production: Go serves the SPA and API on one port) // Works for any hosting scenario: localhost, custom domain, Tailscale, etc. - if (window.__KANDEV_API_PORT) { - const port = parseInt(window.__KANDEV_API_PORT, 10); - if (Number.isInteger(port) && port > 0 && port <= 65535) { - const protocol = window.location.protocol; - return { apiBaseUrl: `${protocol}//${window.location.hostname}:${port}` }; - } + const env = getViteEnv(); + const port = readPort(window.__KANDEV_API_PORT) ?? readPort(env.VITE_KANDEV_API_PORT); + if (port) { + const protocol = window.location.protocol; + return { apiBaseUrl: `${protocol}//${window.location.hostname}:${port}` }; } return { apiBaseUrl: window.location.origin }; } + +function readPort(value: string | undefined): number | null { + if (!value) return null; + const port = parseInt(value, 10); + return Number.isInteger(port) && port > 0 && port <= 65535 ? port : null; +} + +function getViteEnv(): Record { + return (import.meta as unknown as { env?: Record }).env ?? {}; +} diff --git a/apps/web/lib/debug/log.test.ts b/apps/web/lib/debug/log.test.ts index e9c0c737c3..9528b35517 100644 --- a/apps/web/lib/debug/log.test.ts +++ b/apps/web/lib/debug/log.test.ts @@ -178,7 +178,7 @@ describe("isDebug", () => { it("is true in a production build when window.__KANDEV_DEBUG is set", async () => { // Simulates `make start-debug`: production bundle, runtime window flag. vi.stubEnv("NODE_ENV", "production"); - vi.stubEnv("NEXT_PUBLIC_KANDEV_DEBUG", ""); + vi.stubEnv("VITE_KANDEV_DEBUG", ""); vi.stubGlobal("window", { __KANDEV_DEBUG: true }); const { isDebug } = await import("./log"); expect(isDebug()).toBe(true); @@ -186,15 +186,15 @@ describe("isDebug", () => { it("is false in a production build with no flag set", async () => { vi.stubEnv("NODE_ENV", "production"); - vi.stubEnv("NEXT_PUBLIC_KANDEV_DEBUG", ""); + vi.stubEnv("VITE_KANDEV_DEBUG", ""); vi.stubGlobal("window", {}); const { isDebug } = await import("./log"); expect(isDebug()).toBe(false); }); - it("is true when NEXT_PUBLIC_KANDEV_DEBUG=true at build time", async () => { + it("is true when VITE_KANDEV_DEBUG=true at build time", async () => { vi.stubEnv("NODE_ENV", "production"); - vi.stubEnv("NEXT_PUBLIC_KANDEV_DEBUG", "true"); + vi.stubEnv("VITE_KANDEV_DEBUG", "true"); vi.stubGlobal("window", {}); const { isDebug } = await import("./log"); expect(isDebug()).toBe(true); diff --git a/apps/web/lib/debug/log.ts b/apps/web/lib/debug/log.ts index 7814499e38..ea57ba9523 100644 --- a/apps/web/lib/debug/log.ts +++ b/apps/web/lib/debug/log.ts @@ -3,7 +3,7 @@ * * Active when any of the following is true: * - `NODE_ENV !== "production"` (i.e. `make dev`) - * - `NEXT_PUBLIC_KANDEV_DEBUG=true` at build time (inlined into the bundle) + * - `VITE_KANDEV_DEBUG=true` at build time (inlined into the bundle) * - `window.__KANDEV_DEBUG === true` at runtime (set by `layout.tsx` when * the server-side env var is present, e.g. `make start-debug`) * @@ -202,13 +202,38 @@ let debugCached: boolean | undefined; /** Whether namespaced debug logging is active. Evaluated lazily so production start-debug works. */ export function isDebug(): boolean { if (debugCached !== undefined) return debugCached; + const env = getViteEnv(); + const nodeEnv = typeof process !== "undefined" ? process.env.NODE_ENV : undefined; + const viteDev = env.DEV === true && nodeEnv !== "production"; + const processDev = typeof process !== "undefined" && process.env.NODE_ENV !== "production"; + const processDebug = + typeof process !== "undefined" && + (process.env.KANDEV_DEBUG === "true" || process.env.VITE_KANDEV_DEBUG === "true"); debugCached = - process.env.NODE_ENV !== "production" || - process.env.NEXT_PUBLIC_KANDEV_DEBUG === "true" || + viteDev || + processDev || + env.VITE_KANDEV_DEBUG === "true" || + processDebug || (typeof window !== "undefined" && window.__KANDEV_DEBUG === true); return debugCached; } +function getViteEnv(): { + DEV?: boolean; + VITE_KANDEV_DEBUG?: string; +} { + return ( + ( + import.meta as unknown as { + env?: { + DEV?: boolean; + VITE_KANDEV_DEBUG?: string; + }; + } + ).env ?? {} + ); +} + const BARE_VALUE_RE = /^[A-Za-z0-9_\-:./@+]+$/; function formatValue(value: unknown): string { diff --git a/apps/web/lib/layout/read-layout-defaults.ts b/apps/web/lib/layout/read-layout-defaults.ts index a3bab7255d..db1c99cd74 100644 --- a/apps/web/lib/layout/read-layout-defaults.ts +++ b/apps/web/lib/layout/read-layout-defaults.ts @@ -1,8 +1,8 @@ -import { cookies } from "next/headers"; +import { readCookies } from "@/lib/server/cookies"; import type { Layout } from "react-resizable-panels"; export const readLayoutDefaults = async () => { - const cookieStore = await cookies(); + const cookieStore = await readCookies(); const defaults: Record = {}; const cookieList = typeof (cookieStore as { getAll?: () => { name: string; value: string }[] }).getAll === diff --git a/apps/web/lib/routing/client-dynamic.ts b/apps/web/lib/routing/client-dynamic.ts new file mode 100644 index 0000000000..a1b78fd4e8 --- /dev/null +++ b/apps/web/lib/routing/client-dynamic.ts @@ -0,0 +1,29 @@ +import { Suspense, createElement, lazy, type ComponentType, type ReactNode } from "react"; + +type DynamicModule

= { default: ComponentType

} | ComponentType

; + +type DynamicOptions = { + loading?: () => ReactNode; + ssr?: boolean; +}; + +export default function dynamic

( + loader: () => Promise>, + options: DynamicOptions = {}, +): ComponentType

{ + const LazyComponent = lazy(async () => { + const mod = await loader(); + return "default" in mod ? mod : { default: mod }; + }); + + const DynamicComponent = (props: P) => + createElement( + Suspense, + { fallback: options.loading?.() ?? null }, + createElement(LazyComponent as ComponentType>, { + ...(props as Record), + }), + ); + DynamicComponent.displayName = "DynamicComponent"; + return DynamicComponent; +} diff --git a/apps/web/lib/routing/client-route-helpers.test.ts b/apps/web/lib/routing/client-route-helpers.test.ts new file mode 100644 index 0000000000..f1354a7f4f --- /dev/null +++ b/apps/web/lib/routing/client-route-helpers.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; + +import { toRouteErrorState } from "./client-route-helpers"; + +describe("toRouteErrorState", () => { + it("uses the error message when available", () => { + expect(toRouteErrorState(new Error("network down"))).toEqual({ + status: "error", + message: "network down", + }); + }); + + it("falls back for non-Error values", () => { + expect(toRouteErrorState("offline", "Failed to load routine")).toEqual({ + status: "error", + message: "Failed to load routine", + }); + }); +}); diff --git a/apps/web/lib/routing/client-route-helpers.ts b/apps/web/lib/routing/client-route-helpers.ts new file mode 100644 index 0000000000..138cecf59f --- /dev/null +++ b/apps/web/lib/routing/client-route-helpers.ts @@ -0,0 +1,14 @@ +export type LoadState = + | { status: "loading" } + | { status: "ready"; data: T } + | { status: "error"; message: string }; + +export function toRouteErrorState( + error: unknown, + fallbackMessage = "Failed to load route", +): LoadState { + return { + status: "error", + message: error instanceof Error ? error.message : fallbackMessage, + }; +} diff --git a/apps/web/lib/routing/client-router.test.ts b/apps/web/lib/routing/client-router.test.ts new file mode 100644 index 0000000000..05ef244cee --- /dev/null +++ b/apps/web/lib/routing/client-router.test.ts @@ -0,0 +1,61 @@ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useParams, usePathname, useRouter, useSearchParams } from "./client-router"; + +function setLocation(path: string) { + window.history.replaceState({}, "", path); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("client router adapter", () => { + it("pushes and replaces browser history routes", () => { + setLocation("/"); + const scrollTo = vi.fn(); + vi.stubGlobal("scrollTo", scrollTo); + const { result } = renderHook(() => useRouter()); + + act(() => result.current.push("/tasks")); + expect(window.location.pathname).toBe("/tasks"); + expect(scrollTo).toHaveBeenCalledWith(0, 0); + + act(() => result.current.replace("/stats?range=7d", { scroll: false })); + expect(window.location.pathname).toBe("/stats"); + expect(window.location.search).toBe("?range=7d"); + expect(scrollTo).toHaveBeenCalledTimes(1); + }); + + it("returns current path and search params", () => { + setLocation("/stats?range=7d"); + + expect(renderHook(() => usePathname()).result.current).toBe("/stats"); + expect(renderHook(() => useSearchParams()).result.current.get("range")).toBe("7d"); + }); + + it("derives known route params from the current path", () => { + setLocation("/t/task-123"); + + expect(renderHook(() => useParams()).result.current).toEqual({ taskId: "task-123" }); + }); + + it("derives nested settings agent profile params", () => { + setLocation("/settings/agents/mock-agent/profiles/profile-123"); + + expect(renderHook(() => useParams()).result.current).toMatchObject({ + agentId: "mock-agent", + profileId: "profile-123", + }); + }); + + it("refreshes by reloading the document", () => { + const reload = vi.fn(); + vi.stubGlobal("location", { ...window.location, reload }); + const { result } = renderHook(() => useRouter()); + + act(() => result.current.refresh()); + + expect(reload).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/web/lib/routing/client-router.ts b/apps/web/lib/routing/client-router.ts new file mode 100644 index 0000000000..ccabadf07f --- /dev/null +++ b/apps/web/lib/routing/client-router.ts @@ -0,0 +1,133 @@ +"use client"; + +import { useMemo, useSyncExternalStore } from "react"; + +import { LOCATION_CHANGE_EVENT } from "./navigation-event"; + +type NavigateOptions = { + scroll?: boolean; +}; + +export type AppRouter = { + push: (href: string, options?: NavigateOptions) => void; + replace: (href: string, options?: NavigateOptions) => void; + refresh: () => void; + back: () => void; + forward: () => void; + prefetch: (_href: string) => void; +}; + +export function useRouter(): AppRouter { + return useMemo( + () => ({ + push: (href, options) => navigate("push", href, options), + replace: (href, options) => navigate("replace", href, options), + refresh: () => window.location.reload(), + back: () => window.history.back(), + forward: () => window.history.forward(), + prefetch: () => undefined, + }), + [], + ); +} + +export function usePathname(): string { + return parseLocationKey(useLocationKey()).pathname; +} + +export function useSearchParams(): URLSearchParams { + const { search } = parseLocationKey(useLocationKey()); + return useMemo(() => new URLSearchParams(search), [search]); +} + +export function useParams(): Record { + const { pathname } = parseLocationKey(useLocationKey()); + return useMemo(() => paramsForPath(pathname), [pathname]); +} + +function navigate(mode: "push" | "replace", href: string, options?: NavigateOptions): void { + if (mode === "push") { + window.history.pushState({}, "", href); + } else { + window.history.replaceState({}, "", href); + } + if (options?.scroll !== false) { + window.scrollTo(0, 0); + } + dispatchLocationChange(); +} + +function useLocationKey(): string { + return useSyncExternalStore(subscribeLocation, getLocationKey, getServerLocationKey); +} + +type LocationSnapshot = { + pathname: string; + search: string; +}; + +function subscribeLocation(callback: () => void): () => void { + window.addEventListener("popstate", callback); + window.addEventListener(LOCATION_CHANGE_EVENT, callback); + return () => { + window.removeEventListener("popstate", callback); + window.removeEventListener(LOCATION_CHANGE_EVENT, callback); + }; +} + +function getLocationKey(): string { + return `${window.location.pathname}\n${window.location.search}`; +} + +function getServerLocationKey(): string { + return "/\n"; +} + +function parseLocationKey(key: string): LocationSnapshot { + const [pathname, search = ""] = key.split("\n", 2); + return { pathname, search }; +} + +function dispatchLocationChange(): void { + window.dispatchEvent(new Event(LOCATION_CHANGE_EVENT)); +} + +function paramsForPath(pathname: string): Record { + const segments = pathname.split("/").filter(Boolean).map(decodeURIComponent); + const params: Record = {}; + + assignSingle(params, "taskId", segments, ["t"]); + assignSingle(params, "id", segments, ["office", "tasks"]); + assignSingle(params, "id", segments, ["office", "projects"]); + assignSingle(params, "id", segments, ["office", "routines"]); + assignSingle(params, "id", segments, ["office", "agents"]); + assignSingle(params, "runId", segments, ["office", "agents", "*", "runs"]); + assignSingle(params, "agentId", segments, ["settings", "agents"]); + assignSingle(params, "profileId", segments, ["settings", "agents", "*", "profiles"]); + assignSingle(params, "profileId", segments, ["settings", "executors"]); + assignSingle(params, "executorId", segments, ["settings", "executors", "ssh"]); + assignSingle(params, "type", segments, ["settings", "executors", "new"]); + assignSingle(params, "id", segments, ["settings", "workspace"]); + assignSingle(params, "automationId", segments, ["settings", "workspace", "*", "automations"]); + + return params; +} + +function assignSingle( + params: Record, + key: string, + segments: string[], + pattern: string[], +): void { + const value = matchSingleParam(segments, pattern); + if (value) params[key] = value; +} + +function matchSingleParam(segments: string[], pattern: string[]): string | null { + if (segments.length <= pattern.length) return null; + for (let i = 0; i < pattern.length; i++) { + const expected = pattern[i]; + if (expected !== "*" && segments[i] !== expected) return null; + } + return segments[pattern.length] ?? null; +} diff --git a/apps/web/lib/routing/kanban-route-hydration.test.ts b/apps/web/lib/routing/kanban-route-hydration.test.ts new file mode 100644 index 0000000000..661e8c4ea2 --- /dev/null +++ b/apps/web/lib/routing/kanban-route-hydration.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; + +import { hasHydratedKanbanRouteState } from "./kanban-route-hydration"; +import type { AppState } from "@/lib/state/store"; + +type HydrationState = Pick; + +function state(overrides: Partial = {}): HydrationState { + return { + workspaces: { activeId: "ws-1", items: [] }, + workflows: { + activeId: "wf-1", + items: [{ id: "wf-1", workspaceId: "ws-1", name: "Development" }], + }, + kanban: { workflowId: "wf-1", steps: [], tasks: [] }, + kanbanMulti: { + snapshots: { + "wf-1": { workflowId: "wf-1", workflowName: "Development", steps: [], tasks: [] }, + }, + isLoading: false, + }, + ...overrides, + }; +} + +describe("hasHydratedKanbanRouteState", () => { + it("accepts boot-hydrated state for the active workspace and workflow", () => { + expect(hasHydratedKanbanRouteState(state(), {})).toBe(true); + expect(hasHydratedKanbanRouteState(state(), { workspaceId: "ws-1", workflowId: "wf-1" })).toBe( + true, + ); + }); + + it("rejects missing or mismatched route state so the client can fetch", () => { + expect( + hasHydratedKanbanRouteState(state({ workspaces: { activeId: null, items: [] } }), {}), + ).toBe(false); + expect(hasHydratedKanbanRouteState(state(), { workspaceId: "ws-2" })).toBe(false); + expect(hasHydratedKanbanRouteState(state(), { workflowId: "wf-2" })).toBe(false); + expect( + hasHydratedKanbanRouteState( + state({ + kanban: { workflowId: null, steps: [], tasks: [] }, + kanbanMulti: { snapshots: {}, isLoading: false }, + }), + {}, + ), + ).toBe(false); + }); +}); diff --git a/apps/web/lib/routing/kanban-route-hydration.ts b/apps/web/lib/routing/kanban-route-hydration.ts new file mode 100644 index 0000000000..9e5b837aab --- /dev/null +++ b/apps/web/lib/routing/kanban-route-hydration.ts @@ -0,0 +1,35 @@ +import type { AppState } from "@/lib/state/store"; + +type KanbanRouteHydrationState = Pick< + AppState, + "kanban" | "kanbanMulti" | "workflows" | "workspaces" +>; + +export type KanbanRouteSelection = { + workspaceId?: string; + workflowId?: string; +}; + +export function hasHydratedKanbanRouteState( + state: KanbanRouteHydrationState, + route: KanbanRouteSelection, +): boolean { + const activeWorkspaceId = state.workspaces.activeId; + if (!activeWorkspaceId) return false; + if (route.workspaceId && route.workspaceId !== activeWorkspaceId) return false; + + const workspaceWorkflows = state.workflows.items.filter( + (workflow) => workflow.workspaceId === activeWorkspaceId, + ); + if (workspaceWorkflows.length === 0) return false; + if ( + route.workflowId && + !workspaceWorkflows.some((workflow) => workflow.id === route.workflowId) + ) { + return false; + } + + const workflowId = route.workflowId ?? state.workflows.activeId; + if (!workflowId) return false; + return Boolean(state.kanbanMulti.snapshots[workflowId] || state.kanban.workflowId === workflowId); +} diff --git a/apps/web/lib/routing/navigation-event.ts b/apps/web/lib/routing/navigation-event.ts new file mode 100644 index 0000000000..73e7eed4fc --- /dev/null +++ b/apps/web/lib/routing/navigation-event.ts @@ -0,0 +1 @@ +export const LOCATION_CHANGE_EVENT = "kandev:navigation"; diff --git a/apps/web/lib/routing/route-bootstrap.test.ts b/apps/web/lib/routing/route-bootstrap.test.ts new file mode 100644 index 0000000000..ca463db1f1 --- /dev/null +++ b/apps/web/lib/routing/route-bootstrap.test.ts @@ -0,0 +1,59 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import { mapWorkspaceItem, readActiveWorkspaceCookie, readCookie } from "./route-bootstrap"; +import type { ListWorkspacesResponse } from "@/lib/types/http"; + +type WorkspaceItem = ListWorkspacesResponse["workspaces"][number]; + +beforeEach(() => { + document.cookie = "kandev-active-workspace=; path=/; max-age=0"; + document.cookie = "office-active-workspace=; path=/; max-age=0"; +}); + +describe("mapWorkspaceItem", () => { + it("normalizes optional workspace fields for store hydration", () => { + expect( + mapWorkspaceItem({ + id: "ws-1", + name: "Workspace", + owner_id: "owner-1", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-02T00:00:00Z", + } as WorkspaceItem), + ).toEqual({ + id: "ws-1", + name: "Workspace", + description: null, + owner_id: "owner-1", + default_executor_id: null, + default_environment_id: null, + default_agent_profile_id: null, + default_config_agent_profile_id: null, + office_workflow_id: null, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-02T00:00:00Z", + }); + }); +}); + +describe("readCookie", () => { + it("reads encoded cookie values by encoded cookie name", () => { + document.cookie = `${encodeURIComponent("office-active-workspace")}=${encodeURIComponent("ws 1/2")}`; + + expect(readCookie("office-active-workspace")).toBe("ws 1/2"); + expect(readCookie("missing")).toBeNull(); + }); + + it("prefers the general active workspace cookie over the legacy office cookie", () => { + document.cookie = "office-active-workspace=office-1; path=/"; + document.cookie = "kandev-active-workspace=kanban-1; path=/"; + + expect(readActiveWorkspaceCookie()).toBe("kanban-1"); + }); + + it("falls back to the legacy office active workspace cookie", () => { + document.cookie = "office-active-workspace=office-1; path=/"; + + expect(readActiveWorkspaceCookie()).toBe("office-1"); + }); +}); diff --git a/apps/web/lib/routing/route-bootstrap.ts b/apps/web/lib/routing/route-bootstrap.ts new file mode 100644 index 0000000000..304120152c --- /dev/null +++ b/apps/web/lib/routing/route-bootstrap.ts @@ -0,0 +1,38 @@ +import type { WorkspaceState } from "@/lib/state/slices/workspace/types"; +import type { ListWorkspacesResponse } from "@/lib/types/http"; + +export const ACTIVE_WORKSPACE_COOKIE = "kandev-active-workspace"; +export const LEGACY_OFFICE_ACTIVE_WORKSPACE_COOKIE = "office-active-workspace"; + +type WorkspaceItem = ListWorkspacesResponse["workspaces"][number]; + +export function mapWorkspaceItem(ws: WorkspaceItem): WorkspaceState["items"][number] { + return { + id: ws.id, + name: ws.name, + description: ws.description ?? null, + owner_id: ws.owner_id, + default_executor_id: ws.default_executor_id ?? null, + default_environment_id: ws.default_environment_id ?? null, + default_agent_profile_id: ws.default_agent_profile_id ?? null, + default_config_agent_profile_id: ws.default_config_agent_profile_id ?? null, + office_workflow_id: ws.office_workflow_id ?? null, + created_at: ws.created_at, + updated_at: ws.updated_at, + }; +} + +export function readCookie(name: string): string | null { + if (typeof document === "undefined") return null; + const encodedName = `${encodeURIComponent(name)}=`; + const entries = document.cookie + .split(";") + .map((part) => part.trim()) + .filter((part) => part.startsWith(encodedName)); + const entry = entries[entries.length - 1]; + return entry ? decodeURIComponent(entry.slice(encodedName.length)) : null; +} + +export function readActiveWorkspaceCookie(): string | null { + return readCookie(ACTIVE_WORKSPACE_COOKIE) || readCookie(LEGACY_OFFICE_ACTIVE_WORKSPACE_COOKIE); +} diff --git a/apps/web/lib/routing/server-navigation.ts b/apps/web/lib/routing/server-navigation.ts new file mode 100644 index 0000000000..f19ad28ea7 --- /dev/null +++ b/apps/web/lib/routing/server-navigation.ts @@ -0,0 +1,10 @@ +export function redirect(url: string): never { + if (typeof window !== "undefined") { + window.location.assign(url); + } + throw new Error(`Redirect to ${url}`); +} + +export function notFound(): never { + throw new Error("Route not found"); +} diff --git a/apps/web/lib/server/cookies.ts b/apps/web/lib/server/cookies.ts new file mode 100644 index 0000000000..ffb23c4b8c --- /dev/null +++ b/apps/web/lib/server/cookies.ts @@ -0,0 +1,49 @@ +type CookieValue = { + name: string; + value: string; +}; + +export type CookieStore = { + get(name: string): CookieValue | undefined; + getAll(): CookieValue[]; + toString(): string; +}; + +function safeDecode(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +function parseCookieHeader(raw: string): CookieValue[] { + if (!raw) return []; + return raw + .split(";") + .map((entry) => entry.trim()) + .filter(Boolean) + .map((entry) => { + const [name, ...rest] = entry.split("="); + return { + name: safeDecode(name), + value: safeDecode(rest.join("=")), + }; + }); +} + +export async function readCookies(): Promise { + const raw = typeof document === "undefined" ? "" : document.cookie; + const cookies = parseCookieHeader(raw); + return { + get(name: string) { + return cookies.find((cookie) => cookie.name === name); + }, + getAll() { + return cookies; + }, + toString() { + return raw; + }, + }; +} diff --git a/apps/web/lib/ws/handlers/agent-session.test.ts b/apps/web/lib/ws/handlers/agent-session.test.ts index 370d25fbaa..d19e951941 100644 --- a/apps/web/lib/ws/handlers/agent-session.test.ts +++ b/apps/web/lib/ws/handlers/agent-session.test.ts @@ -547,7 +547,7 @@ describe("session.state_changed → agentctl ready fallback", () => { expect(setSessionAgentctlStatus).not.toHaveBeenCalled(); }); - it("seeds env mapping from agentctl_starting payload via upsertTaskSessionFromEvent", () => { + it("seeds env mapping and workspace path from agentctl_starting payload", () => { const upsertTaskSessionFromEvent = vi.fn(); const store = makeStore({ taskSessions: { @@ -569,12 +569,17 @@ describe("session.state_changed → agentctl ready fallback", () => { session_id: "s-1", agent_execution_id: "ae-1", task_environment_id: "env-1", + worktree_path: "/tmp/kandev/tasks/ws/task-1", }, }); expect(upsertTaskSessionFromEvent).toHaveBeenCalledWith( "t-1", - expect.objectContaining({ id: "s-1", task_environment_id: "env-1" }), + expect.objectContaining({ + id: "s-1", + task_environment_id: "env-1", + worktree_path: "/tmp/kandev/tasks/ws/task-1", + }), ); }); diff --git a/apps/web/lib/ws/handlers/agent-session.ts b/apps/web/lib/ws/handlers/agent-session.ts index d9e9b4cd0b..e3f0287b41 100644 --- a/apps/web/lib/ws/handlers/agent-session.ts +++ b/apps/web/lib/ws/handlers/agent-session.ts @@ -261,6 +261,9 @@ function syncEnvFromAgentctlPayload( started_at: existing?.started_at ?? "", updated_at: existing?.updated_at ?? "", task_environment_id: envId, + worktree_id: payload.worktree_id, + worktree_path: payload.worktree_path, + worktree_branch: payload.worktree_branch, }); } diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts deleted file mode 100644 index 478fa35c79..0000000000 --- a/apps/web/next.config.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { NextConfig } from "next"; - -// The kandev CLI auto-populates NEXT_ALLOWED_DEV_ORIGINS with the host's -// non-loopback addresses (see apps/cli/src/shared.ts) so LAN / Tailscale / -// SSH-forwarded clients pass Next.js's allowedDevOrigins check. Users running -// `next dev` directly can still extend the list manually via that env var. -const defaultDevOrigins = ["localhost", "localhost:37429"]; -const extraDevOrigins = process.env.NEXT_ALLOWED_DEV_ORIGINS - ? process.env.NEXT_ALLOWED_DEV_ORIGINS.split(",") - .map((s) => s.trim()) - .filter(Boolean) - : []; - -const nextConfig: NextConfig = { - output: "standalone", - allowedDevOrigins: [...defaultDevOrigins, ...extraDevOrigins], - transpilePackages: ["@kandev/ui", "@kandev/theme", "@kandev/types"], - turbopack: {}, -}; - -export default nextConfig; diff --git a/apps/web/package.json b/apps/web/package.json index 91777fe913..14169f652c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -4,13 +4,15 @@ "private": true, "scripts": { "predev": "node scripts/generate-release-notes.mjs && node scripts/generate-changelog.mjs", - "dev": "next dev --hostname 0.0.0.0", + "dev": "vite --host 0.0.0.0", "prebuild": "node scripts/generate-release-notes.mjs && node scripts/generate-changelog.mjs", - "build": "cross-env NODE_ENV=production next build", - "start": "cross-env NODE_ENV=production next start", + "build": "vite build", + "build:vite": "vite build", + "start": "vite preview --host 0.0.0.0", "lint": "eslint --max-warnings 0", "pretypecheck": "node scripts/generate-release-notes.mjs && node scripts/generate-changelog.mjs", "typecheck": "tsc --noEmit", + "pretest": "node scripts/generate-release-notes.mjs && node scripts/generate-changelog.mjs", "test": "vitest run", "e2e": "playwright test --config e2e/playwright.config.ts", "e2e:run": "e2e/scripts/run-e2e.sh", @@ -99,8 +101,6 @@ "lowlight": "^3.3.0", "mermaid": "^11.12.2", "monaco-editor": "^0.55.1", - "next": "16.1.7", - "next-themes": "^0.4.6", "radix-ui": "^1.4.3", "react": "19.2.3", "react-day-picker": "^9.13.0", @@ -117,7 +117,6 @@ "shadcn": "^3.6.3", "shiki": "^3.22.0", "sonner": "^2.0.7", - "styled-jsx": "5.1.6", "tailwind-merge": "^3.4.0", "tiptap-markdown": "^0.9.0", "tw-animate-css": "^1.4.0", @@ -125,6 +124,7 @@ "zustand": "^5.0.8" }, "devDependencies": { + "@eslint/js": "^9.39.2", "@playwright/test": "^1.58.2", "@tailwindcss/postcss": "^4", "@testing-library/react": "^16.3.2", @@ -132,15 +132,18 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@vitejs/plugin-react": "^6.0.2", "cross-env": "^7.0.3", "eslint": "^9", - "eslint-config-next": "16.1.1", + "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-sonarjs": "^3.0.7", "eslint-plugin-unused-imports": "^4.4.1", "happy-dom": "^20.8.9", "tailwindcss": "^4", "tsx": "^4.21.0", "typescript": "^5", + "typescript-eslint": "^8.53.0", + "vite": "^8.0.16", "vitest": "^1.6.0" } } diff --git a/apps/web/src/app-shell.tsx b/apps/web/src/app-shell.tsx new file mode 100644 index 0000000000..ed6a71de2a --- /dev/null +++ b/apps/web/src/app-shell.tsx @@ -0,0 +1,51 @@ +import { AppSidebar } from "@/components/app-sidebar/app-sidebar"; +import { CommandPanel } from "@/components/command-panel"; +import { ConfigChatProvider } from "@/components/config-chat/config-chat-provider"; +import { DiffWorkerPoolProvider } from "@/components/diff-worker-pool-provider"; +import { GlobalCommands } from "@/components/global-commands"; +import { LogBufferBridge } from "@/components/log-buffer-bridge"; +import { QuickChatProvider } from "@/components/quick-chat/quick-chat-provider"; +import { RecentTaskSwitcher } from "@/components/task/recent-task-switcher"; +import { SessionFailureToastBridge } from "@/components/session-failure-toast-bridge"; +import { SidebarViewsSyncBridge } from "@/components/sidebar-views-sync-bridge"; +import { ThemeProvider } from "@/components/theme-provider"; +import { ToastProvider } from "@/components/toast-provider"; +import { WebSocketConnector } from "@/components/ws-connector"; +import { CommandRegistryProvider } from "@/lib/commands/command-registry"; +import { Toaster as SonnerToaster } from "@kandev/ui/sonner"; +import { TooltipProvider } from "@kandev/ui/tooltip"; + +type AppShellProps = { + children: React.ReactNode; +}; + +export function AppShell({ children }: AppShellProps) { + return ( + + + + + + + + + + + + + + + +

+ +
{children}
+
+ + + + + + + + ); +} diff --git a/apps/web/src/boot-payload.test.ts b/apps/web/src/boot-payload.test.ts new file mode 100644 index 0000000000..1f21ed6948 --- /dev/null +++ b/apps/web/src/boot-payload.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it, vi } from "vitest"; +import { loadBootPayload, readBootPayload } from "./boot-payload"; + +describe("readBootPayload", () => { + it("returns an empty initial state when Go has not injected boot data yet", () => { + const win = {} as Window; + + expect(readBootPayload(win)).toEqual({ initialState: {} }); + }); + + it("normalizes the injected Go boot payload shape", () => { + const win = { + __KANDEV_BOOT_PAYLOAD__: { + version: 1, + route: { + kind: "spa", + route: "taskDetail", + path: "/t/task-1", + params: { taskId: "task-1" }, + }, + runtime: { + apiPrefix: "/api/v1", + webSocketPath: "/ws", + }, + initialState: { + tasks: { activeTaskId: "task-1" }, + }, + }, + } as unknown as Window; + + expect(readBootPayload(win)).toMatchObject({ + version: 1, + route: { + kind: "spa", + route: "taskDetail", + path: "/t/task-1", + params: { taskId: "task-1" }, + }, + runtime: { + apiPrefix: "/api/v1", + webSocketPath: "/ws", + }, + initialState: { + tasks: { activeTaskId: "task-1" }, + }, + }); + }); + + it("drops invalid route params instead of exposing mixed values", () => { + const win = { + __KANDEV_BOOT_PAYLOAD__: { + route: { + params: { taskId: "task-1", bad: 3 }, + }, + }, + } as unknown as Window; + + expect(readBootPayload(win).route?.params).toBeUndefined(); + }); +}); + +describe("loadBootPayload", () => { + it("uses the injected Go boot payload without fetching", async () => { + const win = Object.assign(new Window(), { + __KANDEV_BOOT_PAYLOAD__: { version: 1, initialState: { features: { office: true } } }, + }) as Window; + const fetcher = vi.fn(); + + await expect(loadBootPayload(win, fetcher)).resolves.toMatchObject({ + initialState: { features: { office: true } }, + }); + expect(fetcher).not.toHaveBeenCalled(); + }); + + it("fetches app-state before mount when no boot payload was injected", async () => { + const win = new Window(); + Object.defineProperty(win, "location", { + value: { pathname: "/", search: "?workspaceId=ws-1" }, + }); + const fetcher = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ version: 1, initialState: { workflows: { activeId: "wf-1" } } }), + }); + + await expect(loadBootPayload(win, fetcher)).resolves.toMatchObject({ + initialState: { workflows: { activeId: "wf-1" } }, + }); + expect(fetcher).toHaveBeenCalledWith( + expect.stringContaining("/api/v1/app-state?path=%2F%3FworkspaceId%3Dws-1"), + expect.objectContaining({ cache: "no-store", credentials: "include" }), + ); + }); +}); diff --git a/apps/web/src/boot-payload.ts b/apps/web/src/boot-payload.ts new file mode 100644 index 0000000000..1bbc2ea906 --- /dev/null +++ b/apps/web/src/boot-payload.ts @@ -0,0 +1,111 @@ +import type { AppState } from "@/lib/state/store"; +import { getBackendConfig } from "@/lib/config"; +import type { FetchedSessionData } from "@/lib/ssr/session-page-state"; +import type { Repository, Task, Workflow, WorkflowStep } from "@/lib/types/http"; + +export type BootRoute = { + kind?: string; + route?: string; + path?: string; + params?: Record; +}; + +export type BootRuntime = { + apiPrefix?: string; + webSocketPath?: string; +}; + +export type BootRouteData = { + taskDetail?: FetchedSessionData; + routeContext?: { + activeWorkspaceId?: string | null; + workflows?: Workflow[]; + steps?: WorkflowStep[]; + repositories?: Repository[]; + }; + tasksPage?: { + activeWorkspaceId?: string | null; + workflows?: Workflow[]; + steps?: WorkflowStep[]; + repositories?: Repository[]; + tasks?: Task[]; + total?: number; + }; +}; + +export type BootPayload = { + version?: number; + route?: BootRoute; + runtime?: BootRuntime; + initialState?: Partial; + routeData?: BootRouteData; +}; + +type BootWindow = Window & { + __KANDEV_BOOT_PAYLOAD__?: unknown; +}; + +export function readBootPayload(win: Window = window): BootPayload { + const payload = (win as BootWindow).__KANDEV_BOOT_PAYLOAD__; + if (!isRecord(payload)) return { initialState: {} }; + + return { + version: typeof payload.version === "number" ? payload.version : undefined, + route: isRecord(payload.route) ? readRoute(payload.route) : undefined, + runtime: isRecord(payload.runtime) ? readRuntime(payload.runtime) : undefined, + initialState: isRecord(payload.initialState) ? (payload.initialState as Partial) : {}, + routeData: isRecord(payload.routeData) ? (payload.routeData as BootRouteData) : undefined, + }; +} + +export async function loadBootPayload( + win: Window = window, + fetcher: typeof fetch = fetch, +): Promise { + const injected = (win as BootWindow).__KANDEV_BOOT_PAYLOAD__; + if (isRecord(injected)) { + return readBootPayload(win); + } + + try { + const path = `${win.location?.pathname || "/"}${win.location?.search || ""}`; + const url = new URL(`${getBackendConfig().apiBaseUrl}/api/v1/app-state`); + url.searchParams.set("path", path); + const response = await fetcher(url.toString(), { cache: "no-store", credentials: "include" }); + if (!response.ok) return { initialState: {} }; + const payload = await response.json(); + (win as BootWindow).__KANDEV_BOOT_PAYLOAD__ = payload; + return readBootPayload(win); + } catch { + return { initialState: {} }; + } +} + +function readRoute(value: Record): BootRoute { + return { + kind: readString(value.kind), + route: readString(value.route), + path: readString(value.path), + params: isStringRecord(value.params) ? value.params : undefined, + }; +} + +function readRuntime(value: Record): BootRuntime { + return { + apiPrefix: readString(value.apiPrefix), + webSocketPath: readString(value.webSocketPath), + }; +} + +function readString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function isStringRecord(value: unknown): value is Record { + if (!isRecord(value)) return false; + return Object.values(value).every((entry) => typeof entry === "string"); +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx new file mode 100644 index 0000000000..de08375420 --- /dev/null +++ b/apps/web/src/main.tsx @@ -0,0 +1,32 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import "@/app/globals.css"; +import { StateProvider } from "@/components/state-provider"; +import { AppShell } from "./app-shell"; +import { loadBootPayload } from "./boot-payload"; +import type { BootPayload } from "./boot-payload"; +import { SpaRoutes } from "./spa-routes"; + +function App({ payload }: { payload: BootPayload }) { + return ( + + + + + + ); +} + +const root = document.getElementById("root"); + +if (!root) { + throw new Error("Missing #root element"); +} + +void loadBootPayload().then((payload) => { + createRoot(root).render( + + + , + ); +}); diff --git a/apps/web/src/office-agent-client-routes.tsx b/apps/web/src/office-agent-client-routes.tsx new file mode 100644 index 0000000000..a6bd7aec42 --- /dev/null +++ b/apps/web/src/office-agent-client-routes.tsx @@ -0,0 +1,116 @@ +import { useEffect, useState } from "react"; +import { DashboardView } from "@/app/office/agents/[id]/dashboard/dashboard-view"; +import { RunsListView } from "@/app/office/agents/[id]/runs/runs-list-view"; +import { RunDetailView } from "@/app/office/agents/[id]/runs/[runId]/run-detail-view"; +import { + getAgentSummary, + getRunDetail, + listAgentRuns, + type AgentRunsListPage, + type AgentSummaryResponse, + type RunDetail, +} from "@/lib/api/domains/office-extended-api"; +import { toRouteErrorState, type LoadState } from "@/lib/routing/client-route-helpers"; + +const DASHBOARD_DAYS = 14; + +export function AgentDashboardRoute({ agentId }: { agentId: string }) { + const [state, setState] = useState>({ status: "loading" }); + + useEffect(() => { + let cancelled = false; + setState({ status: "loading" }); + + getAgentSummary(agentId, DASHBOARD_DAYS, { cache: "no-store" }) + .then((data) => { + if (!cancelled) setState({ status: "ready", data }); + }) + .catch((error: unknown) => { + if (!cancelled) setState(toRouteErrorState(error)); + }); + + return () => { + cancelled = true; + }; + }, [agentId]); + + if (state.status !== "ready") { + return ; + } + + return ; +} + +export function AgentRunsRoute({ agentId }: { agentId: string }) { + const [state, setState] = useState>({ status: "loading" }); + + useEffect(() => { + let cancelled = false; + setState({ status: "loading" }); + + listAgentRuns(agentId, { limit: 25 }, { cache: "no-store" }) + .then((data) => { + if (!cancelled) setState({ status: "ready", data }); + }) + .catch((error: unknown) => { + if (!cancelled) setState(toRouteErrorState(error)); + }); + + return () => { + cancelled = true; + }; + }, [agentId]); + + if (state.status !== "ready") { + return ; + } + + return ; +} + +export function AgentRunDetailRoute({ agentId, runId }: { agentId: string; runId: string }) { + const [state, setState] = useState>({ + status: "loading", + }); + + useEffect(() => { + let cancelled = false; + setState({ status: "loading" }); + + async function loadRunDetail() { + const [initial, recent] = await Promise.all([ + getRunDetail(agentId, runId, { cache: "no-store" }), + listAgentRuns(agentId, { limit: 30 }, { cache: "no-store" }), + ]); + return { initial, recent }; + } + + loadRunDetail() + .then((data) => { + if (!cancelled) setState({ status: "ready", data }); + }) + .catch((error: unknown) => { + if (!cancelled) setState(toRouteErrorState(error)); + }); + + return () => { + cancelled = true; + }; + }, [agentId, runId]); + + if (state.status !== "ready") { + return ; + } + + return ( + + ); +} + +function AgentRoutePlaceholder({ state, label }: { state: LoadState; label: string }) { + if (state.status === "error") { + return
{state.message}
; + } + + return
Loading {label}...
; +} diff --git a/apps/web/src/office-routes.tsx b/apps/web/src/office-routes.tsx new file mode 100644 index 0000000000..e1f2d76aa8 --- /dev/null +++ b/apps/web/src/office-routes.tsx @@ -0,0 +1,430 @@ +import { useEffect, useState } from "react"; +import ProjectDetailPage from "@/app/office/projects/[id]/page"; +import AgentDetailLayout from "@/app/office/agents/[id]/layout"; +import AgentChannelsPage from "@/app/office/agents/[id]/channels/page"; +import AgentConfigurationPage from "@/app/office/agents/[id]/configuration/page"; +import AgentInstructionsPage from "@/app/office/agents/[id]/instructions/page"; +import AgentMemoryPage from "@/app/office/agents/[id]/memory/page"; +import AgentPermissionsPage from "@/app/office/agents/[id]/permissions/page"; +import AgentSkillsPage from "@/app/office/agents/[id]/skills/page"; +import { AgentsPageClient } from "@/app/office/agents/agents-page-client"; +import { OfficeTopbar } from "@/app/office/components/office-topbar"; +import { InboxPageClient } from "@/app/office/inbox/inbox-page-client"; +import { OfficePageClient } from "@/app/office/page-client"; +import { ProjectsPageClient } from "@/app/office/projects/projects-page-client"; +import { SetupWizard } from "@/app/office/setup/setup-wizard"; +import { loadSetupRouteData } from "@/app/office/setup/setup-route-data"; +import type { SetupWizardRouteProps } from "@/app/office/setup/setup-route-data"; +import ProviderRoutingPage from "@/app/office/workspace/routing/page"; +import { RoutinesPageClient } from "@/app/office/routines/routines-page-client"; +import SettingsPage from "@/app/office/workspace/settings/page"; +import SyncPage from "@/app/office/workspace/settings/sync/page"; +import OrgPage from "@/app/office/workspace/org/page"; +import IssueDetailPage from "@/app/office/tasks/[id]/page"; +import { TasksPageClient as OfficeTasksPageClient } from "@/app/office/tasks/tasks-page-client"; +import { ActivityPageClient } from "@/app/office/workspace/activity/activity-page-client"; +import { CostsPageClient } from "@/app/office/workspace/costs/costs-page-client"; +import { SkillsPageClient } from "@/app/office/workspace/skills/skills-page-client"; +import { fetchUserSettings, listWorkspaces } from "@/lib/api"; +import { + getInbox, + getMeta, + getOnboardingState, + listAgentProfiles, + listProjects, +} from "@/lib/api/domains/office-api"; +import { useAppStore, useAppStoreApi } from "@/components/state-provider"; +import { useRouter, useSearchParams } from "@/lib/routing/client-router"; +import { mapWorkspaceItem, readActiveWorkspaceCookie } from "@/lib/routing/route-bootstrap"; +import type { WorkspaceState } from "@/lib/state/slices/workspace/types"; +import { mapUserSettingsResponse } from "@/lib/ssr/user-settings"; +import { + AgentDashboardRoute, + AgentRunDetailRoute, + AgentRunsRoute, +} from "./office-agent-client-routes"; +import { RoutineDetailRoute } from "./office-routine-client-routes"; +import { TooltipProvider } from "@kandev/ui/tooltip"; + +type RouteRenderer = () => React.ReactNode; + +const OFFICE_ROUTES: Record = { + "/office": () => , + "/office/inbox": () => , + "/office/tasks": () => , + "/office/projects": () => , + "/office/routines": () => , + "/office/agents": () => , + "/office/workspace/activity": () => , + "/office/workspace/costs": () => , + "/office/workspace/skills": () => , + "/office/workspace/routing": () => , + "/office/workspace/settings": () => , + "/office/workspace/settings/sync": () => , + "/office/workspace/org": () => , +}; + +export function OfficeRoutes({ pathname }: { pathname: string }) { + const router = useRouter(); + const officeEnabled = useAppStore((state) => state.features.office); + const workspaces = useAppStore((state) => state.workspaces); + const normalizedPathname = normalizeOfficePath(pathname); + const routeWorkspaceId = useSearchParams().get("workspaceId"); + const bootstrap = useOfficeRouteBootstrap(officeEnabled, routeWorkspaceId); + const setupRedirectHref = resolveOfficeHomeSetupRedirect( + normalizedPathname, + bootstrap.complete, + bootstrap.onboardingComplete, + workspaces.items, + ); + + useEffect(() => { + if (!officeEnabled || !setupRedirectHref) return; + router.replace(setupRedirectHref); + }, [officeEnabled, router, setupRedirectHref]); + + if (!officeEnabled) { + return ; + } + + if (normalizedPathname === "/office/setup") { + return ; + } + + if ( + setupRedirectHref || + shouldHoldOfficeHomeForBootstrap(normalizedPathname, bootstrap.complete, workspaces) + ) { + return ; + } + + return ( + +
+ +
+ {renderOfficeRoute(normalizedPathname)} +
+
+
+ ); +} + +export function officeRouteKey(pathname: string): string { + return normalizeOfficePath(pathname); +} + +export function resolveOfficeHomeSetupRedirect( + pathname: string, + bootstrapComplete: boolean, + onboardingComplete: boolean | null, + workspaceItems: WorkspaceState["items"], +): "/office/setup" | "/office/setup?mode=new" | null { + if (pathname !== "/office" || !bootstrapComplete) return null; + if (onboardingComplete === false) return "/office/setup"; + return hasOfficeWorkspace(workspaceItems) ? null : "/office/setup?mode=new"; +} + +function renderOfficeRoute(pathname: string) { + const agentRoute = matchAgentRoute(pathname); + if (agentRoute) { + return renderAgentRoute(agentRoute); + } + + const projectId = matchSingle(pathname, /^\/office\/projects\/([^/]+)$/); + if (projectId) { + return ; + } + + const routineId = matchSingle(pathname, /^\/office\/routines\/([^/]+)$/); + if (routineId) { + return ; + } + + const taskId = matchSingle(pathname, /^\/office\/tasks\/([^/]+)$/); + if (taskId) { + return ; + } + + return OFFICE_ROUTES[pathname]?.() ?? ; +} + +type OfficeBootstrapState = { + complete: boolean; + onboardingComplete: boolean | null; +}; + +function useOfficeRouteBootstrap( + officeEnabled: boolean, + routeWorkspaceId: string | null, +): OfficeBootstrapState { + const store = useAppStoreApi(); + const [bootstrap, setBootstrap] = useState({ + complete: false, + onboardingComplete: null, + }); + + useEffect(() => { + if (!officeEnabled) { + setBootstrap({ complete: false, onboardingComplete: null }); + return; + } + let cancelled = false; + setBootstrap({ complete: false, onboardingComplete: null }); + + async function loadBootstrapState() { + const [onboardingResponse, workspacesResponse, userSettingsResponse, metaResponse] = + await Promise.all([ + getOnboardingState({ cache: "no-store" }).catch(() => ({ completed: true })), + listWorkspaces({ cache: "no-store" }).catch(() => ({ workspaces: [] })), + fetchUserSettings({ cache: "no-store" }).catch(() => null), + getMeta({ cache: "no-store" }).catch(() => null), + ]); + if (cancelled) return; + + const onboardingComplete = onboardingResponse.completed; + if (!onboardingComplete) { + setBootstrap({ complete: true, onboardingComplete: false }); + return; + } + + const workspaceItems = workspacesResponse.workspaces.map(mapWorkspaceItem); + const officeWorkspaceItems = workspaceItems.filter( + (workspace) => workspace.office_workflow_id, + ); + const activeWorkspaceId = resolveActiveOfficeWorkspaceId( + officeWorkspaceItems, + routeWorkspaceId, + readActiveWorkspaceCookie(), + userSettingsResponse?.settings?.workspace_id ?? null, + ); + + store.getState().hydrate({ + workspaces: { items: workspaceItems, activeId: activeWorkspaceId }, + userSettings: { + ...mapUserSettingsResponse(userSettingsResponse), + workspaceId: activeWorkspaceId, + }, + }); + store.getState().setMeta(metaResponse); + + if (!activeWorkspaceId) { + setBootstrap({ complete: true, onboardingComplete }); + return; + } + + const [agentsResponse, projectsResponse, inboxResponse] = await Promise.all([ + listAgentProfiles(activeWorkspaceId, { cache: "no-store" }).catch(() => ({ agents: [] })), + listProjects(activeWorkspaceId, { cache: "no-store" }).catch(() => ({ projects: [] })), + getInbox(activeWorkspaceId, { cache: "no-store" }).catch(() => ({ + items: [], + total_count: 0, + })), + ]); + if (cancelled) return; + + store.getState().setOfficeAgentProfiles(agentsResponse.agents); + store.getState().setProjects(projectsResponse.projects); + store.getState().setInboxItems(inboxResponse.items); + store.getState().setInboxCount(inboxResponse.total_count); + setBootstrap({ complete: true, onboardingComplete }); + } + + void loadBootstrapState().catch(() => { + if (!cancelled) setBootstrap({ complete: true, onboardingComplete: true }); + }); + return () => { + cancelled = true; + }; + }, [officeEnabled, routeWorkspaceId, store]); + + return bootstrap; +} + +function resolveActiveOfficeWorkspaceId( + workspaceItems: { id: string }[], + routeWorkspaceId: string | null, + cookieWorkspaceId: string | null, + settingsWorkspaceId: string | null, +): string | null { + return ( + workspaceItems.find((workspace) => workspace.id === routeWorkspaceId)?.id ?? + workspaceItems.find((workspace) => workspace.id === cookieWorkspaceId)?.id ?? + workspaceItems.find((workspace) => workspace.id === settingsWorkspaceId)?.id ?? + workspaceItems[0]?.id ?? + null + ); +} + +type AgentRouteMatch = { + id: string; + tab: string; + runId?: string; + bare?: boolean; +}; + +function renderAgentRoute(route: AgentRouteMatch) { + const params = Promise.resolve({ id: route.id }); + if (route.bare) { + return ( + + + + ); + } + + return ( + {renderAgentRouteBody(route, params)} + ); +} + +function AgentBareRouteRedirect({ agentId }: { agentId: string }) { + const router = useRouter(); + + useEffect(() => { + router.replace(`/office/agents/${encodeURIComponent(agentId)}/dashboard`); + }, [agentId, router]); + + return ; +} + +function renderAgentRouteBody(route: AgentRouteMatch, params: Promise<{ id: string }>) { + switch (route.tab) { + case "dashboard": + return ; + case "instructions": + return ; + case "skills": + return ; + case "configuration": + return ; + case "permissions": + return ; + case "runs": + if (route.runId) { + return ; + } + return ; + case "memory": + return ; + case "channels": + return ; + default: + return ; + } +} + +function matchAgentRoute(pathname: string): AgentRouteMatch | null { + const match = pathname.match(/^\/office\/agents\/([^/]+)(?:\/([^/]+))?(?:\/([^/]+))?$/); + if (!match?.[1]) return null; + const id = decodeURIComponent(match[1]); + const bare = !match[2]; + const tab = bare ? "dashboard" : decodeURIComponent(match[2]); + const runId = tab === "runs" && match[3] ? decodeURIComponent(match[3]) : undefined; + return { id, tab, runId, bare }; +} + +function OfficeUnavailable() { + return ( +
+ Office is not enabled for this runtime. +
+ ); +} + +function OfficeRouteLoading() { + return ( +
+ Loading... +
+ ); +} + +function shouldHoldOfficeHomeForBootstrap( + pathname: string, + bootstrapComplete: boolean, + workspaces: WorkspaceState, +): boolean { + return ( + pathname === "/office" && + !bootstrapComplete && + (!hasOfficeWorkspace(workspaces.items) || !workspaces.activeId) + ); +} + +function hasOfficeWorkspace(workspaceItems: WorkspaceState["items"]): boolean { + return workspaceItems.some((workspace) => Boolean(workspace.office_workflow_id)); +} + +type OfficeSetupState = + | { status: "loading" } + | { status: "ready"; props: SetupWizardRouteProps } + | { status: "error"; message: string }; + +function OfficeSetupRoute() { + const router = useRouter(); + const searchParams = useSearchParams(); + const mode = searchParams.get("mode") ?? undefined; + const [state, setState] = useState({ status: "loading" }); + + useEffect(() => { + let cancelled = false; + + async function load() { + setState({ status: "loading" }); + try { + const data = await loadSetupRouteData(mode); + if (cancelled) return; + if (data.kind === "redirect") { + router.replace(data.href); + return; + } + setState({ status: "ready", props: data.props }); + } catch (error) { + if (cancelled) return; + setState({ + status: "error", + message: error instanceof Error ? error.message : "Failed to load setup", + }); + } + } + + void load(); + return () => { + cancelled = true; + }; + }, [mode, router]); + + if (state.status === "ready") { + return ; + } + + if (state.status === "error") { + return ( +
+ {state.message} +
+ ); + } + + return ; +} + +function OfficeRouteFallback({ pathname }: { pathname: string }) { + return ( +
+ This Office route is handled by the SPA shell, but its dedicated client page is still being + ported: {pathname} +
+ ); +} + +function matchSingle(pathname: string, pattern: RegExp): string | null { + const match = pathname.match(pattern); + return match?.[1] ? decodeURIComponent(match[1]) : null; +} + +function normalizeOfficePath(pathname: string): string { + if (!pathname || pathname === "/office/") return "/office"; + return pathname.length > 1 && pathname.endsWith("/") ? pathname.slice(0, -1) : pathname; +} diff --git a/apps/web/src/office-routine-client-routes.tsx b/apps/web/src/office-routine-client-routes.tsx new file mode 100644 index 0000000000..c803047b48 --- /dev/null +++ b/apps/web/src/office-routine-client-routes.tsx @@ -0,0 +1,60 @@ +import { useEffect, useState } from "react"; +import { RoutineDetailView } from "@/app/office/routines/[id]/routine-detail-view"; +import { getRoutine, listRoutineTriggers } from "@/lib/api/domains/office-api"; +import { toRouteErrorState, type LoadState } from "@/lib/routing/client-route-helpers"; +import type { Routine, RoutineTrigger } from "@/lib/state/slices/office/types"; + +type RoutineDetailData = { + routine: Routine; + triggers: RoutineTrigger[]; +}; + +export function RoutineDetailRoute({ routineId }: { routineId: string }) { + const [state, setState] = useState>({ status: "loading" }); + + useEffect(() => { + let cancelled = false; + setState({ status: "loading" }); + + async function loadRoutineDetail(): Promise { + const [routineResponse, triggersResponse] = await Promise.all([ + getRoutine(routineId, { cache: "no-store" }), + listRoutineTriggers(routineId, { cache: "no-store" }).catch(() => ({ + triggers: [] as RoutineTrigger[], + })), + ]); + const routine = + (routineResponse as unknown as { routine?: Routine }).routine ?? + (routineResponse as unknown as Routine); + return { routine, triggers: triggersResponse.triggers ?? [] }; + } + + loadRoutineDetail() + .then((data) => { + if (!cancelled) setState({ status: "ready", data }); + }) + .catch((error: unknown) => { + if (!cancelled) setState(toRouteErrorState(error, "Failed to load routine")); + }); + + return () => { + cancelled = true; + }; + }, [routineId]); + + if (state.status !== "ready") { + return ; + } + + return ( + + ); +} + +function RoutineRoutePlaceholder({ state }: { state: LoadState }) { + if (state.status === "error") { + return
{state.message}
; + } + + return
Loading routine...
; +} diff --git a/apps/web/src/settings-routes.test.ts b/apps/web/src/settings-routes.test.ts new file mode 100644 index 0000000000..fa00090883 --- /dev/null +++ b/apps/web/src/settings-routes.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; + +import { workspaceId } from "@/lib/types/ids"; +import type { ListWorkspacesResponse, UserSettingsResponse } from "@/lib/types/http"; +import { buildSettingsInitialStateForRoute } from "./settings-routes"; + +describe("buildSettingsInitialStateForRoute", () => { + it("prefers the workspace matching the URL path param", () => { + const state = buildState({ + pathname: "/settings/workspace/ws-2/repositories", + workspaces: workspaceRows(["ws-1", "ws-2"]), + userSettingsResponse: userSettings({ workspace_id: workspaceId("ws-1") }), + }); + + expect(state.workspaces?.activeId).toBe("ws-2"); + expect(state.userSettings?.workspaceId).toBe("ws-2"); + }); + + it("falls back to the settings workspace_id when no URL param matches", () => { + const state = buildState({ + pathname: "/settings/workspace/missing/repositories", + workspaces: workspaceRows(["ws-1", "ws-2"]), + userSettingsResponse: userSettings({ workspace_id: workspaceId("ws-2") }), + }); + + expect(state.workspaces?.activeId).toBe("ws-2"); + expect(state.userSettings?.workspaceId).toBe("ws-2"); + }); + + it("falls back to the first workspace when neither URL param nor settings match", () => { + const state = buildState({ + pathname: "/settings/utility-agents", + workspaces: workspaceRows(["ws-1", "ws-2"]), + userSettingsResponse: userSettings({ workspace_id: workspaceId("missing") }), + }); + + expect(state.workspaces?.activeId).toBe("ws-1"); + expect(state.userSettings?.workspaceId).toBe("ws-1"); + }); + + it("only spreads userSettings when settings were loaded", () => { + const loaded = buildState({ + workspaces: workspaceRows(["ws-1"]), + userSettingsResponse: userSettings({ workspace_id: workspaceId("ws-1") }), + }); + const failed = buildState({ + workspaces: workspaceRows(["ws-1"]), + userSettingsResponse: null, + }); + + expect(loaded.userSettings?.loaded).toBe(true); + expect(failed.userSettings).toBeUndefined(); + }); + + it("returns empty state defaults when all API calls fail", () => { + const state = buildState({ userSettingsResponse: null }); + + expect(state.workspaces).toEqual({ items: [], activeId: null }); + expect(state.executors).toEqual({ items: [] }); + expect(state.agentProfiles).toEqual({ items: [], version: 0 }); + expect(state.settingsAgents).toEqual({ items: [] }); + expect(state.agentDiscovery).toEqual({ items: [], loading: false, loaded: true }); + expect(state.availableAgents).toEqual({ + items: [], + tools: [], + loading: false, + loaded: true, + }); + expect(state.settingsData).toEqual({ executorsLoaded: true, agentsLoaded: true }); + expect(state.userSettings).toBeUndefined(); + }); +}); + +function buildState( + overrides: Partial[0]> = {}, +) { + return buildSettingsInitialStateForRoute({ + pathname: "/settings", + workspaces: [], + executors: [], + agents: [], + discoveryAgents: [], + availableAgents: [], + availableTools: [], + userSettingsResponse: null, + ...overrides, + }); +} + +function workspaceRows(ids: string[]): ListWorkspacesResponse["workspaces"] { + return ids.map((id) => ({ + id: workspaceId(id), + name: `Workspace ${id}`, + description: null, + owner_id: "owner-1", + default_executor_id: null, + default_environment_id: null, + default_agent_profile_id: null, + default_config_agent_profile_id: null, + office_workflow_id: null, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + })) as unknown as ListWorkspacesResponse["workspaces"]; +} + +function userSettings( + settings: Partial>, +): UserSettingsResponse { + return { + settings: { + user_id: "user-1", + workspace_id: workspaceId(""), + workflow_filter_id: "", + repository_ids: [], + updated_at: "2026-01-01T00:00:00Z", + ...settings, + }, + }; +} diff --git a/apps/web/src/settings-routes.tsx b/apps/web/src/settings-routes.tsx new file mode 100644 index 0000000000..c1a7d1645a --- /dev/null +++ b/apps/web/src/settings-routes.tsx @@ -0,0 +1,536 @@ +import { useEffect, useRef, useState, type ReactNode } from "react"; + +import AgentsSettingsPage from "@/app/settings/agents/page"; +import AgentSetupPage from "@/app/settings/agents/[agentId]/page"; +import AgentProfileRoute from "@/app/settings/agents/[agentId]/profiles/[profileId]/page"; +import AutomationsTopLevelPage from "@/app/settings/automations/page"; +import ExecutorEditPage from "@/app/settings/executor/[id]/page"; +import ProfileDetailPage from "@/app/settings/executor/[id]/profile/[profileId]/page"; +import ExecutorCreatePage from "@/app/settings/executor/new/page"; +import ExecutorsPage from "@/app/settings/executors/page"; +import ProfileEditPage from "@/app/settings/executors/[profileId]/page"; +import CreateProfilePage from "@/app/settings/executors/new/[type]/page"; +import SSHExecutorPage from "@/app/settings/executors/ssh/[executorId]/page"; +import ExternalMcpPage from "@/app/settings/external-mcp/page"; +import IntegrationsIndexPage from "@/app/settings/integrations/page"; +import IntegrationsGitLabPage from "@/app/settings/integrations/gitlab/page"; +import IntegrationsJiraPage from "@/app/settings/integrations/jira/page"; +import IntegrationsLinearPage from "@/app/settings/integrations/linear/page"; +import IntegrationsSentryPage from "@/app/settings/integrations/sentry/page"; +import IntegrationsSlackPage from "@/app/settings/integrations/slack/page"; +import UtilityAgentsSettingsPage from "@/app/settings/utility-agents/page"; +import AutomationsPage from "@/app/settings/workspace/[id]/automations/page"; +import AutomationEditorPage from "@/app/settings/workspace/[id]/automations/[automationId]/page"; +import NewAutomationPage from "@/app/settings/workspace/[id]/automations/new/page"; +import WorkspaceEditPage from "@/app/settings/workspace/[id]/page"; +import { WorkspaceRepositoriesClient } from "@/app/settings/workspace/workspace-repositories-client"; +import { WorkspaceWorkflowsClient } from "@/app/settings/workspace/workspace-workflows-client"; +import WorkspacesPage from "@/app/settings/workspace/page"; +import { GitHubIntegrationPage } from "@/components/github/github-settings"; +import { useAppStoreApi } from "@/components/state-provider"; +import { EditorsSettings } from "@/components/settings/editors-settings"; +import { + AppearanceSettings, + GeneralSettings, + KeyboardShortcutsSettings, +} from "@/components/settings/general-settings"; +import { NotificationsSettings } from "@/components/settings/notifications-settings"; +import { PromptsSettings } from "@/components/settings/prompts-settings"; +import { SecretsSettings } from "@/components/settings/secrets-settings"; +import { SettingsLayoutClient } from "@/components/settings/settings-layout-client"; +import { SpritesSettings } from "@/components/settings/sprites-settings"; +import { AboutCard } from "@/components/settings/system/about-card"; +import { BackupsTable } from "@/components/settings/system/backups-table"; +import { DatabaseStatsCard } from "@/components/settings/system/database-stats-card"; +import { DiskUsageCard } from "@/components/settings/system/disk-usage-card"; +import { FeatureTogglesSettings } from "@/components/settings/system/feature-toggles-settings"; +import { HealthIssuesCard } from "@/components/settings/system/health-issues-card"; +import { LicensesList } from "@/components/settings/system/licenses-list"; +import { LogViewer } from "@/components/settings/system/log-viewer"; +import { SystemPageShell } from "@/components/settings/system/system-page-shell"; +import { UIStateCard } from "@/components/settings/system/ui-state-card"; +import { UpdatesCard } from "@/components/settings/system/updates-card"; +import { VersionSummaryCard } from "@/components/settings/system/version-summary-card"; +import { TerminalSettings } from "@/components/settings/terminal-settings"; +import { VoiceModeSettings } from "@/components/settings/voice-mode-settings"; +import licenses from "@/generated/licenses.json"; +import { fetchJson } from "@/lib/api/client"; +import { listWorkflows } from "@/lib/api/domains/kanban-api"; +import { + fetchUserSettings, + listAgentDiscovery, + listAgents, + listAvailableAgents, + listExecutors, +} from "@/lib/api/domains/settings-api"; +import { listWorkflowTemplates } from "@/lib/api/domains/workflow-api"; +import { listRepositories, listWorkspaces } from "@/lib/api/domains/workspace-api"; +import { useRouter } from "@/lib/routing/client-router"; +import { mapWorkspaceItem } from "@/lib/routing/route-bootstrap"; +import { mapUserSettingsResponse } from "@/lib/ssr/user-settings"; +import type { AppState } from "@/lib/state/store"; +import { toAgentProfileOption } from "@/lib/state/slices/settings/types"; +import type { + ListWorkspacesResponse, + Repository, + RepositoryScript, + UserSettingsResponse, + Workflow, + WorkflowTemplate, + Workspace, +} from "@/lib/types/http"; +import type { LicenseEntry } from "@/lib/types/system"; + +type RouteRenderer = () => ReactNode; +type RepositoryWithScripts = Repository & { scripts: RepositoryScript[] }; +type WorkspaceRepositoriesRouteState = { + workspace: Workspace | null; + repositories: RepositoryWithScripts[]; +}; +type WorkspaceWorkflowsRouteState = { + workspace: Workspace | null; + workflows: Workflow[]; + workflowTemplates: WorkflowTemplate[]; +}; +type SettingsInitialStateData = { + pathname: string; + workspaces: ListWorkspacesResponse["workspaces"]; + executors: Awaited>["executors"]; + agents: Awaited>["agents"]; + discoveryAgents: Awaited>["agents"]; + availableAgents: Awaited>["agents"]; + availableTools: NonNullable>["tools"]>; + userSettingsResponse: UserSettingsResponse | null; +}; + +const licenseEntries = licenses as LicenseEntry[]; + +const SETTINGS_ROUTES: Record = { + "/settings": () => , + "/settings/general": () => , + "/settings/general/appearance": () => , + "/settings/general/changes-panel": () => , + "/settings/general/chat-input": () => ( + + ), + "/settings/general/editors": () => , + "/settings/general/keyboard-shortcuts": () => , + "/settings/general/notifications": () => , + "/settings/general/resource-metrics": () => ( + + ), + "/settings/general/secrets": () => , + "/settings/general/shell": () => , + "/settings/general/sprites": () => , + "/settings/general/terminal": () => , + "/settings/workspace": () => , + "/settings/agents": () => , + "/settings/automations": () => , + "/settings/executors": () => , + "/settings/executor/new": () => , + "/settings/utility-agents": () => , + "/settings/external-mcp": () => , + "/settings/prompts": () => , + "/settings/voice-mode": () => , + "/settings/integrations": () => , + "/settings/integrations/github": () => , + "/settings/integrations/gitlab": () => , + "/settings/integrations/jira": () => , + "/settings/integrations/linear": () => , + "/settings/integrations/sentry": () => , + "/settings/integrations/slack": () => , + "/settings/system": () => , + "/settings/system/about": () => ( + + + + ), + "/settings/system/backups": () => ( + + + + ), + "/settings/system/database": () => ( + + + + ), + "/settings/system/feature-toggles": () => ( + + + + ), + "/settings/system/licenses": () => ( + + + + ), + "/settings/system/logs": () => ( + + + + ), + "/settings/system/status": () => ( + +
+ + +
+ + +
+ ), + "/settings/system/updates": renderUpdatesRoute, + "/settings/changelog": () => , +}; + +export function SettingsRoutes({ pathname }: { pathname: string }) { + const normalizedPathname = normalizeSettingsPath(pathname); + + return ( + <> + + {renderSettingsRoute(normalizedPathname)} + + ); +} + +export function settingsRouteKey(pathname: string): string { + return normalizeSettingsPath(pathname); +} + +function renderSettingsRoute(pathname: string) { + const dynamicRoute = renderDynamicSettingsRoute(pathname); + if (dynamicRoute) return dynamicRoute; + return SETTINGS_ROUTES[pathname]?.() ?? ; +} + +function renderDynamicSettingsRoute(pathname: string) { + const workspaceAutomation = matchDouble( + pathname, + /^\/settings\/workspace\/([^/]+)\/automations\/([^/]+)$/, + ); + if (workspaceAutomation) { + const [id, automationId] = workspaceAutomation; + if (automationId === "new") { + return ; + } + return ; + } + + const workspaceSubpage = matchDouble( + pathname, + /^\/settings\/workspace\/([^/]+)\/(repositories|workflows|automations)$/, + ); + if (workspaceSubpage) { + const [id, section] = workspaceSubpage; + if (section === "repositories") { + return ; + } + if (section === "workflows") { + return ; + } + return ; + } + + const workspaceId = matchSingle(pathname, /^\/settings\/workspace\/([^/]+)$/); + if (workspaceId) { + return ; + } + + const agentProfile = matchDouble(pathname, /^\/settings\/agents\/([^/]+)\/profiles\/([^/]+)$/); + if (agentProfile) { + return ; + } + + const agentId = matchSingle(pathname, /^\/settings\/agents\/([^/]+)$/); + if (agentId) { + return ; + } + + const executorProfile = matchDouble( + pathname, + /^\/settings\/executor\/([^/]+)\/profile\/([^/]+)$/, + ); + if (executorProfile) { + const [id, profileId] = executorProfile; + return ; + } + + const executorId = matchSingle(pathname, /^\/settings\/executor\/([^/]+)$/); + if (executorId) { + return ; + } + + const profileId = matchSingle(pathname, /^\/settings\/executors\/([^/]+)$/); + if (profileId) { + return ; + } + + const executorType = matchSingle(pathname, /^\/settings\/executors\/new\/([^/]+)$/); + if (executorType) { + return ; + } + + const sshExecutorId = matchSingle(pathname, /^\/settings\/executors\/ssh\/([^/]+)$/); + if (sshExecutorId) { + return ; + } + + return null; +} + +function renderUpdatesRoute() { + return ( + + + + ); +} + +function SettingsRedirect({ to }: { to: string }) { + const router = useRouter(); + + useEffect(() => { + router.replace(to); + }, [router, to]); + + return null; +} + +function SettingsRouteBootstrap({ pathname }: { pathname: string }) { + const store = useAppStoreApi(); + const bootstrappedRef = useRef(false); + + useEffect(() => { + if (bootstrappedRef.current) return; + bootstrappedRef.current = true; + let cancelled = false; + + async function bootstrap() { + const initialState = await loadSettingsInitialState(pathname); + if (!cancelled && Object.keys(initialState).length > 0) { + store.getState().hydrate(initialState); + } + } + + void bootstrap(); + return () => { + cancelled = true; + bootstrappedRef.current = false; + }; + }, [pathname, store]); + + return null; +} + +async function loadSettingsInitialState(pathname: string): Promise> { + const [workspaces, executors, agents, discovery, available, userSettingsResponse] = + await Promise.all([ + listWorkspaces({ cache: "no-store" }).catch(() => ({ workspaces: [] })), + listExecutors({ cache: "no-store" }).catch(() => ({ executors: [] })), + listAgents({ cache: "no-store" }).catch(() => ({ agents: [] })), + listAgentDiscovery({ cache: "no-store" }).catch(() => ({ agents: [] })), + listAvailableAgents({ cache: "no-store" }).catch(() => ({ agents: [], tools: [] })), + fetchUserSettings({ cache: "no-store" }).catch(() => null), + ]); + + return buildSettingsInitialStateForRoute({ + pathname, + workspaces: workspaces.workspaces, + executors: executors.executors, + agents: agents.agents, + discoveryAgents: discovery.agents, + availableAgents: available.agents, + availableTools: available.tools ?? [], + userSettingsResponse, + }); +} + +export function buildSettingsInitialStateForRoute({ + pathname, + workspaces, + executors, + agents, + discoveryAgents, + availableAgents, + availableTools, + userSettingsResponse, +}: SettingsInitialStateData): Partial { + const workspaceItems = workspaces.map(mapWorkspaceItem); + const activeWorkspaceId = resolveSettingsActiveWorkspaceId( + workspaceItems, + matchSingle(pathname, /^\/settings\/workspace\/([^/]+)/), + userSettingsResponse?.settings?.workspace_id ?? null, + ); + const mappedUserSettings = mapUserSettingsResponse(userSettingsResponse); + + return { + workspaces: { items: workspaceItems, activeId: activeWorkspaceId }, + executors: { items: executors }, + agentProfiles: { + items: agents.flatMap((agent) => + agent.profiles.map((profile) => toAgentProfileOption(agent, profile)), + ), + version: 0, + }, + settingsAgents: { items: agents }, + agentDiscovery: { items: discoveryAgents, loading: false, loaded: true }, + availableAgents: { + items: availableAgents, + tools: availableTools, + loading: false, + loaded: true, + }, + settingsData: { executorsLoaded: true, agentsLoaded: true }, + ...(mappedUserSettings.loaded + ? { + userSettings: { + ...mappedUserSettings, + workspaceId: activeWorkspaceId, + }, + } + : {}), + }; +} + +function resolveSettingsActiveWorkspaceId( + workspaceItems: Array<{ id: string }>, + requestedWorkspaceId: string | null, + settingsWorkspaceId: string | null, +) { + return ( + workspaceItems.find((workspace) => workspace.id === requestedWorkspaceId)?.id ?? + workspaceItems.find((workspace) => workspace.id === settingsWorkspaceId)?.id ?? + workspaceItems[0]?.id ?? + null + ); +} + +function WorkspaceRepositoriesRoute({ workspaceId }: { workspaceId: string }) { + const [state, setState] = useState(null); + + useEffect(() => { + let cancelled = false; + setState(null); + + loadWorkspaceRepositoriesRoute(workspaceId) + .catch(() => ({ workspace: null, repositories: [] })) + .then((nextState) => { + if (!cancelled) setState(nextState); + }); + + return () => { + cancelled = true; + }; + }, [workspaceId]); + + if (!state) return null; + return ( + + ); +} + +function WorkspaceWorkflowsRoute({ workspaceId }: { workspaceId: string }) { + const [state, setState] = useState(null); + + useEffect(() => { + let cancelled = false; + setState(null); + + loadWorkspaceWorkflowsRoute(workspaceId) + .catch(() => ({ workspace: null, workflows: [], workflowTemplates: [] })) + .then((nextState) => { + if (!cancelled) setState(nextState); + }); + + return () => { + cancelled = true; + }; + }, [workspaceId]); + + if (!state) return null; + return ( + + ); +} + +async function loadWorkspaceRepositoriesRoute( + workspaceId: string, +): Promise { + const [workspace, repoResponse] = await Promise.all([ + fetchJson(`/api/v1/workspaces/${workspaceId}`, { cache: "no-store" }), + listRepositories(workspaceId, { includeScripts: true }, { cache: "no-store" }), + ]); + + return { + workspace, + repositories: repoResponse.repositories.map((repository) => ({ + ...repository, + scripts: repository.scripts ?? [], + })), + }; +} + +async function loadWorkspaceWorkflowsRoute( + workspaceId: string, +): Promise { + const [workspace, workflowResponse, templateResponse] = await Promise.all([ + fetchJson(`/api/v1/workspaces/${workspaceId}`, { cache: "no-store" }), + listWorkflows(workspaceId, { cache: "no-store" }), + listWorkflowTemplates({ cache: "no-store" }), + ]); + + return { + workspace, + workflows: workflowResponse.workflows ?? [], + workflowTemplates: templateResponse.templates ?? [], + }; +} + +function SettingsRouteFallback({ pathname }: { pathname: string }) { + return ( +
+ This settings route is handled by the SPA shell, but its dedicated client page is still being + ported: {pathname} +
+ ); +} + +function matchSingle(pathname: string, pattern: RegExp): string | null { + const match = pathname.match(pattern); + return match?.[1] ? decodeURIComponent(match[1]) : null; +} + +function matchDouble(pathname: string, pattern: RegExp): [string, string] | null { + const match = pathname.match(pattern); + if (!match?.[1] || !match[2]) return null; + return [decodeURIComponent(match[1]), decodeURIComponent(match[2])]; +} + +function normalizeSettingsPath(pathname: string): string { + if (!pathname || pathname === "/settings/") return "/settings"; + return pathname.length > 1 && pathname.endsWith("/") ? pathname.slice(0, -1) : pathname; +} diff --git a/apps/web/src/spa-routes.tsx b/apps/web/src/spa-routes.tsx new file mode 100644 index 0000000000..2f9bd5cc4c --- /dev/null +++ b/apps/web/src/spa-routes.tsx @@ -0,0 +1,451 @@ +import { lazy, Suspense, useEffect, useRef, useState } from "react"; +import { GitHubPageClient } from "@/app/github/github-page-client"; +import { GitLabPageClient } from "@/app/gitlab/gitlab-page-client"; +import { JiraPageClient } from "@/app/jira/jira-page-client"; +import { LinearPageClient } from "@/app/linear/linear-page-client"; +import { PageClient } from "@/app/page-client"; +import { StatsPageClient } from "@/app/stats/stats-page-client"; +import { isRangeKey } from "@/app/stats/stats-utils"; +import type { RangeKey } from "@/app/stats/stats-utils"; +import { TasksPageClient } from "@/app/tasks/tasks-page-client"; +import { useAppStore, useAppStoreApi } from "@/components/state-provider"; +import type { BootRouteData } from "./boot-payload"; +import { fetchJson } from "@/lib/api/client"; +import { listWorkflows } from "@/lib/api/domains/kanban-api"; +import { fetchUserSettings } from "@/lib/api/domains/settings-api"; +import { listRepositories, listWorkspaces } from "@/lib/api/domains/workspace-api"; +import { resolveDesiredWorkflowId } from "@/lib/kanban/resolve-workflow"; +import { hasHydratedKanbanRouteState } from "@/lib/routing/kanban-route-hydration"; +import { usePathname, useSearchParams } from "@/lib/routing/client-router"; +import { mapWorkspaceItem, readActiveWorkspaceCookie } from "@/lib/routing/route-bootstrap"; +import { resolveActiveId } from "@/lib/ssr/resolve-active-id"; +import { mapUserSettingsResponse } from "@/lib/ssr/user-settings"; +import type { + ListWorkflowStepsResponse, + Repository, + Workflow, + WorkflowStep, +} from "@/lib/types/http"; +import { TaskDetailRoute } from "./task-detail-route"; + +const OfficeRoutes = lazy(() => + import("./office-routes").then((mod) => ({ default: mod.OfficeRoutes })), +); +const SettingsRoutes = lazy(() => + import("./settings-routes").then((mod) => ({ default: mod.SettingsRoutes })), +); + +const EMPTY_REPOSITORIES: Repository[] = []; + +type SpaRoute = + | { + kind: "kanban"; + workspaceId?: string; + workflowId?: string; + taskId?: string; + sessionId?: string; + } + | { + kind: "taskDetail"; + taskId: string; + sessionId?: string; + layout?: string | null; + simple?: string; + mode?: string; + } + | { kind: "tasks" } + | { kind: "github" } + | { kind: "gitlab" } + | { kind: "jira" } + | { kind: "linear" } + | { kind: "stats"; range?: RangeKey } + | { kind: "settings"; pathname: string } + | { kind: "office"; pathname: string }; + +type DataBackedSpaRoute = Exclude; + +type RouteDataState = { + activeWorkspaceId: string | null; + workflows: Workflow[]; + steps: WorkflowStep[]; + repositories: Repository[]; +}; + +export function resolveSpaRoute(pathname: string, searchParams: URLSearchParams): SpaRoute { + const normalized = normalizePath(pathname); + return ( + resolveTaskDetailRoute(normalized, searchParams) ?? + resolveTopLevelRoute(normalized, searchParams) ?? + resolveNestedRoute(normalized) ?? + resolveKanbanRoute(searchParams) + ); +} + +function resolveTaskDetailRoute( + normalized: string, + searchParams: URLSearchParams, +): SpaRoute | null { + const taskId = readTaskId(normalized); + if (!taskId) return null; + return { + kind: "taskDetail", + taskId, + sessionId: searchParams.get("sessionId") ?? undefined, + layout: searchParams.get("layout"), + simple: searchParams.get("simple") ?? undefined, + mode: searchParams.get("mode") ?? undefined, + }; +} + +function resolveTopLevelRoute(normalized: string, searchParams: URLSearchParams): SpaRoute | null { + switch (normalized) { + case "/tasks": + return { kind: "tasks" }; + case "/github": + return { kind: "github" }; + case "/gitlab": + return { kind: "gitlab" }; + case "/jira": + return { kind: "jira" }; + case "/linear": + return { kind: "linear" }; + case "/stats": { + const range = searchParams.get("range"); + return { kind: "stats", range: range && isRangeKey(range) ? range : undefined }; + } + default: + return null; + } +} + +function resolveNestedRoute(normalized: string): SpaRoute | null { + if (normalized === "/settings" || normalized.startsWith("/settings/")) { + return { kind: "settings", pathname: normalized }; + } + if (normalized === "/office" || normalized.startsWith("/office/")) { + return { kind: "office", pathname: normalized }; + } + return null; +} + +function resolveKanbanRoute(searchParams: URLSearchParams): SpaRoute { + return { + kind: "kanban", + workspaceId: searchParams.get("workspaceId") ?? undefined, + workflowId: searchParams.get("workflowId") ?? undefined, + taskId: searchParams.get("taskId") ?? undefined, + sessionId: searchParams.get("sessionId") ?? undefined, + }; +} + +export function SpaRoutes({ routeData }: { routeData?: BootRouteData }) { + const pathname = usePathname(); + const searchParams = useSearchParams(); + const route = resolveSpaRoute(pathname, searchParams); + + if (route.kind === "kanban") { + return ; + } + if (route.kind === "taskDetail") { + return ( + + ); + } + if (route.kind === "settings") { + return ( + + + + ); + } + if (route.kind === "office") { + return ( + + + + ); + } + + return ; +} + +function KanbanRoute({ route }: { route: Extract }) { + useKanbanRouteBootstrap(route); + return ; +} + +function useKanbanRouteBootstrap(route: Extract) { + const store = useAppStoreApi(); + + useEffect(() => { + if (hasHydratedKanbanRouteState(store.getState(), route)) return; + + let cancelled = false; + + async function bootstrap() { + const [workspacesResponse, settingsResponse] = await Promise.all([ + listWorkspaces({ cache: "no-store" }).catch(() => ({ workspaces: [], total: 0 })), + fetchUserSettings({ cache: "no-store" }).catch(() => null), + ]); + if (cancelled) return; + + const settingsWorkspaceId = settingsResponse?.settings?.workspace_id || null; + const settingsWorkflowId = settingsResponse?.settings?.workflow_filter_id || null; + const workspaceItems = workspacesResponse.workspaces.map(mapWorkspaceItem); + const activeWorkspaceId = resolveActiveId( + workspaceItems, + route.workspaceId, + readActiveWorkspaceCookie(), + settingsWorkspaceId, + ); + + store.getState().hydrate({ + workspaces: { items: workspaceItems, activeId: activeWorkspaceId }, + userSettings: { + ...mapUserSettingsResponse(settingsResponse), + workspaceId: activeWorkspaceId, + }, + }); + + if (!activeWorkspaceId) return; + + const [workflowsResponse, repositoriesResponse] = await Promise.all([ + listWorkflows(activeWorkspaceId, { cache: "no-store", includeHidden: true }).catch(() => ({ + workflows: [], + })), + listRepositories(activeWorkspaceId, undefined, { cache: "no-store" }).catch(() => ({ + repositories: [], + })), + ]); + if (cancelled) return; + + const workflowId = resolveDesiredWorkflowId({ + activeWorkflowId: route.workflowId ?? null, + settingsWorkflowId, + workspaceWorkflows: workflowsResponse.workflows, + }); + + store.getState().hydrate({ + userSettings: { + ...mapUserSettingsResponse(settingsResponse), + workspaceId: activeWorkspaceId, + workflowId, + }, + workflows: { + items: workflowsResponse.workflows.map(mapWorkflowItem), + activeId: workflowId, + }, + }); + store.getState().setRepositories(activeWorkspaceId, repositoriesResponse.repositories); + } + + void bootstrap(); + return () => { + cancelled = true; + }; + }, [route.workspaceId, route.workflowId, store]); +} + +function DataBackedRoute({ + route, + routeData, +}: { + route: DataBackedSpaRoute; + routeData?: BootRouteData; +}) { + const tasksPage = route.kind === "tasks" ? routeData?.tasksPage : undefined; + const routeContext = routeData?.routeContext; + const bootstrapped = useRouteData({ + skipBootstrap: Boolean(tasksPage || routeContext), + }); + if (route.kind === "tasks") { + return ; + } + + const effectiveData = resolveEffectiveRouteData(routeContext, bootstrapped); + return ; +} + +function TasksDataRoute({ + bootstrapped, + tasksPage, +}: { + bootstrapped: RouteDataState; + tasksPage?: BootRouteData["tasksPage"]; +}) { + return ( + + ); +} + +function ExternalDataRoute({ + route, + data, +}: { + route: Exclude; + data: RouteDataState; +}) { + const workspaceId = data.activeWorkspaceId ?? undefined; + switch (route.kind) { + case "github": + return ( + + ); + case "gitlab": + return ; + case "jira": + return ( + + ); + case "linear": + return ( + + ); + case "stats": + return ( + + ); + } +} + +function resolveEffectiveRouteData( + routeContext: BootRouteData["routeContext"], + fallback: RouteDataState, +): RouteDataState { + return { + activeWorkspaceId: routeContext?.activeWorkspaceId ?? fallback.activeWorkspaceId, + workflows: routeContext?.workflows ?? fallback.workflows, + steps: routeContext?.steps ?? fallback.steps, + repositories: routeContext?.repositories ?? fallback.repositories, + }; +} + +function useRouteData({ + skipBootstrap = false, +}: { + skipBootstrap?: boolean; +} = {}): RouteDataState { + const store = useAppStoreApi(); + const bootstrappedRef = useRef(false); + const [workflows, setRouteWorkflows] = useState([]); + const [steps, setSteps] = useState([]); + const activeWorkspaceId = useAppStore((state) => state.workspaces.activeId); + const repositories = useAppStore((state) => + activeWorkspaceId + ? (state.repositories.itemsByWorkspaceId[activeWorkspaceId] ?? EMPTY_REPOSITORIES) + : EMPTY_REPOSITORIES, + ); + + useEffect(() => { + if (bootstrappedRef.current) return; + if (skipBootstrap) return; + bootstrappedRef.current = true; + let cancelled = false; + + async function bootstrap() { + const [workspacesResponse, settingsResponse] = await Promise.all([ + listWorkspaces({ cache: "no-store" }).catch(() => ({ workspaces: [] })), + fetchUserSettings({ cache: "no-store" }).catch(() => null), + ]); + if (cancelled) return; + + const workspaces = workspacesResponse.workspaces; + const workspaceId = settingsResponse?.settings?.workspace_id || workspaces[0]?.id || null; + const settingsWorkflowId = settingsResponse?.settings?.workflow_filter_id || null; + store.getState().hydrate({ + workspaces: { items: workspaces, activeId: workspaceId }, + workflows: { items: store.getState().workflows.items, activeId: settingsWorkflowId }, + userSettings: { ...mapUserSettingsResponse(settingsResponse), workspaceId }, + }); + if (!workspaceId) return; + + const [workflowsResponse, repositoriesResponse, stepsResponse] = await Promise.all([ + listWorkflows(workspaceId, { cache: "no-store" }).catch(() => ({ workflows: [] })), + listRepositories(workspaceId, undefined, { cache: "no-store" }).catch(() => ({ + repositories: [], + })), + listWorkspaceWorkflowSteps(workspaceId).catch(() => ({ steps: [], total: 0 })), + ]); + if (cancelled) return; + + const workflowItems = workflowsResponse.workflows.map(mapWorkflowItem); + const activeWorkflowId = resolveDesiredWorkflowId({ + activeWorkflowId: store.getState().workflows.activeId, + settingsWorkflowId, + workspaceWorkflows: workflowItems, + }); + + store.getState().hydrate({ + workflows: { items: workflowItems, activeId: activeWorkflowId }, + }); + store.getState().setRepositories(workspaceId, repositoriesResponse.repositories); + setRouteWorkflows(workflowsResponse.workflows); + setSteps(stepsResponse.steps); + } + + void bootstrap(); + return () => { + cancelled = true; + bootstrappedRef.current = false; + }; + }, [skipBootstrap, store]); + + return { activeWorkspaceId, workflows, steps, repositories }; +} + +function listWorkspaceWorkflowSteps(workspaceId: string) { + return fetchJson(`/api/v1/workspaces/${workspaceId}/workflow-steps`, { + cache: "no-store", + }); +} + +function mapWorkflowItem(workflow: Workflow) { + return { + id: workflow.id, + workspaceId: workflow.workspace_id, + name: workflow.name, + description: workflow.description ?? null, + sortOrder: workflow.sort_order ?? 0, + ...(workflow.agent_profile_id ? { agent_profile_id: workflow.agent_profile_id } : {}), + ...(workflow.hidden !== undefined ? { hidden: workflow.hidden } : {}), + ...(workflow.style !== undefined ? { style: workflow.style } : {}), + }; +} + +function normalizePath(pathname: string): string { + if (!pathname || pathname === "/") return "/"; + return pathname.length > 1 && pathname.endsWith("/") ? pathname.slice(0, -1) : pathname; +} + +function readTaskId(pathname: string): string | undefined { + for (const prefix of ["/t/", "/tasks/"]) { + if (!pathname.startsWith(prefix)) continue; + const suffix = pathname.slice(prefix.length); + if (!suffix || suffix.includes("/")) return undefined; + return decodeURIComponent(suffix); + } + return undefined; +} diff --git a/apps/web/src/spa-routing.test.ts b/apps/web/src/spa-routing.test.ts new file mode 100644 index 0000000000..440da0dbd5 --- /dev/null +++ b/apps/web/src/spa-routing.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from "vitest"; + +import { officeRouteKey, resolveOfficeHomeSetupRedirect } from "./office-routes"; +import { getInitialPageProps } from "./spa-routing"; +import { resolveSpaRoute } from "./spa-routes"; +import { settingsRouteKey } from "./settings-routes"; + +const OFFICE_HOME_PATH = "/office"; +const OFFICE_SETUP_PATH = "/office/setup"; + +describe("getInitialPageProps", () => { + it("hydrates task detail URLs into the existing kanban page client", () => { + expect( + getInitialPageProps({ + route: { + kind: "spa", + route: "taskDetail", + path: "/t/task-1", + params: { taskId: "task-1" }, + }, + initialState: {}, + }), + ).toEqual({ initialTaskId: "task-1" }); + }); + + it("renders the root kanban page without task focus for non-task routes", () => { + expect( + getInitialPageProps({ + route: { kind: "spa", route: "home", path: "/" }, + initialState: {}, + }), + ).toEqual({}); + }); +}); + +describe("resolveSpaRoute", () => { + it("maps task detail paths to the full task detail route", () => { + expect(resolveSpaRoute("/t/task-1", new URLSearchParams())).toEqual({ + kind: "taskDetail", + taskId: "task-1", + sessionId: undefined, + layout: null, + simple: undefined, + mode: undefined, + }); + expect( + resolveSpaRoute( + "/tasks/task-2", + new URLSearchParams("sessionId=sess-1&layout=wide&simple=false&mode=advanced"), + ), + ).toEqual({ + kind: "taskDetail", + taskId: "task-2", + sessionId: "sess-1", + layout: "wide", + simple: "false", + mode: "advanced", + }); + }); + + it("maps first-class SPA surfaces to their route keys", () => { + expect(resolveSpaRoute("/tasks", new URLSearchParams())).toEqual({ kind: "tasks" }); + expect(resolveSpaRoute("/github", new URLSearchParams())).toEqual({ kind: "github" }); + expect(resolveSpaRoute("/gitlab", new URLSearchParams())).toEqual({ kind: "gitlab" }); + expect(resolveSpaRoute("/jira", new URLSearchParams())).toEqual({ kind: "jira" }); + expect(resolveSpaRoute("/linear", new URLSearchParams())).toEqual({ kind: "linear" }); + }); + + it("preserves root kanban query params for SPA bootstrap", () => { + expect( + resolveSpaRoute( + "/", + new URLSearchParams( + "workspaceId=workspace-1&workflowId=workflow-1&taskId=task-1&sessionId=session-1", + ), + ), + ).toEqual({ + kind: "kanban", + workspaceId: "workspace-1", + workflowId: "workflow-1", + taskId: "task-1", + sessionId: "session-1", + }); + }); + + it("normalizes stats range query params", () => { + expect(resolveSpaRoute("/stats", new URLSearchParams("range=week"))).toEqual({ + kind: "stats", + range: "week", + }); + expect(resolveSpaRoute("/stats", new URLSearchParams("range=bad"))).toEqual({ + kind: "stats", + range: undefined, + }); + }); + + it("routes settings and office paths through their SPA shells", () => { + expect(resolveSpaRoute("/settings/general/editors", new URLSearchParams())).toEqual({ + kind: "settings", + pathname: "/settings/general/editors", + }); + expect(resolveSpaRoute("/office/projects/project-1", new URLSearchParams())).toEqual({ + kind: "office", + pathname: "/office/projects/project-1", + }); + expect(resolveSpaRoute(OFFICE_SETUP_PATH, new URLSearchParams("mode=new"))).toEqual({ + kind: "office", + pathname: OFFICE_SETUP_PATH, + }); + }); +}); + +describe("settingsRouteKey", () => { + it("normalizes dynamic settings detail paths", () => { + expect(settingsRouteKey("/settings/general/keyboard-shortcuts/")).toBe( + "/settings/general/keyboard-shortcuts", + ); + expect(settingsRouteKey("/settings/system/logs/")).toBe("/settings/system/logs"); + expect(settingsRouteKey("/settings/changelog/")).toBe("/settings/changelog"); + expect(settingsRouteKey("/settings/workspace/ws-1/repositories/")).toBe( + "/settings/workspace/ws-1/repositories", + ); + expect(settingsRouteKey("/settings/executor/executor-1/profile/profile-1")).toBe( + "/settings/executor/executor-1/profile/profile-1", + ); + expect(settingsRouteKey("/settings/executors/new/ssh")).toBe("/settings/executors/new/ssh"); + }); +}); + +describe("officeRouteKey", () => { + it("normalizes dynamic office detail paths", () => { + expect(officeRouteKey("/office/agents/agent-1/configuration/")).toBe( + "/office/agents/agent-1/configuration", + ); + expect(officeRouteKey("/office/agents/agent-1/runs/run-1")).toBe( + "/office/agents/agent-1/runs/run-1", + ); + expect(officeRouteKey("/office/routines/routine-1")).toBe("/office/routines/routine-1"); + expect(officeRouteKey(`${OFFICE_SETUP_PATH}/`)).toBe(OFFICE_SETUP_PATH); + }); + + it("resolves the office home setup redirect after bootstrap", () => { + expect(resolveOfficeHomeSetupRedirect(OFFICE_HOME_PATH, true, false, [])).toBe( + OFFICE_SETUP_PATH, + ); + expect(resolveOfficeHomeSetupRedirect(OFFICE_HOME_PATH, true, true, [])).toBe( + `${OFFICE_SETUP_PATH}?mode=new`, + ); + expect( + resolveOfficeHomeSetupRedirect(OFFICE_HOME_PATH, true, true, [ + { + id: "workspace-1", + name: "Workspace", + owner_id: "user-1", + office_workflow_id: "workflow-1", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + }, + ]), + ).toBeNull(); + expect(resolveOfficeHomeSetupRedirect(OFFICE_SETUP_PATH, true, true, [])).toBeNull(); + expect(resolveOfficeHomeSetupRedirect(OFFICE_HOME_PATH, false, true, [])).toBeNull(); + }); +}); diff --git a/apps/web/src/spa-routing.ts b/apps/web/src/spa-routing.ts new file mode 100644 index 0000000000..f7e3703b33 --- /dev/null +++ b/apps/web/src/spa-routing.ts @@ -0,0 +1,15 @@ +import type { BootPayload } from "./boot-payload"; + +export type InitialPageProps = { + initialTaskId?: string; + initialSessionId?: string; +}; + +export function getInitialPageProps(payload: BootPayload): InitialPageProps { + const route = payload.route; + if (route?.route !== "taskDetail") return {}; + + return { + initialTaskId: route.params?.taskId, + }; +} diff --git a/apps/web/src/task-detail-route.test.tsx b/apps/web/src/task-detail-route.test.tsx new file mode 100644 index 0000000000..f34810852b --- /dev/null +++ b/apps/web/src/task-detail-route.test.tsx @@ -0,0 +1,94 @@ +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { TaskDetailRoute } from "./task-detail-route"; +import type { FetchedSessionData } from "@/lib/ssr/session-page-state"; +import { taskId, workspaceId, workflowId } from "@/lib/types/ids"; +import type { Task } from "@/lib/types/http"; + +const mocks = vi.hoisted(() => ({ + fetchSessionDataForTask: vi.fn(), +})); + +vi.mock("@/components/state-hydrator", () => ({ + StateHydrator: () =>
, +})); + +vi.mock("@/app/tasks/[id]/kanban-task-shell", () => ({ + KanbanTaskShell: ({ task, taskId }: { task: Task | null; taskId: string }) => ( +
+ ), +})); + +vi.mock("@/lib/ssr/session-page-state", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchSessionDataForTask: mocks.fetchSessionDataForTask, + extractInitialRepositories: vi.fn(() => []), + extractInitialScripts: vi.fn(() => []), + }; +}); + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((next) => { + resolve = next; + }); + return { promise, resolve }; +} + +function makeFetchedData(): FetchedSessionData { + return { + task: { + id: taskId("task-1"), + title: "Task one", + description: "", + workspace_id: workspaceId("workspace-1"), + workflow_id: workflowId("workflow-1"), + workflow_step_id: "step-1", + state: "CREATED", + priority: 0, + position: 0, + repositories: [], + created_at: "2026-06-16T00:00:00Z", + updated_at: "2026-06-16T00:00:00Z", + }, + sessionId: "session-1", + initialState: {}, + initialTerminals: [], + }; +} + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe("TaskDetailRoute", () => { + it("waits for route data before mounting the task shell", async () => { + const routeData = deferred(); + mocks.fetchSessionDataForTask.mockReturnValueOnce(routeData.promise); + + render(); + + expect(screen.queryByTestId("kanban-task-shell")).toBeNull(); + + routeData.resolve(makeFetchedData()); + + await waitFor(() => { + expect(screen.getByTestId("kanban-task-shell").getAttribute("data-task-id")).toBe("task-1"); + }); + }); + + it("uses boot route data without fetching again", async () => { + render(); + + expect(mocks.fetchSessionDataForTask).not.toHaveBeenCalled(); + expect(screen.getByTestId("kanban-task-shell").getAttribute("data-task-id")).toBe("task-1"); + expect(screen.getByTestId("state-hydrator")).toBeTruthy(); + }); +}); diff --git a/apps/web/src/task-detail-route.tsx b/apps/web/src/task-detail-route.tsx new file mode 100644 index 0000000000..d7894cd0d2 --- /dev/null +++ b/apps/web/src/task-detail-route.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { StateHydrator } from "@/components/state-hydrator"; +import { KanbanTaskShell } from "@/app/tasks/[id]/kanban-task-shell"; +import { + extractInitialRepositories, + extractInitialScripts, + fetchSessionDataForTask, + type FetchedSessionData, +} from "@/lib/ssr/session-page-state"; + +type TaskDetailRouteProps = { + taskId: string; + sessionId?: string; + layout?: string | null; + simple?: string; + mode?: string; + initialData?: FetchedSessionData; +}; + +type TaskDetailRouteState = + | { status: "loading"; data: null } + | { status: "loaded"; data: FetchedSessionData } + | { status: "error"; data: null }; + +function routeDataMatchesTask( + data: FetchedSessionData | undefined, + taskId: string, +): data is FetchedSessionData { + return data?.task?.id === taskId; +} + +function initialRouteState( + initialData: FetchedSessionData | undefined, + taskId: string, +): TaskDetailRouteState { + if (routeDataMatchesTask(initialData, taskId)) { + return { status: "loaded", data: initialData }; + } + return { status: "loading", data: null }; +} + +export function TaskDetailRoute({ + taskId, + sessionId, + layout, + simple, + mode, + initialData, +}: TaskDetailRouteProps) { + const [routeState, setRouteState] = useState(() => + initialRouteState(initialData, taskId), + ); + + useEffect(() => { + if (routeDataMatchesTask(initialData, taskId)) { + setRouteState({ status: "loaded", data: initialData }); + return; + } + let cancelled = false; + setRouteState({ status: "loading", data: null }); + fetchSessionDataForTask(taskId) + .then((next) => { + if (!cancelled) setRouteState({ status: "loaded", data: next }); + }) + .catch((error) => { + if (!cancelled) { + console.warn( + "Could not load /t/:taskId route data; task page will fall back to client fetches:", + error instanceof Error ? error.message : String(error), + ); + setRouteState({ status: "error", data: null }); + } + }); + return () => { + cancelled = true; + }; + }, [initialData, taskId]); + + if (routeState.status === "loading") { + return
; + } + + const data = routeState.data; + const activeSessionId = sessionId ?? data?.sessionId ?? null; + const initialState = data?.initialState ?? null; + const task = data?.task ?? null; + + return ( + <> + {initialState ? ( + + ) : null} + + + ); +} diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index fc6d1c8f4f..544b4dc1d3 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -14,11 +14,6 @@ "isolatedModules": true, "jsx": "react-jsx", "incremental": true, - "plugins": [ - { - "name": "next" - } - ], "paths": { "@/*": ["./*"], "@kandev/ui": ["../packages/ui/src"], @@ -27,13 +22,6 @@ "@kandev/types": ["../packages/types/src"] } }, - "include": [ - "next-env.d.ts", - "**/*.ts", - "**/*.tsx", - ".next/types/**/*.ts", - ".next/dev/types/**/*.ts", - "**/*.mts" - ], + "include": ["**/*.ts", "**/*.tsx", "**/*.mts"], "exclude": ["node_modules", "e2e"] } diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts new file mode 100644 index 0000000000..80991ab41d --- /dev/null +++ b/apps/web/vite.config.ts @@ -0,0 +1,33 @@ +import path from "node:path"; +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [react()], + server: { + port: readPort(process.env.PORT), + strictPort: Boolean(process.env.PORT), + }, + preview: { + port: readPort(process.env.PORT), + strictPort: Boolean(process.env.PORT), + }, + resolve: { + alias: { + "@": path.resolve(__dirname, "."), + "@kandev/ui": path.resolve(__dirname, "../packages/ui/src"), + "@kandev/theme": path.resolve(__dirname, "../packages/theme/src"), + "@kandev/types": path.resolve(__dirname, "../packages/types/src"), + }, + }, + build: { + outDir: "dist", + emptyOutDir: true, + }, +}); + +function readPort(value: string | undefined): number | undefined { + if (!value) return undefined; + const port = Number(value); + return Number.isInteger(port) && port > 0 ? port : undefined; +} diff --git a/docs/cli.md b/docs/cli.md index eca8d01230..8dced3fe5b 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -12,7 +12,7 @@ flowchart TB run --> resolve["Resolve installed runtime"] dev --> makedev["make dev"] - start --> binary["Local binary + next start"] + start --> binary["Local binary
(embedded Vite assets)"] resolve --> envvar["KANDEV_BUNDLE_DIR
(Homebrew, tests)"] resolve --> npmpkg["@kdlbs/runtime-{platform}
(npm/npx)"] @@ -26,7 +26,7 @@ flowchart TB subgraph supervisor["Process Supervisor"] direction LR - s1["Manages backend + web processes"] + s1["Manages backend process"] s2["Handles graceful shutdown"] s3["Forwards signals"] end @@ -34,7 +34,7 @@ flowchart TB ## Overview -The Kandev CLI (`kandev`) is the primary way to run the Kandev application. It launches the backend and web processes from an installed runtime bundle and provides a unified interface for end users and developers. +The Kandev CLI (`kandev`) is the primary way to run the Kandev application. It launches the backend from an installed runtime bundle; the backend serves the web assets, API, and WebSocket gateway. ## Installation @@ -107,11 +107,12 @@ kandev dev **What happens:** 1. Locates the repo root (looks for `apps/backend` and `apps/web`). 2. Runs `make dev` for the backend (Go with hot-reload). -3. Runs `pnpm dev` for the web app (Next.js dev server). +3. Runs `pnpm dev` for the web app (Vite dev server). ### `kandev start` -Runs the application using local production builds. Requires running `make build` first. +Runs the application using the local production backend binary. Requires running +`make build` first so the Vite SPA is embedded into that binary. ```bash make build @@ -124,7 +125,7 @@ kandev start |--------|-------------|---------| | `--version`, `-V` | Print CLI version and exit | `kandev --version` | | `--port ` | Backend port (alias: `--backend-port`) | `--port 3000` | -| `--web-internal-port ` | Override internal Next.js port | `--web-internal-port 13000` | +| `--web-internal-port ` | Override internal web app port | `--web-internal-port 13000` | | `--verbose`, `-v` | Show info logs from backend + web | `--verbose` | | `--debug` | Show debug logs + agent message dumps | `--debug` | | `--help`, `-h` | Show help | `--help` | @@ -164,7 +165,7 @@ If the default port is in use, the CLI finds the next available port automatical |----------|-------------| | `KANDEV_BUNDLE_DIR` | Force the runtime bundle location (set by Homebrew wrapper) | | `KANDEV_PORT` / `KANDEV_BACKEND_PORT` | Backend port (CLI flag wins) | -| `KANDEV_WEB_PORT` | Internal Next.js port | +| `KANDEV_WEB_PORT` | Internal web app port | | `KANDEV_HEALTH_TIMEOUT_MS` | Override health check timeout (ms) | | `KANDEV_GITHUB_OWNER` / `KANDEV_GITHUB_REPO` | Override GitHub repo for `--runtime-version` downloads | | `KANDEV_GITHUB_TOKEN` | GitHub token for `--runtime-version` API access | diff --git a/docs/decisions/0021-go-served-spa-with-boot-state.md b/docs/decisions/0021-go-served-spa-with-boot-state.md new file mode 100644 index 0000000000..4fdf2a493a --- /dev/null +++ b/docs/decisions/0021-go-served-spa-with-boot-state.md @@ -0,0 +1,39 @@ +# 0021: Go-served SPA with boot state + +**Status:** accepted +**Date:** 2026-06-15 +**Area:** backend, frontend, cli + +## Context + +Kandev currently ships a Go backend plus a Next.js standalone web server. The CLI starts both processes in production, and the backend reverse-proxies browser routes to the Next server. Next server components also fetch request-time data and hydrate the Zustand store, so removing Next cannot regress first paint into loading spinners. + +## Decision + +Kandev will migrate the web runtime to a Vite-built React SPA served by the Go backend. Production will not run a Node.js web server. The Go backend will serve embedded frontend assets, route unknown browser paths to the SPA shell, and inject a JSON boot payload containing runtime config, route metadata, and `Partial` initial state. + +ADR-0022 specifies the packaging detail for those frontend assets: release-quality production builds embed the Vite output into the backend binary, with `KANDEV_WEB_DIST_DIR` retained as an explicit override for non-release workflows. + +ADR-0023 specifies the active-workspace persistence rule used by boot state: only the current workspace ID is stored in a server-readable cookie, while broader settings stay in backend settings, URL params, or localStorage. + +ADR-0024 specifies the development-mode counterpart: `make dev` still runs Vite internally for transforms and HMR, but browser document requests enter through Go so boot payload behavior matches production. + +The boot-state builder will live on the Go side and use backend services directly where practical. The SPA will initialize `StateProvider` from the injected payload, and client-side route transitions that require preloaded data will fetch the same payload shape from a backend app-state endpoint before rendering the destination view. + +Full React SSR from Go is explicitly out of scope. The server-rendered surface is the HTML shell and serialized data contract; React renders in the browser. + +Operational migration guidance for in-progress PRs lives in [`../nextjs-spa-migration.md`](../nextjs-spa-migration.md). + +## Consequences + +The production runtime becomes simpler: one Go web process serves HTTP APIs, WebSockets, static assets, SPA fallback, and boot data. Release artifacts no longer need Next.js standalone output or a Node process for the web UI. + +The migration must preserve the existing first-paint hydration contract. Some TypeScript SSR mapping logic will need a Go equivalent, so golden tests and narrow mappers are required to avoid drift. Feature gates, redirects, not-found behavior, and active-workspace cookie precedence must move from Next server components into Go boot-state builders. + +Development still uses Node-based tooling for Vite, TypeScript, linting, tests, and package management. This decision removes the production Node web runtime first; removing all Node tooling is a separate effort. + +## Alternatives Considered + +1. **Keep Next.js and optimize packaging.** This preserves existing behavior but keeps the production Node runtime dependency that this migration is meant to remove. +2. **Full React SSR from Go.** Rejected because it would require embedding or supervising a JavaScript runtime, or maintaining a second renderer, which defeats the runtime simplification. +3. **Pure client-side SPA fetches after mount.** Rejected as the target architecture because it would regress first paint for routes that currently SSR-fetch and hydrate Zustand. diff --git a/docs/decisions/0022-embedded-vite-assets.md b/docs/decisions/0022-embedded-vite-assets.md new file mode 100644 index 0000000000..13d3f7d350 --- /dev/null +++ b/docs/decisions/0022-embedded-vite-assets.md @@ -0,0 +1,27 @@ +# 0022: Embedded Vite Assets + +**Status:** accepted +**Date:** 2026-06-17 +**Area:** backend, frontend, cli + +## Context + +ADR-0021 moved Kandev toward a Go-served Vite SPA with boot state and no production Node.js web runtime. The first implementation still required a built `apps/web/dist` directory beside the backend binary in local `start` and release bundles, with `KANDEV_WEB_DIST_DIR` pointing the backend at those files. That kept frontend serving in Go, but it left production packaging dependent on an external asset directory. + +## Decision + +Production builds embed the Vite output into the backend binary. The root build runs `build-web`, syncs `apps/web/dist` into `apps/backend/internal/webapp/embedded/generated`, and then compiles `cmd/kandev` with those assets embedded via `go:embed`. The backend still accepts `KANDEV_WEB_DIST_DIR` as an explicit override for local debugging, previews, and tests. + +Release bundles no longer require a sibling `web/` directory for runtime. The CLI starts the backend without setting `KANDEV_WEB_DIST_DIR`, so the normal production path serves the embedded assets. + +## Consequences + +Production runtime packaging is simpler: the backend binary contains the SPA shell and static assets, while the CLI remains only a launcher. Build ordering now matters: the web build must be synced before compiling the backend for release-quality binaries. Backend-only builds remain possible because the embed package includes a minimal fallback shell, but those binaries are not release-quality frontend builds. + +Tests and preview flows can continue to set `KANDEV_WEB_DIST_DIR` when they need to serve a mutable filesystem dist. + +## Alternatives Considered + +1. **Keep filesystem-only assets.** Rejected because it preserves an avoidable runtime packaging dependency. +2. **Move Vite output permanently under `apps/backend`.** Rejected because it couples frontend build output to backend source layout and increases generated-file churn. +3. **Embed only for release builds with build tags.** Rejected as unnecessary complexity; an override env var is enough for non-release workflows. diff --git a/docs/decisions/0023-active-workspace-cookie.md b/docs/decisions/0023-active-workspace-cookie.md new file mode 100644 index 0000000000..e6b48e2907 --- /dev/null +++ b/docs/decisions/0023-active-workspace-cookie.md @@ -0,0 +1,27 @@ +# 0023: Active workspace cookie for boot state + +**Status:** accepted +**Date:** 2026-06-18 +**Area:** backend, frontend + +## Context + +The Vite migration moved first-paint data loading from Next server components to Go boot payloads. Active workspace selection still came from several places: URL params, user settings, an Office-named cookie, and kanban localStorage. Go cannot read localStorage while serving HTML, so a hard reload could hydrate a different workspace than the browser sidebar remembered. + +## Decision + +Kandev will use `kandev-active-workspace` as the general browser cookie for the current active workspace. The Go boot-state builders and SPA route bootstraps read it after explicit URL workspace params and before user settings. The legacy `office-active-workspace` cookie remains a read fallback and is still written for Office compatibility during migration. + +The cookie stores only the workspace ID. Broader preferences and filters should not move into cookies by default: durable user settings stay in backend user/workspace settings, shareable route state stays in URL params, and purely local view state may stay in localStorage. Add another cookie only when Go must know that value before serving the SPA shell. + +## Consequences + +Hard reloads, production boot payloads, and Vite dev app-state fetches can resolve the same active workspace. The cookie is sent on normal HTTP requests, so keeping it limited to a small non-sensitive ID avoids unnecessary request bloat and avoids leaking external integration data into the boot path. + +Vite dev uses a separate web port, so boot-state fetches must include credentials and the Go CORS middleware must echo the request origin when allowing credentials. Production remains same-origin because Go serves both the SPA and API. + +## Alternatives Considered + +1. **Keep kanban localStorage only.** Rejected because Go cannot read it for first-paint boot state. +2. **Use only user settings.** Rejected because Office intentionally does not overwrite kanban's shared `workspace_id`, and quick workspace switches should not necessarily mutate durable settings. +3. **Move all UI settings to cookies.** Rejected because cookies are sent on every request and should stay limited to values the server needs before serving HTML. diff --git a/docs/decisions/0024-go-fronted-vite-dev-mode.md b/docs/decisions/0024-go-fronted-vite-dev-mode.md new file mode 100644 index 0000000000..6378bde1ad --- /dev/null +++ b/docs/decisions/0024-go-fronted-vite-dev-mode.md @@ -0,0 +1,29 @@ +# 0024: Go-fronted Vite dev mode + +**Status:** accepted +**Date:** 2026-06-18 +**Area:** backend, frontend, cli + +## Context + +After migrating production to a Go-served Vite SPA, `make dev` still opened the Vite dev server directly. That kept React Fast Refresh, but it made development materially different from production: browser requests were cross-origin, route reloads did not naturally exercise Go shell rendering, and boot-payload behavior could differ from `make start`. + +## Decision + +Development mode uses the Go backend as the browser entrypoint. The CLI still starts Vite as an internal frontend transform and HMR server, but it prints and opens the backend URL. The backend's `KANDEV_WEB_INTERNAL_URL` path now uses `webapp.DevHandler`: HTML navigation requests fetch Vite's current `index.html`, inject the Go boot payload, and return the shell from Go; Vite module, static asset, and HMR requests are proxied through to the internal Vite server. + +Browser-facing Vite env vars that force cross-origin API calls, such as `VITE_KANDEV_API_PORT`, are unset in dev. Frontend code should use the same-origin runtime config injected by the Go boot payload, matching production. + +## Consequences + +Full reloads in dev now exercise the same Go route classification and boot-payload path as production while retaining Vite's fast TypeScript/React transform loop and HMR. CORS becomes less central to normal local development because the app URL is same-origin. + +Vite remains a development dependency. Replacing Vite's transform, React Fast Refresh, worker handling, CSS pipeline, and module graph with Go-native tooling is out of scope. If a production-like no-HMR loop is needed later, it can be added as a separate `vite build --watch` plus Go static-serving mode. + +## Alternatives Considered + +1. **Keep opening Vite directly.** Rejected because it preserves cross-origin browser behavior and misses Go boot-payload rendering on document requests. +2. **Replace Vite entirely in dev.** Rejected because rebuilding Vite's transform and HMR feature set in Go would add substantial custom tooling with little product value. +3. **Use `vite build --watch` and full-page reloads as default dev.** Rejected as the default because it is more production-like but loses React Fast Refresh. It remains a reasonable optional mode. + +No feature spec update is required; this is an internal development-runtime decision, not a user-facing product capability. diff --git a/docs/decisions/INDEX.md b/docs/decisions/INDEX.md index 2c1abbb69b..ada2e2c3e8 100644 --- a/docs/decisions/INDEX.md +++ b/docs/decisions/INDEX.md @@ -4,25 +4,29 @@ Architecture Decision Records (ADRs) for the Kandev project. Each decision captu Read individual ADRs for full context. Create new ones via `/record decision` or manually following the template in `0001-file-based-knowledge-system.md`. -| # | Title | Status | Area | Date | -|---|-------|--------|------|------| -| 0001 | [File-based knowledge system](0001-file-based-knowledge-system.md) | accepted | infra | 2026-03-28 | -| 0002 | [Host utility agentctl for sessionless ACP flows](0002-host-utility-agentctl-for-sessionless-flows.md) | accepted | backend | 2026-04-08 | -| 0003 | [executors_running as the single source of truth for agent_execution_id](0003-executors-running-as-execution-id-source-of-truth.md) | accepted | backend | 2026-05-03 | -| 0004 | [Task model unification — shared base, per-strategy meta, shared kernel](0004-task-model-unification.md) | proposed | backend, frontend | 2026-05-05 | -| 0005 | [Agent model unification — one `agents` table](0005-agent-model-unification.md) | proposed | backend, frontend | 2026-05-06 | -| 0006 | [Tier routing vs cheap_agent_profile_id coexistence](0006-tier-routing-vs-cheap-agent-profile-coexistence.md) | superseded | backend | 2026-05-11 | -| 0007 | [profiles.yaml — runtime defaults for prod / dev / e2e](0007-runtime-feature-flags.md) | accepted | backend, frontend | 2026-05-16 | -| 0008 | [DB upgrade safety - meta table, pre-migration backup, migration logging](0008-db-upgrade-safety.md) | accepted | backend | 2026-05-16 | -| 0009 | [Fail-closed GC semantics for filesystem and container cleanup](0009-fail-closed-gc-semantics.md) | accepted | backend | 2026-05-16 | -| 0010 | [Worktree copy-files — per-repo, idempotent, host-local](0010-worktree-copy-files.md) | accepted | backend, frontend | 2026-05-19 | -| 0011 | [Transient provider errors (529 Overloaded) auto-retry with visible backoff](0011-transient-provider-error-retry.md) | accepted | backend, frontend | 2026-05-30 | -| 0012 | [Service-only UI self-update](0012-service-only-self-update.md) | accepted | backend, frontend, cli | 2026-05-29 | -| 0013 | [Multi-branch task support — N (repo, branch) pairs per task](0013-multi-branch-tasks.md) | accepted | backend, frontend | 2026-06-01 | -| 0014 | [Per-CLI MCP server injection for passthrough mode](0014-passthrough-mcp-injection-strategies.md) | accepted | backend | 2026-05-29 | -| 0015 | [Explicit completion signal for auto-advance](0015-explicit-completion-signal-for-auto-advance.md) | proposed | backend, frontend | 2026-06-04 | -| 0016 | [Read-only absolute file paths](0016-observed-external-file-reads.md) | accepted | backend | 2026-06-14 | -| 0017 | [Resource metrics sampling](0017-resource-metrics-sampling.md) | accepted | backend, frontend, protocol | 2026-06-14 | -| 0018 | [Runtime settings overrides](0018-runtime-settings-overrides.md) | accepted | backend, frontend, cli | 2026-06-14 | -| 0019 | [Restart supervisor owns backend restarts](0019-restart-supervisor.md) | accepted | backend, frontend, cli | 2026-06-14 | -| 0020 | [Pi project MCP config injection](0020-pi-project-mcp-config-injection.md) | accepted | backend | 2026-06-16 | +| # | Title | Status | Area | Date | +| ---- | ----------------------------------------------------------------------------------------------------------------------------------- | ---------- | --------------------------- | ---------- | +| 0001 | [File-based knowledge system](0001-file-based-knowledge-system.md) | accepted | infra | 2026-03-28 | +| 0002 | [Host utility agentctl for sessionless ACP flows](0002-host-utility-agentctl-for-sessionless-flows.md) | accepted | backend | 2026-04-08 | +| 0003 | [executors_running as the single source of truth for agent_execution_id](0003-executors-running-as-execution-id-source-of-truth.md) | accepted | backend | 2026-05-03 | +| 0004 | [Task model unification — shared base, per-strategy meta, shared kernel](0004-task-model-unification.md) | proposed | backend, frontend | 2026-05-05 | +| 0005 | [Agent model unification — one `agents` table](0005-agent-model-unification.md) | proposed | backend, frontend | 2026-05-06 | +| 0006 | [Tier routing vs cheap_agent_profile_id coexistence](0006-tier-routing-vs-cheap-agent-profile-coexistence.md) | superseded | backend | 2026-05-11 | +| 0007 | [profiles.yaml — runtime defaults for prod / dev / e2e](0007-runtime-feature-flags.md) | accepted | backend, frontend | 2026-05-16 | +| 0008 | [DB upgrade safety - meta table, pre-migration backup, migration logging](0008-db-upgrade-safety.md) | accepted | backend | 2026-05-16 | +| 0009 | [Fail-closed GC semantics for filesystem and container cleanup](0009-fail-closed-gc-semantics.md) | accepted | backend | 2026-05-16 | +| 0010 | [Worktree copy-files — per-repo, idempotent, host-local](0010-worktree-copy-files.md) | accepted | backend, frontend | 2026-05-19 | +| 0011 | [Transient provider errors (529 Overloaded) auto-retry with visible backoff](0011-transient-provider-error-retry.md) | accepted | backend, frontend | 2026-05-30 | +| 0012 | [Service-only UI self-update](0012-service-only-self-update.md) | accepted | backend, frontend, cli | 2026-05-29 | +| 0013 | [Multi-branch task support — N (repo, branch) pairs per task](0013-multi-branch-tasks.md) | accepted | backend, frontend | 2026-06-01 | +| 0014 | [Per-CLI MCP server injection for passthrough mode](0014-passthrough-mcp-injection-strategies.md) | accepted | backend | 2026-05-29 | +| 0015 | [Explicit completion signal for auto-advance](0015-explicit-completion-signal-for-auto-advance.md) | proposed | backend, frontend | 2026-06-04 | +| 0016 | [Read-only absolute file paths](0016-observed-external-file-reads.md) | accepted | backend | 2026-06-14 | +| 0017 | [Resource metrics sampling](0017-resource-metrics-sampling.md) | accepted | backend, frontend, protocol | 2026-06-14 | +| 0018 | [Runtime settings overrides](0018-runtime-settings-overrides.md) | accepted | backend, frontend, cli | 2026-06-14 | +| 0019 | [Restart supervisor owns backend restarts](0019-restart-supervisor.md) | accepted | backend, frontend, cli | 2026-06-14 | +| 0020 | [Pi project MCP config injection](0020-pi-project-mcp-config-injection.md) | accepted | backend | 2026-06-16 | +| 0021 | [Go-served SPA with boot state](0021-go-served-spa-with-boot-state.md) | accepted | backend, frontend, cli | 2026-06-15 | +| 0022 | [Embedded Vite assets](0022-embedded-vite-assets.md) | accepted | backend, frontend, cli | 2026-06-17 | +| 0023 | [Active workspace cookie for boot state](0023-active-workspace-cookie.md) | accepted | backend, frontend | 2026-06-18 | +| 0024 | [Go-fronted Vite dev mode](0024-go-fronted-vite-dev-mode.md) | accepted | backend, frontend, cli | 2026-06-18 | diff --git a/docs/docker.md b/docs/docker.md index 38f55aeaba..37cbbab79d 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -132,7 +132,7 @@ docker run -p 38429:38429 \ ## Port -Kandev exposes a single port. The Go backend serves the API, WebSocket, and reverse-proxies the Next.js frontend — all on one port: +Kandev exposes a single port. The Go backend serves the API, WebSocket, static SPA assets, and page boot data — all on one port: | Port | Service | |------|---------| diff --git a/docs/k8s.md b/docs/k8s.md index 4ee88039a5..53967abd00 100644 --- a/docs/k8s.md +++ b/docs/k8s.md @@ -80,7 +80,7 @@ kubectl port-forward svc/kandev 38429:38429 # Open http://localhost:38429 ``` -**Ingress**: Edit `k8s/ingress.yaml` to set your domain, then apply. The ingress routes all traffic to the backend on port 38429 (the Go backend reverse-proxies the Next.js frontend internally). +**Ingress**: Edit `k8s/ingress.yaml` to set your domain, then apply. The ingress routes all traffic to the backend on port 38429; the Go backend serves API, WebSocket, and SPA traffic on that port. ### Custom Domain / Reverse Proxy diff --git a/docs/nextjs-spa-migration.md b/docs/nextjs-spa-migration.md new file mode 100644 index 0000000000..9ce9d6cee5 --- /dev/null +++ b/docs/nextjs-spa-migration.md @@ -0,0 +1,112 @@ +# Next.js to Go-served SPA migration guide + +Use this guide when rebasing or finishing an in-progress PR that was written +against the old Next.js web runtime. The target architecture is ADR-0020: +production serves a Vite React SPA from the Go backend, and Go injects the boot +payload needed for first paint. + +## Target contract + +- Production does not run a Next.js server or any Node.js web runtime. +- `apps/web` may still use Node tooling for Vite, TypeScript, linting, tests, + and development. +- The Go backend serves the SPA shell, static Vite assets, APIs, WebSockets, and + `/api/v1/app-state`. +- Go injects `window.__KANDEV_BOOT_PAYLOAD__` before React mounts. The payload + contains route metadata, runtime config, and `Partial` initial + state. +- Data that was visible on first paint in a Next server component must move to + the Go boot payload or a route bootstrap that can skip fetching when the store + is already hydrated. + +## Rebase workflow + +1. Resolve conflicts by preserving SPA adapters and Go boot-state behavior. +2. Audit the rebased changes for new `next/*` imports, mocks, server component + fetches, `redirect()`, `notFound()`, `cookies()`, and `headers()`. +3. For each new store read added by the incoming PR, decide whether it affects + first paint. If it does, add that data to the Go boot payload. +4. Add or update focused tests before broad verification. +5. Re-run the Next audit command before handing off. + +Useful audit commands: + +```bash +rtk rg -n 'from "next/|from '\''next/|vi\.mock\("next/|next/link|next/navigation|next/image|next/dynamic|next/headers|next-themes|styled-jsx|eslint-config-next|next\.config' apps/web apps/packages/ui apps/pnpm-lock.yaml apps/package.json +rtk rg -n 'redirect\(|notFound\(|cookies\(|headers\(|StateProvider initialState|searchParams|params:' apps/web/app +``` + +The first command should have no production `next/*` imports or tests mocking +Next modules. Comments and unrelated variable names are acceptable only after +manual review. + +## Import replacements + +| Old import | Replacement | +| ----------------- | -------------------------------- | +| `next/link` | `@/components/routing/app-link` | +| `next/navigation` | `@/lib/routing/client-router` | +| `next/image` | `@/components/routing/app-image` | +| `next/dynamic` | `@/lib/routing/client-dynamic` | +| `next-themes` | `@/components/theme/app-theme` | + +When replacing `next/navigation` in tests, mock +`@/lib/routing/client-router`, not the old Next module. + +If a component needs route params from a new dynamic SPA route, update +`paramsForPath` in `apps/web/lib/routing/client-router.ts` and add coverage in +`apps/web/lib/routing/client-router.test.ts`. + +## Migrating server-side data + +Old Next pages often did this: + +- Fetch request-time data in `app/**/page.tsx` or `layout.tsx`. +- Pass it to a client component as props. +- Hydrate Zustand with ``. +- Use `redirect()` or `notFound()` for route decisions. + +Migrate that as follows: + +1. Identify the exact store fields the page needs before first paint. +2. Add route classification or params in `apps/backend/internal/webapp/routes.go` + if Go needs to distinguish the route. +3. Add the data to `apps/backend/cmd/kandev/boot_state.go`. Prefer backend + services directly over self-calling HTTP APIs. +4. If an HTTP handler already returns the same data shape, extract a small DTO + mapper in that backend package and reuse it from both the handler and the + boot-state builder. +5. Keep JSON shape aligned with the frontend store/API types. If the existing + API shape is snake_case, the boot payload should use the same shape unless + the store already expects otherwise. +6. Update the SPA route/client bootstrap so it skips its client fetch when the + store already has boot data. +7. Add tests for the Go mapper or boot builder, and a frontend test proving the + client does not refetch when boot state is present. + +Optional or below-the-fold data may still fetch after mount, but do not regress +routes that previously rendered useful data without an initial loading spinner. + +## Routing and redirects + +- Add browser route rendering in `apps/web/src/spa-routes.tsx`, + `apps/web/src/settings-routes.tsx`, or `apps/web/src/office-routes.tsx`. +- Use `useRouter().replace()` for client-side redirect behavior. +- Move server-only gating that must happen before first paint into Go boot-state + route logic or expose enough boot state for the SPA to render the correct + initial screen. +- Replace `notFound()` with the route fallback pattern already used by the SPA + route modules, or return an explicit load/error state from the client route. + +## Verification checklist + +Run focused tests for the files you touched, then at minimum: + +```bash +rtk go test ./cmd/kandev ./internal/webapp +rtk pnpm --dir apps --filter @kandev/web typecheck +rtk pnpm --dir apps --filter @kandev/web lint +``` + +For data-hydration changes, include one backend test for the boot payload or DTO +shape and one frontend test that starts with hydrated store state. diff --git a/docs/remote-cloud-environment.md b/docs/remote-cloud-environment.md index 24fb9a0cbe..ac14507638 100644 --- a/docs/remote-cloud-environment.md +++ b/docs/remote-cloud-environment.md @@ -38,7 +38,7 @@ Without these, `tsc` fails with missing module errors for `@/generated/release-n ## Running dev mode -`make dev` from the repo root builds the backend and starts both the Go backend (port 38429) and Next.js frontend (port 37429). The CLI launcher sets `KANDEV_DEBUG_DEV_MODE=true` which activates the `dev` profile from `profiles.yaml`, enabling mock agent and other dev conveniences. No external services (database, message queue, Docker) are needed — SQLite is embedded and the event bus runs in-memory. +`make dev` from the repo root builds the backend and starts both the Go backend (port 38429) and Vite frontend (port 37429). The CLI launcher sets `KANDEV_DEBUG_DEV_MODE=true` which activates the `dev` profile from `profiles.yaml`, enabling mock agent and other dev conveniences. No external services (database, message queue, Docker) are needed — SQLite is embedded and the event bus runs in-memory. ## Key commands diff --git a/docs/specs/homebrew-core/spec.md b/docs/specs/homebrew-core/spec.md index aa94a34d97..1a829b6152 100644 --- a/docs/specs/homebrew-core/spec.md +++ b/docs/specs/homebrew-core/spec.md @@ -16,9 +16,9 @@ Kandev is currently installable via `brew install kdlbs/kandev/kandev` from the ## What -- Author a homebrew-core-compliant `Formula/kandev.rb` that **builds entirely from source** — Go backend (cgo + sqlite via `mattn/go-sqlite3`), Next.js standalone web bundle, and the TypeScript CLI esbuild bundle. +- Author a homebrew-core-compliant `Formula/kandev.rb` that **builds entirely from source** — Go backend (cgo + sqlite via `mattn/go-sqlite3`), Vite web bundle, and the TypeScript CLI esbuild bundle. - Source comes from the GitHub-generated tag archive (`https://github.com/kdlbs/kandev/archive/refs/tags/vX.Y.Z.tar.gz`); per-release sha256 is captured at submission/bump time. -- Build deps: `go => :build`, `pnpm => :build`. Runtime dep: `node` (CLI launcher + Next.js standalone server). `uses_from_macos "rsync" => :build` (used by `scripts/release/package-web.sh` to dereference symlinks in the Next.js standalone output) and `uses_from_macos "sqlite"` (cgo sqlite3 link) — both ship with macOS, only installed via Homebrew on Linux. +- Build deps: `go => :build`, `pnpm => :build`. Runtime dep: `node` (CLI launcher). `uses_from_macos "sqlite"` (cgo sqlite3 link) ships with macOS and is only installed via Homebrew on Linux. - Install layout: `libexec/{bin,web,cli}` plus a single `bin/kandev` wrapper produced by `write_env_script`, setting `KANDEV_BUNDLE_DIR=` and `KANDEV_VERSION=`. This is the contract `apps/cli/src/runtime.ts` already honors. - `livecheck do; url :stable; regex(/^v?(\d+(?:\.\d+)+)$/i); end` — Git strategy (the default for `url :stable`) checks tags directly and avoids the GitHub API rate limit. - Test block: spawns `libexec/bin/kandev` with an isolated `KANDEV_HOME_DIR` and random `KANDEV_SERVER_PORT`, polls `/api/v1/system/health` until the response contains `"healthy"` (60s timeout), then shuts the backend down. Also asserts the formula `version` appears in `bin/kandev --version`. diff --git a/docs/test_e2e_web.md b/docs/test_e2e_web.md index dd1806a08b..4c9594f332 100644 --- a/docs/test_e2e_web.md +++ b/docs/test_e2e_web.md @@ -1,6 +1,6 @@ # Web E2E Tests -Browser-based end-to-end tests that exercise the full stack: Next.js frontend, Go backend, and mock agent. Tests run in parallel with complete isolation — each Playwright worker gets its own backend process, frontend process, database, and temp directory. +Browser-based end-to-end tests that exercise the full stack: Go-served Vite SPA, Go backend, and mock agent. Tests run in parallel with complete isolation — each Playwright worker gets its own backend process, database, and temp directory. **Location:** `apps/web/e2e/` @@ -11,8 +11,7 @@ Browser-based end-to-end tests that exercise the full stack: Next.js frontend, G │ Worker 0 Worker 1 │ │ ┌──────────────────────┐ ┌──────────────────────┐│ │ │ Backend :18080 │ │ Backend :18081 ││ -│ │ Frontend :13000 │ │ Frontend :13001 ││ -│ │ SSR → localhost:18080│ │ SSR → localhost:18081││ +│ │ Web UI :18080 │ │ Web UI :18081 ││ │ │ HOME=/tmp/e2e-0 │ │ HOME=/tmp/e2e-1 ││ │ │ own SQLite DB │ │ own SQLite DB ││ │ │ mock-agent only │ │ mock-agent only ││ @@ -22,8 +21,8 @@ Browser-based end-to-end tests that exercise the full stack: Next.js frontend, G ``` - **One backend per worker** — port `18080 + workerIndex`, isolated temp HOME, own SQLite DB -- **One frontend per worker** — port `13000 + workerIndex`, SSR routed to that worker's backend via `KANDEV_API_BASE_URL` -- **Shared `.next/` build** — all frontend processes serve from the same pre-built output (read-only) +- **Web UI served by backend** — each worker's backend serves Vite `dist` and boot data on the same port as the API +- **Shared `dist/` build** — all workers serve from the same pre-built Vite output (read-only) - **Mock agent** — deterministic responses, no API keys needed - **Mock GitHub client** — reports as authenticated, returns configurable data via REST API - **No external dependencies** — no Docker, no GitHub tokens, no real git credentials @@ -36,7 +35,7 @@ Build the backend binaries and web app before running tests: make build-backend build-web ``` -This produces `apps/backend/bin/kandev`, `apps/backend/bin/mock-agent`, and `apps/web/.next/`. The global setup script verifies all exist. +This produces `apps/backend/bin/kandev`, `apps/backend/bin/mock-agent`, and `apps/web/dist/`. The global setup script verifies all exist. ## Running Tests @@ -93,7 +92,7 @@ apps/web/e2e/ ### Backend + frontend fixture (`fixtures/backend.ts`) -Each Playwright worker spawns an isolated `kandev` backend and a dedicated `next start` frontend: +Each Playwright worker spawns an isolated `kandev` backend that also serves the SPA: **Backend:** 1. Creates a temp directory as `HOME` with its own `.gitconfig`, data dir, and SQLite DB @@ -109,12 +108,12 @@ Each Playwright worker spawns an isolated `kandev` backend and a dedicated `next 3. Spawns `apps/backend/bin/kandev` and waits for `/health` to return 200 **Frontend:** -1. Spawns `npx next start --port {13000 + workerIndex}` from `apps/web/` -2. Sets `KANDEV_API_BASE_URL` to the worker's backend URL so SSR routes correctly -3. Waits for the frontend to become healthy -4. All `next start` processes share the same `.next/` build output (read-only) +1. Sets `KANDEV_WEB_DIST_DIR` to `apps/web/dist` +2. Uses the worker backend URL as the browser URL +3. The backend serves `index.html`, static assets, and injected boot payloads +4. All workers share the same `dist/` build output (read-only) -**Teardown:** sends SIGTERM to frontend then backend, falls back to SIGKILL after 5s, cleans up temp dir. +**Teardown:** sends SIGTERM to the backend process group, falls back to SIGKILL after 7s, cleans up temp dir. ### Test base (`fixtures/test-base.ts`) @@ -288,7 +287,7 @@ The GitHub Actions workflow (`.github/workflows/e2e-tests.yml`) runs on pushes a **Tests fail with "Backend did not become healthy" or "Service did not become healthy"** - Ensure `make build-backend build-web` completed successfully -- Check that `apps/backend/bin/kandev`, `apps/backend/bin/mock-agent`, and `apps/web/.next/` exist +- Check that `apps/backend/bin/kandev`, `apps/backend/bin/mock-agent`, and `apps/web/dist/` exist - Run with `E2E_DEBUG=1` to see backend and frontend stderr **Tests fail with "Cannot find module" or import errors** diff --git a/scripts/dev-isolated b/scripts/dev-isolated index 7847cf718d..86209e7ac4 100755 --- a/scripts/dev-isolated +++ b/scripts/dev-isolated @@ -28,7 +28,7 @@ # scripts/dev-isolated [options] # # Options: -# --web Also start the Next.js dev frontend (default: backend only) +# --web Also start the Vite dev frontend (default: backend only) # --backend-port N Force the backend port (default: auto from base 48429) # --web-port N Force the web port (default: auto from base 47429) # --agentctl-port N Force the agentctl base port (default: auto from base 49429) @@ -281,10 +281,10 @@ if [[ "$healthy" -ne 1 ]]; then exit 1 fi -# --- Optionally launch the web frontend (Next.js dev) --- +# --- Optionally launch the web frontend (Vite dev) --- WEB_STARTED=0 if [[ "$WANT_WEB" -eq 1 ]]; then - echo "dev-isolated: starting web (next dev) on :$WEB_PORT ..." + echo "dev-isolated: starting web (vite dev) on :$WEB_PORT ..." env \ NEXT_PUBLIC_KANDEV_API_PORT="$BACKEND_PORT" \ KANDEV_API_BASE_URL="$BACKEND_URL" \ diff --git a/scripts/kandev-instances b/scripts/kandev-instances index 09b4e62b62..cbd2b4fa2a 100755 --- a/scripts/kandev-instances +++ b/scripts/kandev-instances @@ -7,10 +7,9 @@ # # Sources: # - backend PID + listening port: `ss -tlnp` (falls back to `lsof`) -# - web port: the separate `next-server` listener pointing at this backend -# (matched via its KANDEV_API_BASE_URL / NEXT_PUBLIC_KANDEV_API_PORT, port -# read from `ss`); falls back to the backend's KANDEV_WEB_INTERNAL_URL -# (else `?`). The backend's own port is never the web port. +# - web port: a separate Vite dev listener from KANDEV_WEB_INTERNAL_URL when +# configured and reachable; otherwise the backend port, because production +# serves the SPA from the Go backend. # - agentctl port: KANDEV_AGENT_STANDALONE_PORT / AGENTCTL_PORT in environ, # else the `-port=` flag of a child `agentctl` process (else `?`) # - HOME_DIR: KANDEV_HOME_DIR in environ, else HOME, else `?` @@ -97,75 +96,22 @@ port_has_listener() { # Resolve the web port for a backend. # -# The web UI is served by a SEPARATE `next-server` process, not the backend, so -# the backend's own listening port is never the web port. We resolve in two -# steps, preferring the authoritative source: -# -# 1. The next-server process that points back at THIS backend. Each web -# process is launched with KANDEV_API_BASE_URL=http://host: -# (and/or NEXT_PUBLIC_KANDEV_API_PORT=) and its own -# PORT=. We find the next-server whose backend-pointing env -# matches this backend's port, then read its ACTUAL listening port from ss -# (authoritative — the real socket, not an env hint). -# 2. Fallback: the backend's KANDEV_WEB_INTERNAL_URL=http://host:, -# which the backend uses to reach the web server. Parse the trailing -# : (the host:port pair, never the scheme's colon). -# -# If neither resolves, print `?` rather than guessing a wrong port (e.g. the -# backend port from PORT / NEXT_PUBLIC_KANDEV_API_PORT, which are the backend's). +# Production serves the SPA from the backend port. In development, a separate +# Vite server may still be configured via KANDEV_WEB_INTERNAL_URL; report that +# port only when something is actually listening there. resolve_web_port() { local backend_pid="$1" backend_port="${2:-}" port - port="$(resolve_web_port_via_next_server "$backend_port")" - if [[ -z "$port" ]]; then - local url cand="" - url="$(read_environ_var "$backend_pid" KANDEV_WEB_INTERNAL_URL)" - # Match the LAST : so an IPv6 host or path can't fool us; the trailing - # host:port is what we want, not the scheme separator (http:). - if [[ "$url" =~ :([0-9]+)([/?#].*)?$ ]]; then - cand="${BASH_REMATCH[1]}" - fi - # KANDEV_WEB_INTERNAL_URL is the web URL the backend was CONFIGURED with — it - # may name a web port that never started (e.g. a backend-only dev-isolated - # run). Only trust it if something is actually listening there; otherwise the - # web UI isn't reachable, so report `?` rather than a dead port. - if [[ -n "$cand" ]] && port_has_listener "$cand"; then - port="$cand" - fi + local url cand="" + url="$(read_environ_var "$backend_pid" KANDEV_WEB_INTERNAL_URL)" + # Match the LAST : so an IPv6 host or path can't fool us; the trailing + # host:port is what we want, not the scheme separator (http:). + if [[ "$url" =~ :([0-9]+)([/?#].*)?$ ]]; then + cand="${BASH_REMATCH[1]}" fi - printf '%s\n' "${port:-?}" -} - -# Find the next-server process bound to this backend and return its listening -# port (the web port). Empty output means "not found". -resolve_web_port_via_next_server() { - local backend_port="$1" - [[ -n "$backend_port" ]] || return 0 - command -v ss >/dev/null 2>&1 || return 0 - - # Enumerate listening next-server sockets as "pid port" pairs. - local pid web_port api_port api_url - while read -r _ _ _ local_addr _ procinfo; do - case "$procinfo" in - *'next-server'*) ;; - *) continue ;; - esac - # sed (not grep) so a no-match yields empty + exit 0 instead of tripping - # `set -o pipefail` when a socket line carries no pid= field. - pid="$(printf '%s\n' "$procinfo" | sed -nE 's/.*pid=([0-9]+).*/\1/p' | head -1)" - web_port="${local_addr##*:}" - [[ -n "$pid" && -n "$web_port" ]] || continue - # Does this web process point back at OUR backend port? - api_port="$(read_environ_var "$pid" NEXT_PUBLIC_KANDEV_API_PORT)" - if [[ -z "$api_port" ]]; then - api_url="$(read_environ_var "$pid" KANDEV_API_BASE_URL)" - [[ "$api_url" =~ :([0-9]+)([/?#].*)?$ ]] && api_port="${BASH_REMATCH[1]}" - fi - if [[ "$api_port" == "$backend_port" ]]; then - printf '%s\n' "$web_port" - return 0 - fi - done < <(ss -tlnpH 2>/dev/null) - return 0 + if [[ -n "$cand" ]] && port_has_listener "$cand"; then + port="$cand" + fi + printf '%s\n' "${port:-$backend_port}" } # Emit "PID PORT" pairs for every listening kandev backend socket. diff --git a/scripts/kandev-kill b/scripts/kandev-kill index ca1fa757b6..0fc8becc80 100755 --- a/scripts/kandev-kill +++ b/scripts/kandev-kill @@ -1,6 +1,6 @@ #!/usr/bin/env bash # kandev-kill — terminate exactly ONE kandev instance: the backend listening on -# a given port, plus its agentctl child(ren) and its matching web/next server. +# a given port, plus its agentctl child(ren) and matching Vite dev server. # # This NEVER does a blanket `pkill kandev`. It resolves the backend PID from the # port, confirms the process is actually a kandev backend, computes the exact @@ -210,8 +210,8 @@ collect_descendants() { mapfile -t TREE < <(collect_descendants "$BACKEND_PID") -# The web/next server is typically a sibling (spawned by the launcher, not a -# child of the backend). If a pidfile sidecar lists it, include it. +# The optional Vite dev server is typically a sibling (spawned by the launcher, +# not a child of the backend). If a pidfile sidecar lists it, include it. WEB_PID="" if [[ -n "$PIDFILE" && -f "${PIDFILE%.pid}.web.pid" ]]; then WEB_PID="$(tr -d '[:space:]' <"${PIDFILE%.pid}.web.pid" 2>/dev/null || true)" diff --git a/scripts/release/kandev.rb b/scripts/release/kandev.rb index e3b204d096..ee32c90e5a 100644 --- a/scripts/release/kandev.rb +++ b/scripts/release/kandev.rb @@ -6,7 +6,7 @@ class Kandev < Formula license "AGPL-3.0-only" version "__VERSION__" - # Node is required: the CLI launcher and Next.js standalone server both need it. + # Node is required for the CLI launcher. depends_on "node" on_macos do diff --git a/scripts/release/package-bundle.sh b/scripts/release/package-bundle.sh index a0d0582a49..6afb39b2c1 100755 --- a/scripts/release/package-bundle.sh +++ b/scripts/release/package-bundle.sh @@ -1,24 +1,15 @@ #!/usr/bin/env bash # Finalize the dist/kandev/ release layout from already-built pieces. # Caller must have run, in this order: -# - scripts/release/package-web.sh (produces dist/web/) # - scripts/release/package-cli.sh (produces dist/kandev/cli/) -# - go build ./cmd/{kandev,agentctl} -o dist/kandev/bin/... -# After this: dist/kandev/{bin,web,cli} is ready to install or tar. +# - go build ./cmd/{kandev,agentctl} -o dist/kandev/bin/... with Vite +# assets synced into the backend embed package before building kandev. +# After this: dist/kandev/{bin,cli} is ready to install or tar. set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -WEB_SRC="$ROOT_DIR/dist/web" BUNDLE="$ROOT_DIR/dist/kandev" -if [ ! -d "$WEB_SRC" ]; then - echo "Missing $WEB_SRC; run scripts/release/package-web.sh first" >&2 - exit 1 -fi - -mkdir -p "$BUNDLE/web" -cp -R "$WEB_SRC/." "$BUNDLE/web/" - if [ ! -f "$BUNDLE/cli/bin/cli.js" ]; then echo "Missing $BUNDLE/cli/bin/cli.js; run scripts/release/package-cli.sh first" >&2 exit 1 diff --git a/scripts/release/package-web.sh b/scripts/release/package-web.sh index 130df7a661..231d2a2c8f 100755 --- a/scripts/release/package-web.sh +++ b/scripts/release/package-web.sh @@ -1,62 +1,24 @@ #!/usr/bin/env bash -# Used by .github/workflows/release.yml to package the Next.js standalone output. +# Used by .github/workflows/release.yml to package the Vite SPA output. set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" WEB_DIR="$ROOT_DIR/apps/web" OUT_DIR="$ROOT_DIR/dist/web" -echo "Packaging Next.js standalone output for release..." +echo "Packaging Vite web output for release..." -if [ ! -d "$WEB_DIR/.next/standalone" ]; then - echo "Missing standalone output at $WEB_DIR/.next/standalone" - echo "Run: pnpm -C apps --filter @kandev/web build" +if [ ! -f "$WEB_DIR/dist/index.html" ]; then + echo "Missing Vite output at $WEB_DIR/dist/index.html" + echo "Run: pnpm -C apps --filter @kandev/web build:vite" exit 1 fi +"$ROOT_DIR/scripts/release/validate-web-bundle.sh" "$WEB_DIR/dist" rm -rf "$OUT_DIR" mkdir -p "$OUT_DIR" -# Copy with symlinks dereferenced so pnpm's .pnpm/ structure survives -# archiving (symlinks cause issues on Windows even inside tar). The -# standalone output may contain broken symlinks from partial tracing — -# --safe-links skips those and rsync exits with code 23 (partial transfer), -# which is expected. -rsync -a --copy-links --safe-links "$WEB_DIR/.next/standalone/" "$OUT_DIR/" || { - rc=$? - if [ "$rc" -eq 23 ]; then - echo "Warning: some broken symlinks were skipped (expected for standalone output)" - else - exit "$rc" - fi -} -# pnpm's .pnpm virtual store keeps hoisted packages in -# node_modules/.pnpm/node_modules/ which is unreachable via normal Node -# module resolution from web/node_modules/next/. Copy everything into -# web/node_modules/ so Next.js (and its deps) can find them at runtime. -PNPM_HOISTED="$OUT_DIR/node_modules/.pnpm/node_modules" -NEXT_MODULES="$OUT_DIR/web/node_modules" -if [ -d "$PNPM_HOISTED" ] && [ -d "$NEXT_MODULES" ]; then - for pkg in "$PNPM_HOISTED"/*; do - name="$(basename "$pkg")" - if [ ! -e "$NEXT_MODULES/$name" ]; then - cp -R "$pkg" "$NEXT_MODULES/$name" - fi - done - echo "Hoisted pnpm packages into web/node_modules/" - # Remove the .pnpm virtual store — everything needed is now in - # web/node_modules/ and the root node_modules/ is unreachable from - # the server. Removing it avoids 3-4x duplication in the archive. - rm -rf "$OUT_DIR/node_modules" -fi - -# Static assets and public/ must be alongside server.js (inside web/) -# so Next.js can serve them. The standalone trace puts build artifacts -# at web/.next/ but doesn't include static/ — copy it there. -cp -R "$WEB_DIR/.next/static" "$OUT_DIR/web/.next/" - -if [ -d "$WEB_DIR/public" ]; then - cp -R "$WEB_DIR/public" "$OUT_DIR/web/public" -fi +cp -R "$WEB_DIR/dist/." "$OUT_DIR/" +"$ROOT_DIR/scripts/release/validate-web-bundle.sh" "$OUT_DIR" echo "Web bundle packaged at $OUT_DIR" diff --git a/scripts/release/validate-web-bundle.sh b/scripts/release/validate-web-bundle.sh new file mode 100755 index 0000000000..53aab3bd9f --- /dev/null +++ b/scripts/release/validate-web-bundle.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Validate that a directory contains a complete Vite SPA bundle. +set -euo pipefail + +if [ "$#" -ne 1 ]; then + echo "usage: $0 " >&2 + exit 2 +fi + +WEB_DIR="$1" + +if [ ! -f "$WEB_DIR/index.html" ]; then + echo "Missing Vite SPA index: $WEB_DIR/index.html" >&2 + exit 1 +fi + +if [ ! -d "$WEB_DIR/assets" ]; then + echo "Missing Vite SPA assets directory: $WEB_DIR/assets" >&2 + exit 1 +fi + +if ! find "$WEB_DIR/assets" -type f -name '*.js' -print -quit | grep -q .; then + echo "Missing Vite SPA JavaScript assets under: $WEB_DIR/assets" >&2 + exit 1 +fi