Files
kandev/apps/backend/internal/automation/models.go
T

324 lines
15 KiB
Go

// Package automation provides a generic automation system for Kandev.
// Automations are named rules with triggers (cron, GitHub events, webhooks)
// that automatically create and optionally start tasks when fired.
package automation
import (
"context"
"encoding/json"
"time"
"github.com/kandev/kandev/internal/github"
)
// TriggerType identifies the kind of trigger.
type TriggerType string
const (
TriggerTypeScheduled TriggerType = "scheduled"
TriggerTypeGitHubPR TriggerType = "github_pr"
TriggerTypeGitHubPRMerged TriggerType = "github_pr_merged"
TriggerTypeGitHubPush TriggerType = "github_push"
TriggerTypeGitHubCI TriggerType = "github_ci"
TriggerTypeWebhook TriggerType = "webhook"
)
const (
automationAuthorLoginKey = "author_login"
automationBaseBranchKey = "base_branch"
automationBodyKey = "body"
automationHeadBranchKey = "head_branch"
automationHTMLURLKey = "html_url"
automationRepoKey = "repo"
defaultBranchMain = "main"
exampleGitHubPRURL = "https://github.com/acme/api/pull/7"
exampleRepositoryOwner = "org/repo"
placeholderRepositoryOwner = "Repository (owner/name)"
triggerCategoryGitHub = "github"
triggerDataSourceKey = "source"
triggerDataSourceManual = "manual"
)
// RunStatus tracks the outcome of a trigger firing.
type RunStatus string
const (
RunStatusTriggered RunStatus = "triggered"
RunStatusTaskCreated RunStatus = "task_created"
RunStatusSucceeded RunStatus = "succeeded"
RunStatusFailed RunStatus = "failed"
RunStatusSkipped RunStatus = "skipped"
// RunStatusArchived is a read-time-derived terminal status: it is never
// written by a trigger-firing code path, only computed by ListRuns when
// a task_created run's generated task has been archived (archived_at
// set) — whether archived through the UI or by the agent itself (e.g.
// via an "archive this task" instruction). The task's outcome may still
// be known (succeeded/failed already recorded); this only overrides the
// still-open task_created status, never a real terminal one.
RunStatusArchived RunStatus = "archived"
// RunStatusCancelled is a read-time-derived terminal status: it is never
// written by a trigger-firing code path, only computed by ListRuns (and
// implied by CountActiveRuns' filter) for a task_created run whose
// generated task either no longer exists (deleted — outcome
// unrecoverable) or whose task's *current* session (task_sessions row
// with is_primary = 1) was explicitly put into the CANCELLED state —
// set only when an agent run was manually stopped (a deliberate
// cancellation, distinct from archiving; the task's own state is not
// touched by a stop and so isn't consulted here). Archived_at takes
// precedence over the cancelled-session check when both apply — see
// listRunsWithTaskState.
RunStatusCancelled RunStatus = "cancelled"
)
// Automation is a named rule with triggers, a prompt template, and agent/executor config.
type Automation struct {
ID string `json:"id" db:"id"`
WorkspaceID string `json:"workspace_id" db:"workspace_id"`
Name string `json:"name" db:"name"`
Description string `json:"description" db:"description"`
// WorkflowID / WorkflowStepID are optional: no automation run is placed
// on a board, so no automation needs a starting column.
WorkflowID string `json:"workflow_id" db:"workflow_id"`
WorkflowStepID string `json:"workflow_step_id" db:"workflow_step_id"`
AgentProfileID string `json:"agent_profile_id" db:"agent_profile_id"`
ExecutorProfileID string `json:"executor_profile_id" db:"executor_profile_id"`
Prompt string `json:"prompt" db:"prompt"`
TaskTitleTemplate string `json:"task_title_template" db:"task_title_template"`
Enabled bool `json:"enabled" db:"enabled"`
MaxConcurrentRuns int `json:"max_concurrent_runs" db:"max_concurrent_runs"`
WebhookSecret string `json:"-" db:"webhook_secret"`
LastTriggeredAt *time.Time `json:"last_triggered_at,omitempty" db:"last_triggered_at"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
// LegacyBoardCard reports that this automation was created while the
// withdrawn execution_mode still decided where a firing landed, and that
// its stored value is `task` — the DEFAULT, so this covers every
// automation nobody explicitly set to `run`. Those firings used to put a
// card on the kanban and no longer do; the UI uses this to say so once,
// per workspace, instead of leaving the cards to just stop appearing.
//
// Derived in SQL (`execution_mode = 'task'`) rather than by reading the
// column into a field, so the raw mode never enters the Go model and
// cannot grow a firing-path branch: this is a migration-window notice,
// not a second destination. See docs/specs/office/automations-settings.md
// § Migration.
LegacyBoardCard bool `json:"legacy_board_card" db:"legacy_board_card"`
// Hydrated separately, not stored as columns on this table.
Triggers []AutomationTrigger `json:"triggers" db:"-"`
// RepositoryIDs is the ordered list of repositories to use for trigger
// firings, backed by the automation_repositories join table. Empty
// falls back to the workspace's first repository (for scheduled /
// webhook triggers) or the PR's repository (for github_pr triggers,
// which always override this field — see resolveAutomationRepository).
RepositoryIDs []string `json:"repository_ids" db:"-"`
}
// AutomationTrigger is a single trigger attached to an automation.
type AutomationTrigger struct {
ID string `json:"id" db:"id"`
AutomationID string `json:"automation_id" db:"automation_id"`
Type TriggerType `json:"type" db:"type"`
Config json.RawMessage `json:"config" db:"-"`
ConfigJSON string `json:"-" db:"config"`
Enabled bool `json:"enabled" db:"enabled"`
LastEvaluatedAt *time.Time `json:"last_evaluated_at,omitempty" db:"last_evaluated_at"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// AutomationRun records a single trigger firing for audit/observability.
type AutomationRun struct {
ID string `json:"id" db:"id"`
AutomationID string `json:"automation_id" db:"automation_id"`
TriggerID string `json:"trigger_id" db:"trigger_id"`
TriggerType TriggerType `json:"trigger_type" db:"trigger_type"`
TaskID string `json:"task_id,omitempty" db:"task_id"`
Status RunStatus `json:"status" db:"status"`
DedupKey string `json:"dedup_key" db:"dedup_key"`
TriggerData json.RawMessage `json:"trigger_data" db:"-"`
TriggerDataJSON string `json:"-" db:"trigger_data"`
ErrorMessage string `json:"error_message,omitempty" db:"error_message"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
// Summary is the tail of the agent's last message on the generated task,
// read at list time and truncated for display. Every automation hides its
// task from the board, so without this the run row can report that
// something happened but never what — which is the one thing the reader
// of a scheduled report actually wants. Empty when the run never produced
// a task or the agent never spoke.
Summary string `json:"summary,omitempty" db:"summary"`
// SessionID is the run's primary conversation, empty when the task is gone
// or never started one. The detail view mounts the transcript from it.
SessionID string `json:"session_id,omitempty" db:"session_id"`
}
// WorkspaceAutomationRun is a run carrying just enough of its owning
// automation to be readable outside that automation's own settings page.
// The workspace-wide feed interleaves runs from every automation, so a row
// that knows only its automation_id is unattributable — the reader sees
// that something fired but not what fired it.
type WorkspaceAutomationRun struct {
AutomationRun
AutomationName string `json:"automation_name" db:"automation_name"`
}
// AutomationSummary is one automation's health, answered per automation rather
// than inferred from a capped feed: what it last said, and whether anything of
// its own is still running. The runs list reads exactly these two facts.
type AutomationSummary struct {
AutomationID string `json:"automation_id"`
// OpenRuns counts the runs still outstanding under the same definition the
// concurrency cap uses, so "won't fire — still running" and the cap that
// causes it can never disagree.
OpenRuns int `json:"open_runs"`
// LastRun is nil when the automation has never run, or when its runs have
// all been deleted — both of which read as "no runs yet".
LastRun *AutomationRun `json:"last_run,omitempty"`
}
// --- Trigger config types ---
// ScheduledTriggerConfig holds configuration for cron-based triggers.
type ScheduledTriggerConfig struct {
CronExpression string `json:"cron_expression"`
Timezone string `json:"timezone,omitempty"`
}
// GitHubPRTriggerConfig filters PR events.
type GitHubPRTriggerConfig struct {
Events []string `json:"events"` // opened, commented, merged, review_requested, closed
Repos []github.RepoFilter `json:"repos"` // empty = all repos
Branches []string `json:"branches,omitempty"` // base branch filter
Authors []string `json:"authors,omitempty"` // PR author filter
Labels []string `json:"labels,omitempty"` // label filter
ExcludeDraft bool `json:"exclude_draft,omitempty"`
}
// GitHubPushTriggerConfig filters push-to-branch events.
type GitHubPushTriggerConfig struct {
Repos []github.RepoFilter `json:"repos"`
Branches []string `json:"branches"` // glob patterns: ["main", "release/*"]
}
// GitHubCITriggerConfig filters CI completion events.
type GitHubCITriggerConfig struct {
Repos []github.RepoFilter `json:"repos"`
Conclusions []string `json:"conclusions"` // success, failure, etc.
CheckNames []string `json:"check_names,omitempty"`
Branches []string `json:"branches,omitempty"` // head-branch glob filter; empty = all
}
// GitHubPRMergedTriggerConfig filters PR-merged events.
// all_repos: true = every repository matches; false + non-empty repos = those entries only;
// false + empty repos = matches nothing (the fail-closed default).
type GitHubPRMergedTriggerConfig struct {
AllRepos bool `json:"all_repos"`
Repos []github.RepoFilter `json:"repos"`
BaseBranches []string `json:"base_branches"`
}
// WebhookTriggerConfig holds configuration for webhook triggers.
type WebhookTriggerConfig struct {
FilterExpression string `json:"filter_expression,omitempty"`
}
// TaskOriginLookup answers the two facts a merged-PR event needs about the
// task its PR was linked to. ok=false means the task could not be resolved
// (absent or query error — the adapter collapses both into ok=false and logs
// at the appropriate level).
type TaskOriginLookup interface {
TaskWorkspaceAndAutomationOrigin(ctx context.Context, taskID string) (
workspaceID string, isAutomationRun bool, ok bool,
)
}
// --- Request/response DTOs ---
// CreateAutomationRequest is the payload for creating an automation.
type CreateAutomationRequest struct {
WorkspaceID string `json:"workspace_id"`
Name string `json:"name"`
Description string `json:"description"`
WorkflowID string `json:"workflow_id"`
WorkflowStepID string `json:"workflow_step_id"`
AgentProfileID string `json:"agent_profile_id"`
ExecutorProfileID string `json:"executor_profile_id"`
RepositoryIDs []string `json:"repository_ids"`
Prompt string `json:"prompt"`
TaskTitleTemplate string `json:"task_title_template"`
MaxConcurrentRuns int `json:"max_concurrent_runs"`
Triggers []CreateTriggerSpec `json:"triggers"`
}
// CreateTriggerSpec defines a trigger to add during automation creation.
type CreateTriggerSpec struct {
Type TriggerType `json:"type"`
Config json.RawMessage `json:"config"`
Enabled bool `json:"enabled"`
}
// UpdateAutomationRequest is the payload for updating an automation.
type UpdateAutomationRequest struct {
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
WorkflowID *string `json:"workflow_id,omitempty"`
WorkflowStepID *string `json:"workflow_step_id,omitempty"`
AgentProfileID *string `json:"agent_profile_id,omitempty"`
ExecutorProfileID *string `json:"executor_profile_id,omitempty"`
// RepositoryIDs replaces the automation's repository list when non-nil.
// nil means "leave unchanged"; an explicit empty slice clears it.
RepositoryIDs []string `json:"repository_ids,omitempty"`
Prompt *string `json:"prompt,omitempty"`
TaskTitleTemplate *string `json:"task_title_template,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
MaxConcurrentRuns *int `json:"max_concurrent_runs,omitempty"`
}
// AddTriggerRequest adds a trigger to an existing automation.
type AddTriggerRequest struct {
AutomationID string `json:"automation_id"`
Type TriggerType `json:"type"`
Config json.RawMessage `json:"config"`
Enabled bool `json:"enabled"`
}
// UpdateTriggerRequest updates a trigger's config or enabled state.
type UpdateTriggerRequest struct {
Config *json.RawMessage `json:"config,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
}
// CreateAutomationResponse wraps a newly-created automation and returns the
// generated webhook secret in plaintext exactly once, so the UI can show the
// user a value they can paste into an external system. Subsequent GET / list
// responses keep the secret hidden (WebhookSecret is `json:"-"`) — the
// dedicated reveal endpoint is the way back to it.
type CreateAutomationResponse struct {
*Automation
WebhookSecret string `json:"webhook_secret"`
}
// RevealWebhookSecretResponse returns the webhook secret for an automation.
type RevealWebhookSecretResponse struct {
WebhookSecret string `json:"webhook_secret"`
}
// AutomationTriggeredEvent is published when a trigger fires.
type AutomationTriggeredEvent struct {
AutomationID string `json:"automation_id"`
TriggerID string `json:"trigger_id"`
TriggerType TriggerType `json:"trigger_type"`
TriggerData json.RawMessage `json:"trigger_data"`
DedupKey string `json:"dedup_key"`
}
// RepositoryLookup resolves a repository's workspace ownership for
// validating an automation's repository_ids. Structurally identical to the
// jira/linear/gitlab RepositoryLookup interfaces — the same
// repositoryLookupAdapter over the task service satisfies all of them.
type RepositoryLookup interface {
GetRepository(ctx context.Context, id string) (workspaceID, defaultBranch string, ok bool)
}