487 lines
19 KiB
Go
487 lines
19 KiB
Go
package backendapp
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/kandev/kandev/internal/automation"
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
"github.com/kandev/kandev/internal/github"
|
|
"github.com/kandev/kandev/internal/gitlab"
|
|
taskmodels "github.com/kandev/kandev/internal/task/models"
|
|
sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite"
|
|
taskservice "github.com/kandev/kandev/internal/task/service"
|
|
)
|
|
|
|
// errKey is the JSON field used for error responses from the E2E endpoints.
|
|
const errKey = "error"
|
|
const statusKey = "status"
|
|
|
|
// registerE2EResetRoutes registers the E2E test-only endpoints.
|
|
// The endpoints are available when KANDEV_MOCK_AGENT is "true" or "only" (dev/E2E modes).
|
|
func registerE2EResetRoutes(
|
|
router *gin.Engine,
|
|
repo *sqliterepo.Repository,
|
|
taskSvc *taskservice.Service,
|
|
automationSvc *automation.Service,
|
|
githubSvc *github.Service,
|
|
gitlabSvc *gitlab.Service,
|
|
log *logger.Logger,
|
|
) {
|
|
mockMode := os.Getenv("KANDEV_MOCK_AGENT")
|
|
if mockMode != "true" && mockMode != "only" {
|
|
return
|
|
}
|
|
|
|
api := router.Group("/api/v1/e2e")
|
|
api.DELETE("/reset/:workspaceId", handleE2EReset(repo, taskSvc, automationSvc, githubSvc, gitlabSvc, log))
|
|
// Hidden-workflow factory: lets E2E tests cover the system-only
|
|
// workflow path (e.g. improve-kandev) without depending on the real
|
|
// bootstrap endpoint, which clones from GitHub and shells out to gh.
|
|
api.POST("/hidden-workflow", handleE2ECreateHiddenWorkflow(taskSvc, log))
|
|
// Automation seeding helpers for E2E tests that need runs without going
|
|
// through the WS API (works on any Node version).
|
|
if automationSvc != nil {
|
|
api.POST("/automations", handleE2ECreateAutomation(automationSvc, log))
|
|
api.POST("/automation-runs", handleE2ECreateAutomationRun(automationSvc, log))
|
|
}
|
|
// Seeds a task_sessions row directly (e.g. a CANCELLED primary session)
|
|
// so tests can assert on session-state-derived behavior without a full
|
|
// agent stop/resume cycle.
|
|
api.POST("/task-sessions", handleE2ECreateTaskSession(repo, log))
|
|
// Force-sets a session's read cursor, bypassing the production mark-read
|
|
// endpoint's monotonic (forward-only) guard, so specs can deterministically
|
|
// seed "the user last read up through an earlier message" without a real
|
|
// background agent turn.
|
|
api.PATCH("/task-sessions/:id/read-cursor", handleE2ESetSessionReadCursor(repo, log))
|
|
|
|
log.Info("registered E2E endpoints (test-only)")
|
|
}
|
|
|
|
func handleE2EReset(
|
|
repo *sqliterepo.Repository,
|
|
taskSvc *taskservice.Service,
|
|
automationSvc *automation.Service,
|
|
githubSvc *github.Service,
|
|
gitlabSvc *gitlab.Service,
|
|
log *logger.Logger,
|
|
) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
workspaceID := c.Param("workspaceId")
|
|
|
|
// Optional: comma-separated workflow IDs to keep (e.g., the seeded workflow).
|
|
var keepWorkflowIDs []string
|
|
if raw := c.Query("keep_workflows"); raw != "" {
|
|
keepWorkflowIDs = strings.Split(raw, ",")
|
|
}
|
|
|
|
ctx := c.Request.Context()
|
|
|
|
// Wipe routing state so the office-routing-* specs don't leak
|
|
// degraded health rows / route attempts / parked runs between
|
|
// each other. Office tables live in the same SQLite db so the
|
|
// task repo's connection can hit them. Tables are no-ops when
|
|
// the office routing feature isn't enabled.
|
|
for _, q := range []string{
|
|
`DELETE FROM office_run_route_attempts WHERE run_id IN (SELECT id FROM runs WHERE agent_profile_id IN (SELECT id FROM agent_profiles WHERE workspace_id = ?))`,
|
|
`DELETE FROM runs WHERE agent_profile_id IN (SELECT id FROM agent_profiles WHERE workspace_id = ?)`,
|
|
`DELETE FROM office_provider_health WHERE workspace_id = ?`,
|
|
`DELETE FROM office_workspace_routing WHERE workspace_id = ?`,
|
|
} {
|
|
if _, err := repo.DB().ExecContext(ctx, q, workspaceID); err != nil {
|
|
// Best-effort: log + continue. Some routing tables may
|
|
// not exist when the feature is gated off.
|
|
log.Warn("e2e reset: routing cleanup failed", zap.String("sql", q), zap.Error(err))
|
|
}
|
|
}
|
|
if _, err := repo.DB().ExecContext(ctx, `DELETE FROM runtime_flag_overrides`); err != nil {
|
|
log.Warn("e2e reset: runtime flag override cleanup failed", zap.Error(err))
|
|
}
|
|
if _, err := repo.DB().ExecContext(ctx, `DELETE FROM github_workspace_settings WHERE workspace_id = ?`, workspaceID); err != nil {
|
|
log.Warn("e2e reset: GitHub workspace settings cleanup failed", zap.Error(err))
|
|
}
|
|
if err := deleteGitHubAuthForReset(ctx, repo.DB(), workspaceID); err != nil {
|
|
log.Error("e2e reset: GitHub authentication cleanup failed", zap.Error(err))
|
|
c.JSON(http.StatusInternalServerError, gin.H{errKey: "GitHub authentication cleanup failed"})
|
|
return
|
|
}
|
|
if githubSvc != nil {
|
|
githubSvc.ResetMockAuth(workspaceID)
|
|
if err := resetGitHubAppRegistrationsForE2E(ctx, githubSvc, workspaceID); err != nil {
|
|
log.Error("e2e reset: GitHub deployment App cleanup failed", zap.Error(err))
|
|
c.JSON(http.StatusInternalServerError, gin.H{errKey: "GitHub deployment App cleanup failed"})
|
|
return
|
|
}
|
|
}
|
|
// The workflow-sync poller reads these rows globally; delete before
|
|
// task/workflow deletion so a mid-reset tick can't resync workflows.
|
|
// The table always exists, so a failure is a genuine DB error — abort
|
|
// rather than let the poller recreate workflows mid-reset.
|
|
if _, err := repo.DB().ExecContext(ctx, `DELETE FROM workflow_sync_configs WHERE workspace_id = ?`, workspaceID); err != nil {
|
|
log.Error("e2e reset: workflow sync config cleanup failed", zap.Error(err))
|
|
c.JSON(http.StatusInternalServerError, gin.H{errKey: "workflow sync config cleanup failed"})
|
|
return
|
|
}
|
|
|
|
// Reset every agent's routing override to the inherit-markers
|
|
// shape onboarding writes. Without this, an agent-override test
|
|
// leaves the CEO pinned to a single provider, which derails
|
|
// subsequent workspace-level routing specs that expect the
|
|
// resolver to walk the full provider_order.
|
|
if _, err := repo.DB().ExecContext(ctx, `
|
|
UPDATE agent_profiles
|
|
SET settings = '{"routing":{"provider_order_source":"inherit","tier_source":"inherit"}}'
|
|
WHERE workspace_id = ?
|
|
`, workspaceID); err != nil {
|
|
log.Warn("e2e reset: agent settings reset failed", zap.Error(err))
|
|
}
|
|
|
|
// Wipe GitHub review watches (and their dedup rows + owned tasks) so
|
|
// review-watch specs stay isolated. seedData/backend are worker-scoped,
|
|
// so a watch an earlier test created stays enabled; the global review
|
|
// poller polls every enabled watch and would create a duplicate task
|
|
// for any PR a later test adds that the stale watch also matches.
|
|
// Done before task deletion so the poller can't recreate tasks mid-reset.
|
|
var deletedWatches int
|
|
if githubSvc != nil {
|
|
n, err := githubSvc.DeleteReviewWatchesByWorkspace(ctx, workspaceID)
|
|
if err != nil {
|
|
// Abort like every other cleanup below: leaving stale watches
|
|
// behind reintroduces the cross-test poller pollution this
|
|
// endpoint exists to prevent.
|
|
log.Error("e2e reset: review watch cleanup failed", zap.Error(err))
|
|
c.JSON(http.StatusInternalServerError, gin.H{errKey: err.Error()})
|
|
return
|
|
}
|
|
deletedWatches = n
|
|
}
|
|
|
|
var gitLabReset gitlab.E2EResetResult
|
|
if gitlabSvc != nil {
|
|
resetResult, resetErr := gitlabSvc.ResetWorkspaceE2E(ctx, workspaceID)
|
|
if resetErr != nil {
|
|
log.Error("e2e reset: GitLab workspace cleanup failed", zap.Error(resetErr))
|
|
c.JSON(http.StatusInternalServerError, gin.H{errKey: resetErr.Error()})
|
|
return
|
|
}
|
|
gitLabReset = resetResult
|
|
}
|
|
|
|
// Clear native code-review runs and findings before deleting the tasks
|
|
// that own them. A review pass left in flight by an earlier spec would
|
|
// otherwise publish findings mid-reset and leak them into a later test.
|
|
if err := repo.DeleteTaskReviewByWorkspace(ctx, workspaceID); err != nil {
|
|
log.Error("e2e reset: task review cleanup failed", zap.Error(err))
|
|
c.JSON(http.StatusInternalServerError, gin.H{errKey: err.Error()})
|
|
return
|
|
}
|
|
|
|
// Route through the task service (rather than a raw SQL DELETE) so
|
|
// each delete spawns the async cleanup goroutine that stops the
|
|
// agentctl instance and releases its port. Without this, instances
|
|
// accumulate across tests in the same Playwright worker and
|
|
// eventually exhaust the per-worker port range.
|
|
const resetPageSize = 10000
|
|
tasks, total, err := repo.ListTasksByWorkspace(ctx, workspaceID, "", "", "", 1, resetPageSize, "", true, true, false, false)
|
|
if err != nil {
|
|
log.Error("e2e reset: failed to list tasks", zap.Error(err))
|
|
c.JSON(http.StatusInternalServerError, gin.H{errKey: err.Error()})
|
|
return
|
|
}
|
|
if total > resetPageSize {
|
|
// Fail loudly rather than silently leaving tasks behind, which
|
|
// would leak agentctl instances and exhaust ports.
|
|
log.Error("e2e reset: task count exceeds page size",
|
|
zap.Int("total", total), zap.Int("page_size", resetPageSize))
|
|
c.JSON(http.StatusInternalServerError, gin.H{
|
|
"error": "task count exceeds reset page size",
|
|
})
|
|
return
|
|
}
|
|
var deletedTasks int64
|
|
for _, t := range tasks {
|
|
if err := taskSvc.DeleteTask(ctx, t.ID); err != nil {
|
|
// Abort: leaving an undeleted task with its workflow gone
|
|
// would create orphan rows visible to subsequent tests.
|
|
log.Error("e2e reset: failed to delete task",
|
|
zap.String("task_id", t.ID), zap.Error(err))
|
|
c.JSON(http.StatusInternalServerError, gin.H{errKey: err.Error()})
|
|
return
|
|
}
|
|
deletedTasks++
|
|
}
|
|
|
|
deletedWorkflows, err := repo.DeleteWorkflowsByWorkspace(ctx, workspaceID, keepWorkflowIDs)
|
|
if err != nil {
|
|
log.Error("e2e reset: failed to delete workflows", zap.Error(err))
|
|
c.JSON(http.StatusInternalServerError, gin.H{errKey: err.Error()})
|
|
return
|
|
}
|
|
|
|
deletedAutomations, autoErr := deleteAutomationsForReset(ctx, automationSvc, workspaceID)
|
|
if autoErr != nil {
|
|
log.Error("e2e reset: failed to delete automations", zap.Error(autoErr))
|
|
c.JSON(http.StatusInternalServerError, gin.H{errKey: autoErr.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"deleted_tasks": deletedTasks,
|
|
"deleted_workflows": deletedWorkflows,
|
|
"deleted_automations": deletedAutomations,
|
|
"deleted_review_watches": deletedWatches,
|
|
"deleted_gitlab_review_watches": gitLabReset.ReviewWatches,
|
|
"deleted_gitlab_issue_watches": gitLabReset.IssueWatches,
|
|
})
|
|
}
|
|
}
|
|
|
|
func resetGitHubAppRegistrationsForE2E(
|
|
ctx context.Context,
|
|
service *github.Service,
|
|
workspaceID string,
|
|
) error {
|
|
if service == nil {
|
|
return nil
|
|
}
|
|
return service.ResetAppRegistrationsForE2E(ctx, workspaceID)
|
|
}
|
|
|
|
func deleteGitHubAuthForReset(ctx context.Context, database *sql.DB, workspaceID string) error {
|
|
tx, err := database.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
for _, query := range []string{
|
|
`DELETE FROM github_auth_flows WHERE workspace_id = ?`,
|
|
`DELETE FROM github_user_connections WHERE workspace_id = ?`,
|
|
`DELETE FROM github_workspace_connections WHERE workspace_id = ?`,
|
|
} {
|
|
if _, err := tx.ExecContext(ctx, query, workspaceID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
userSecretPrefix := "github:user:" + workspaceID + ":"
|
|
if _, err := tx.ExecContext(ctx, `
|
|
DELETE FROM secrets
|
|
WHERE id = ? OR substr(id, 1, length(?)) = ?`,
|
|
github.WorkspacePATSecretKey(workspaceID), userSecretPrefix, userSecretPrefix); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
func deleteAutomationsForReset(
|
|
ctx context.Context,
|
|
automationSvc *automation.Service,
|
|
workspaceID string,
|
|
) (int, error) {
|
|
if automationSvc == nil {
|
|
return 0, nil
|
|
}
|
|
return automationSvc.Store().DeleteAutomationsByWorkspace(ctx, workspaceID)
|
|
}
|
|
|
|
type e2eHiddenWorkflowRequest struct {
|
|
WorkspaceID string `json:"workspace_id"`
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
func handleE2ECreateHiddenWorkflow(taskSvc *taskservice.Service, log *logger.Logger) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
var body e2eHiddenWorkflowRequest
|
|
if err := c.ShouldBindJSON(&body); err != nil || body.WorkspaceID == "" || body.Name == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{errKey: "workspace_id and name are required"})
|
|
return
|
|
}
|
|
workflow, err := taskSvc.CreateWorkflow(c.Request.Context(), &taskservice.CreateWorkflowRequest{
|
|
WorkspaceID: body.WorkspaceID,
|
|
Name: body.Name,
|
|
Hidden: true,
|
|
})
|
|
if err != nil {
|
|
log.Error("e2e: failed to create hidden workflow", zap.Error(err))
|
|
c.JSON(http.StatusInternalServerError, gin.H{errKey: err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusCreated, gin.H{
|
|
"id": workflow.ID,
|
|
"workspace_id": workflow.WorkspaceID,
|
|
"name": workflow.Name,
|
|
"hidden": workflow.Hidden,
|
|
})
|
|
}
|
|
}
|
|
|
|
type e2eCreateAutomationRequest struct {
|
|
WorkspaceID string `json:"workspace_id"`
|
|
Name string `json:"name"`
|
|
WorkflowID string `json:"workflow_id"`
|
|
WorkflowStepID string `json:"workflow_step_id"`
|
|
}
|
|
|
|
// handleE2ECreateAutomation seeds an automation for E2E tests via HTTP so tests
|
|
// don't require Node 24 (WS API requires a global WebSocket that isn't in Node 20).
|
|
func handleE2ECreateAutomation(svc *automation.Service, log *logger.Logger) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
var body e2eCreateAutomationRequest
|
|
if err := c.ShouldBindJSON(&body); err != nil || body.WorkspaceID == "" || body.Name == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{errKey: "workspace_id and name are required"})
|
|
return
|
|
}
|
|
a, err := svc.CreateAutomation(c.Request.Context(), &automation.CreateAutomationRequest{
|
|
WorkspaceID: body.WorkspaceID,
|
|
Name: body.Name,
|
|
WorkflowID: body.WorkflowID,
|
|
WorkflowStepID: body.WorkflowStepID,
|
|
MaxConcurrentRuns: 10,
|
|
})
|
|
if err != nil {
|
|
log.Error("e2e: failed to create automation", zap.Error(err))
|
|
c.JSON(http.StatusInternalServerError, gin.H{errKey: err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusCreated, gin.H{"id": a.ID, "workspace_id": a.WorkspaceID, "name": a.Name})
|
|
}
|
|
}
|
|
|
|
type e2eCreateAutomationRunRequest struct {
|
|
AutomationID string `json:"automation_id"`
|
|
Status string `json:"status"`
|
|
// TaskID optionally links the seeded run to a real task so E2E tests can
|
|
// exercise task-lifecycle interactions (e.g. archiving the task and
|
|
// asserting the run's displayed status/concurrency accounting reacts).
|
|
TaskID string `json:"task_id"`
|
|
}
|
|
|
|
// handleE2ECreateAutomationRun seeds an automation run row for E2E tests.
|
|
func handleE2ECreateAutomationRun(svc *automation.Service, log *logger.Logger) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
var body e2eCreateAutomationRunRequest
|
|
if err := c.ShouldBindJSON(&body); err != nil || body.AutomationID == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{errKey: "automation_id is required"})
|
|
return
|
|
}
|
|
status := automation.RunStatus(body.Status)
|
|
if status == "" {
|
|
status = automation.RunStatusSkipped
|
|
}
|
|
run := &automation.AutomationRun{
|
|
AutomationID: body.AutomationID,
|
|
TriggerType: automation.TriggerTypeScheduled,
|
|
Status: status,
|
|
TaskID: body.TaskID,
|
|
TriggerData: []byte(`{}`),
|
|
}
|
|
if err := svc.RecordRun(c.Request.Context(), run); err != nil {
|
|
log.Error("e2e: failed to create automation run", zap.Error(err))
|
|
c.JSON(http.StatusInternalServerError, gin.H{errKey: err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusCreated, gin.H{
|
|
"id": run.ID, "automation_id": run.AutomationID, statusKey: run.Status, taskIDPayloadKey: run.TaskID,
|
|
})
|
|
}
|
|
}
|
|
|
|
type e2eCreateTaskSessionRequest struct {
|
|
TaskID string `json:"task_id"`
|
|
State string `json:"state"`
|
|
// IsPrimary defaults to true: tests seeding a single session almost
|
|
// always want it to be the task's *current* session (the one
|
|
// listRunsWithTaskState/countActiveRunsWithTaskState read). Set to
|
|
// false to seed a stale, superseded session for resume scenarios.
|
|
IsPrimary *bool `json:"is_primary"`
|
|
}
|
|
|
|
// handleE2ECreateTaskSession seeds a task_sessions row directly for E2E
|
|
// tests that need to assert on session-state-derived behavior (e.g. the
|
|
// automation Recent Runs "Cancelled" status, which is keyed off the task's
|
|
// primary session state, not the task's own state — see
|
|
// internal/automation.Store.listRunsWithTaskState) without driving a real
|
|
// agent through a full stop/resume cycle.
|
|
func handleE2ECreateTaskSession(repo *sqliterepo.Repository, log *logger.Logger) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
var body e2eCreateTaskSessionRequest
|
|
if err := c.ShouldBindJSON(&body); err != nil || body.TaskID == "" || body.State == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{errKey: "task_id and state are required"})
|
|
return
|
|
}
|
|
isPrimary := true
|
|
if body.IsPrimary != nil {
|
|
isPrimary = *body.IsPrimary
|
|
}
|
|
session := &taskmodels.TaskSession{
|
|
TaskID: body.TaskID,
|
|
State: taskmodels.TaskSessionState(body.State),
|
|
}
|
|
if err := repo.CreateTaskSession(c.Request.Context(), session); err != nil {
|
|
log.Error("e2e: failed to create task session", zap.Error(err))
|
|
c.JSON(http.StatusInternalServerError, gin.H{errKey: err.Error()})
|
|
return
|
|
}
|
|
// SetSessionPrimary clears is_primary on every other session for this
|
|
// task before setting it here, mirroring production's resume path
|
|
// (Repository.SetSessionPrimary) so seeding a second primary session
|
|
// for a task that already has one can't leave two is_primary = 1 rows
|
|
// behind — that would corrupt the run-status query's read of the
|
|
// task's "current" session (see listRunsWithTaskState).
|
|
if isPrimary {
|
|
if err := repo.SetSessionPrimary(c.Request.Context(), session.ID); err != nil {
|
|
log.Error("e2e: failed to set primary task session", zap.Error(err))
|
|
c.JSON(http.StatusInternalServerError, gin.H{errKey: err.Error()})
|
|
return
|
|
}
|
|
session.IsPrimary = true
|
|
}
|
|
c.JSON(http.StatusCreated, gin.H{
|
|
"id": session.ID, taskIDPayloadKey: session.TaskID, statusKey: session.State,
|
|
})
|
|
}
|
|
}
|
|
|
|
type e2eSetSessionReadCursorRequest struct {
|
|
MessageID string `json:"message_id"`
|
|
}
|
|
|
|
// handleE2ESetSessionReadCursor force-sets a session's last_read_message_id
|
|
// via a raw update, bypassing UpdateTaskSessionLastReadMessageID's monotonic
|
|
// guard (production only ever advances the cursor forward — see
|
|
// repository/sqlite/session.go). E2E specs use this to seed "the user last
|
|
// read up through an earlier message" deterministically, since replaying an
|
|
// older messageId through the real mark-read endpoint is now a rejected
|
|
// no-op rather than a rewind.
|
|
func handleE2ESetSessionReadCursor(repo *sqliterepo.Repository, log *logger.Logger) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
sessionID := c.Param("id")
|
|
var body e2eSetSessionReadCursorRequest
|
|
if err := c.ShouldBindJSON(&body); err != nil || body.MessageID == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{errKey: "message_id is required"})
|
|
return
|
|
}
|
|
result, err := repo.DB().ExecContext(c.Request.Context(),
|
|
`UPDATE task_sessions SET last_read_message_id = ?, updated_at = ? WHERE id = ?`,
|
|
body.MessageID, time.Now().UTC(), sessionID,
|
|
)
|
|
if err != nil {
|
|
log.Error("e2e: failed to force-set session read cursor", zap.Error(err))
|
|
c.JSON(http.StatusInternalServerError, gin.H{errKey: err.Error()})
|
|
return
|
|
}
|
|
rows, _ := result.RowsAffected()
|
|
if rows == 0 {
|
|
c.JSON(http.StatusNotFound, gin.H{errKey: "session not found: " + sessionID})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"id": sessionID, "last_read_message_id": body.MessageID})
|
|
}
|
|
}
|