762 lines
30 KiB
Go
762 lines
30 KiB
Go
package backendapp
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"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/events"
|
|
"github.com/kandev/kandev/internal/events/bus"
|
|
"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"
|
|
)
|
|
|
|
const (
|
|
// errKey is the JSON field used for error responses from the E2E endpoints.
|
|
errKey = "error"
|
|
statusKey = "status"
|
|
e2eResetSourceKey = "source"
|
|
e2eResetTypeKey = "type"
|
|
)
|
|
|
|
// 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,
|
|
eventBus bus.EventBus,
|
|
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, repo, log))
|
|
api.POST("/automation-runs", handleE2ECreateAutomationRun(automationSvc, log))
|
|
api.POST("/automation-triggers", handleE2EAddTrigger(automationSvc, log))
|
|
// Fire a fake github_pr_merged event into the in-process event bus so
|
|
// E2E tests can exercise the automation subscriber without real GitHub
|
|
// polling. Returns the run task id once the run record appears.
|
|
api.POST("/github/fire-pr-merged", handleE2EFirePRMerged(automationSvc, eventBus, log))
|
|
// Fire a manual automation trigger, mirroring the "Run" button path,
|
|
// and return the run task id once the run record appears.
|
|
api.POST("/automations/:id/trigger", handleE2EAutomationManualTrigger(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))
|
|
// Stamps a task's origin. Production sets origin=automation_run inside the
|
|
// automation firing path, so a task seeded through the ordinary task API is
|
|
// an ordinary task — it shows on the kanban and in task lists, which is the
|
|
// opposite of what an automation run does. Specs that assert on (or
|
|
// photograph) that hiding need the seeded state to match production.
|
|
api.PATCH("/tasks/:id/origin", handleE2ESetTaskOrigin(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"`
|
|
// Prompt is the automation's standing instruction. Optional, but the run
|
|
// view only renders the instruction card when there is one, so a spec
|
|
// asserting on where that card lives has to seed it.
|
|
Prompt string `json:"prompt"`
|
|
// AgentProfileID and ExecutorProfileID mirror the fields a UI-created
|
|
// automation carries. Without them autoStartAutomationTask calls StartTask
|
|
// with empty profile IDs and the lifecycle layer fails to resolve the agent,
|
|
// so tests that need the automation to actually launch an agent must supply
|
|
// these (typically seedData.agentProfileId / seedData.worktreeExecutorProfileId).
|
|
AgentProfileID string `json:"agent_profile_id"`
|
|
ExecutorProfileID string `json:"executor_profile_id"`
|
|
// LegacyBoardCard rewrites the row's execution_mode to 'task' after
|
|
// creation, reproducing on disk exactly what an install that predates the
|
|
// withdrawal of execution modes carries. There is no input path to this:
|
|
// CreateAutomation deliberately writes the empty string so a row created
|
|
// today can never be mistaken for a pre-upgrade one (see
|
|
// automation.Store.CreateAutomation), and the field is ignored on the
|
|
// wire. The migration notice is derived from this column by production
|
|
// SQL (`execution_mode = 'task' AS legacy_board_card`), so seeding the
|
|
// column is the only honest way to put a workspace in the state the
|
|
// notice exists to explain.
|
|
LegacyBoardCard bool `json:"legacy_board_card"`
|
|
}
|
|
|
|
// 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,
|
|
repo *sqliterepo.Repository,
|
|
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,
|
|
Prompt: body.Prompt,
|
|
AgentProfileID: body.AgentProfileID,
|
|
ExecutorProfileID: body.ExecutorProfileID,
|
|
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
|
|
}
|
|
if body.LegacyBoardCard {
|
|
// Automations live in the same SQLite database the task repository
|
|
// holds open, so no second handle is needed.
|
|
if _, err := repo.DB().ExecContext(c.Request.Context(),
|
|
`UPDATE automations SET execution_mode = 'task' WHERE id = ?`, a.ID); err != nil {
|
|
log.Error("e2e: failed to backdate automation execution_mode", 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.
|
|
type e2eSetTaskOriginRequest struct {
|
|
Origin string `json:"origin"`
|
|
}
|
|
|
|
// e2eForceColumn is the shape both force-set seeders share: bind one field,
|
|
// write one column by id, and translate "no rows" into a 404 so a spec that
|
|
// seeds against a typo fails where the mistake is rather than three assertions
|
|
// later. Extracted because the two handlers were byte-for-byte alike apart
|
|
// from the table, and a copied handler is a handler that drifts.
|
|
func e2eForceColumn(
|
|
c *gin.Context,
|
|
repo *sqliterepo.Repository,
|
|
log *logger.Logger,
|
|
opts e2eForceColumnOpts,
|
|
) {
|
|
result, err := repo.DB().ExecContext(c.Request.Context(), opts.query, opts.value, time.Now().UTC(), opts.id)
|
|
if err != nil {
|
|
log.Error("e2e: "+opts.failLog, 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: opts.subject + " not found: " + opts.id})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"id": opts.id, opts.responseKey: opts.value})
|
|
}
|
|
|
|
type e2eForceColumnOpts struct {
|
|
query string
|
|
id string
|
|
value any
|
|
subject string
|
|
responseKey string
|
|
failLog string
|
|
}
|
|
|
|
func handleE2ESetTaskOrigin(repo *sqliterepo.Repository, log *logger.Logger) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
var body e2eSetTaskOriginRequest
|
|
if err := c.ShouldBindJSON(&body); err != nil || body.Origin == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{errKey: "origin is required"})
|
|
return
|
|
}
|
|
e2eForceColumn(c, repo, log, e2eForceColumnOpts{
|
|
query: `UPDATE tasks SET origin = ?, updated_at = ? WHERE id = ?`,
|
|
id: c.Param("id"),
|
|
value: body.Origin,
|
|
subject: "task",
|
|
responseKey: "origin",
|
|
failLog: "failed to set task origin",
|
|
})
|
|
}
|
|
}
|
|
|
|
type e2eFirePRMergedRequest struct {
|
|
TaskID string `json:"task_id"`
|
|
AutomationID string `json:"automation_id"`
|
|
Owner string `json:"owner"`
|
|
Repo string `json:"repo"`
|
|
PRNumber int `json:"pr_number"`
|
|
BaseBranch string `json:"base_branch"`
|
|
}
|
|
|
|
// handleE2EFirePRMerged publishes a fake events.GitHubTaskPRUpdated event with
|
|
// State="merged" directly into the in-process event bus. The subscriber picks it
|
|
// up synchronously (memory bus), triggers the matching automations, and spawns a
|
|
// goroutine to create the run task. The handler then polls until the run record
|
|
// appears in the DB and returns its task id.
|
|
//
|
|
// This endpoint exists only in E2E / mock-agent mode. It does NOT call GitHub or
|
|
// add webhook subscriptions — it fires the internal event bus only.
|
|
func handleE2EFirePRMerged(svc *automation.Service, eventBus bus.EventBus, log *logger.Logger) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
var body e2eFirePRMergedRequest
|
|
if err := c.ShouldBindJSON(&body); err != nil ||
|
|
body.TaskID == "" || body.AutomationID == "" || body.Owner == "" || body.Repo == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{errKey: "task_id, automation_id, owner, and repo are required"})
|
|
return
|
|
}
|
|
if body.PRNumber <= 0 {
|
|
body.PRNumber = 1
|
|
}
|
|
|
|
ctx := c.Request.Context()
|
|
|
|
// Snapshot the latest run id before firing so we can detect the new one.
|
|
beforeID := ""
|
|
existing, _ := svc.ListRuns(ctx, body.AutomationID, 1)
|
|
if len(existing) > 0 {
|
|
beforeID = existing[0].ID
|
|
}
|
|
|
|
// Build a fake TaskPR and publish the event.
|
|
now := time.Now().UTC()
|
|
prURL := fmt.Sprintf("https://github.com/%s/%s/pull/%d", body.Owner, body.Repo, body.PRNumber)
|
|
pr := &github.TaskPR{
|
|
TaskID: body.TaskID,
|
|
Owner: body.Owner,
|
|
Repo: body.Repo,
|
|
PRNumber: body.PRNumber,
|
|
PRURL: prURL,
|
|
BaseBranch: body.BaseBranch,
|
|
State: "merged",
|
|
MergedAt: &now,
|
|
}
|
|
evt := bus.NewEvent(events.GitHubTaskPRUpdated, "e2e", pr)
|
|
if err := eventBus.Publish(ctx, events.GitHubTaskPRUpdated, evt); err != nil {
|
|
log.Error("e2e: failed to publish GitHubTaskPRUpdated event", zap.Error(err))
|
|
c.JSON(http.StatusInternalServerError, gin.H{errKey: err.Error()})
|
|
return
|
|
}
|
|
|
|
taskID, err := e2ePollNewRun(ctx, svc, body.AutomationID, beforeID)
|
|
if err != nil {
|
|
log.Warn("e2e: timed out waiting for automation run after PR merged event",
|
|
zap.String("automation_id", body.AutomationID))
|
|
c.JSON(http.StatusGatewayTimeout, gin.H{errKey: "timeout waiting for automation run to be created"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"run_task_id": taskID})
|
|
}
|
|
}
|
|
|
|
// handleE2EAutomationManualTrigger fires a manual automation trigger (mirroring
|
|
// the "Run" button path) and polls for the resulting run task id.
|
|
func handleE2EAutomationManualTrigger(svc *automation.Service, log *logger.Logger) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
automationID := c.Param("id")
|
|
if automationID == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{errKey: "automation id is required"})
|
|
return
|
|
}
|
|
|
|
ctx := c.Request.Context()
|
|
|
|
a, err := svc.GetAutomation(ctx, automationID)
|
|
if err != nil || a == nil {
|
|
c.JSON(http.StatusNotFound, gin.H{errKey: "automation not found"})
|
|
return
|
|
}
|
|
|
|
// Snapshot the latest run before firing.
|
|
beforeID := ""
|
|
existing, _ := svc.ListRuns(ctx, automationID, 1)
|
|
if len(existing) > 0 {
|
|
beforeID = existing[0].ID
|
|
}
|
|
|
|
// Build manual trigger data matching the production path.
|
|
data, _ := json.Marshal(map[string]string{e2eResetSourceKey: "manual"})
|
|
triggerID := ""
|
|
if len(a.Triggers) > 0 {
|
|
triggerID = a.Triggers[0].ID
|
|
}
|
|
result, fireErr := svc.FireTrigger(ctx, automationID, triggerID, "manual", data, "")
|
|
if fireErr != nil {
|
|
log.Error("e2e: manual trigger failed", zap.String("automation_id", automationID), zap.Error(fireErr))
|
|
c.JSON(http.StatusInternalServerError, gin.H{errKey: fireErr.Error()})
|
|
return
|
|
}
|
|
if result.Skipped {
|
|
c.JSON(http.StatusOK, gin.H{"skipped": true, "reason": result.Reason})
|
|
return
|
|
}
|
|
|
|
taskID, err := e2ePollNewRun(ctx, svc, automationID, beforeID)
|
|
if err != nil {
|
|
log.Warn("e2e: timed out waiting for automation run after manual trigger",
|
|
zap.String("automation_id", automationID))
|
|
c.JSON(http.StatusGatewayTimeout, gin.H{errKey: "timeout waiting for automation run to be created"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"run_task_id": taskID})
|
|
}
|
|
}
|
|
|
|
// e2ePollNewRun blocks until a new run (with a different id than beforeID) appears
|
|
// for automationID, or until 5 seconds elapse. Returns the new run's TaskID.
|
|
func e2ePollNewRun(ctx context.Context, svc *automation.Service, automationID, beforeID string) (string, error) {
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
time.Sleep(100 * time.Millisecond)
|
|
runs, err := svc.ListRuns(ctx, automationID, 1)
|
|
if err != nil || len(runs) == 0 {
|
|
continue
|
|
}
|
|
if runs[0].ID != beforeID {
|
|
return runs[0].TaskID, nil
|
|
}
|
|
}
|
|
return "", fmt.Errorf("timeout")
|
|
}
|
|
|
|
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
|
|
}
|
|
e2eForceColumn(c, repo, log, e2eForceColumnOpts{
|
|
query: `UPDATE task_sessions SET last_read_message_id = ?, updated_at = ? WHERE id = ?`,
|
|
id: sessionID,
|
|
value: body.MessageID,
|
|
subject: "session",
|
|
responseKey: "last_read_message_id",
|
|
failLog: "failed to force-set session read cursor",
|
|
})
|
|
}
|
|
}
|
|
|
|
// handleE2EAddTrigger seeds an automation trigger via HTTP for E2E tests that
|
|
// need a pre-existing trigger without driving the WS API (which requires a
|
|
// global WebSocket unavailable in Node 20).
|
|
func handleE2EAddTrigger(svc *automation.Service, log *logger.Logger) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
var req automation.AddTriggerRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil || req.AutomationID == "" || req.Type == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{errKey: "automation_id and type are required"})
|
|
return
|
|
}
|
|
t, err := svc.AddTrigger(c.Request.Context(), &req)
|
|
if err != nil {
|
|
log.Error("e2e: failed to add trigger", zap.Error(err))
|
|
c.JSON(http.StatusInternalServerError, gin.H{errKey: err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusCreated, gin.H{
|
|
"id": t.ID,
|
|
"automation_id": t.AutomationID,
|
|
e2eResetTypeKey: t.Type,
|
|
"enabled": t.Enabled,
|
|
})
|
|
}
|
|
}
|