Files
kandev/apps/backend/internal/automation/store.go
T
2026-08-24 11:09:03 +00:00

1161 lines
45 KiB
Go

package automation
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/kandev/kandev/internal/db"
taskmodels "github.com/kandev/kandev/internal/task/models"
)
// Store provides SQLite persistence for automations.
type Store struct {
db *sqlx.DB // writer
ro *sqlx.DB // reader
}
// NewStore creates a new automation store and initializes the schema.
func NewStore(writer, reader *sqlx.DB) (*Store, error) {
s := &Store{db: writer, ro: reader}
if err := s.initSchema(); err != nil {
return nil, fmt.Errorf("automation schema init: %w", err)
}
return s, nil
}
const createTablesSQL = `
CREATE TABLE IF NOT EXISTS automations (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT DEFAULT '',
workflow_id TEXT NOT NULL,
workflow_step_id TEXT NOT NULL,
agent_profile_id TEXT NOT NULL,
executor_profile_id TEXT NOT NULL,
repository_id TEXT NOT NULL DEFAULT '',
prompt TEXT DEFAULT '',
task_title_template TEXT DEFAULT '',
-- execution_mode is retained so existing rows need no migration. The
-- task/run choice is withdrawn: no firing path consults it. The one
-- surviving reader is the migration notice, which derives a single
-- boolean from it (see automationColumns). New rows are written with
-- an explicit '' — the 'task' DEFAULT below only ever describes rows
-- that predate the withdrawal.
execution_mode TEXT NOT NULL DEFAULT 'task',
enabled BOOLEAN DEFAULT 1,
max_concurrent_runs INTEGER DEFAULT 1,
webhook_secret TEXT DEFAULT '',
last_triggered_at DATETIME,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
CREATE TABLE IF NOT EXISTS automation_triggers (
id TEXT PRIMARY KEY,
automation_id TEXT NOT NULL REFERENCES automations(id) ON DELETE CASCADE,
type TEXT NOT NULL,
config TEXT NOT NULL DEFAULT '{}',
enabled BOOLEAN DEFAULT 1,
last_evaluated_at DATETIME,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_automation_triggers_automation ON automation_triggers(automation_id);
CREATE TABLE IF NOT EXISTS automation_runs (
id TEXT PRIMARY KEY,
automation_id TEXT NOT NULL REFERENCES automations(id) ON DELETE CASCADE,
trigger_id TEXT NOT NULL,
trigger_type TEXT NOT NULL,
task_id TEXT DEFAULT '',
status TEXT NOT NULL,
dedup_key TEXT DEFAULT '',
trigger_data TEXT NOT NULL DEFAULT '{}',
error_message TEXT DEFAULT '',
created_at DATETIME NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_automation_runs_automation ON automation_runs(automation_id);
CREATE INDEX IF NOT EXISTS idx_automation_runs_dedup ON automation_runs(automation_id, dedup_key);
CREATE INDEX IF NOT EXISTS idx_automation_runs_created_at ON automation_runs(created_at DESC);
-- The summary query asks each automation for its newest run, ordered by
-- (created_at, id) — the ordering every run query uses. Without the
-- composite the per-automation lookup sorts that automation's whole run
-- history, once per candidate row, which is quadratic in a workspace's run
-- count. /automations runs this on load and every poll while anything is
-- running, so the sort shows up as seconds of latency, not milliseconds.
CREATE INDEX IF NOT EXISTS idx_automation_runs_automation_created ON automation_runs(automation_id, created_at DESC, id DESC);
CREATE TABLE IF NOT EXISTS automation_repositories (
id TEXT PRIMARY KEY,
automation_id TEXT NOT NULL REFERENCES automations(id) ON DELETE CASCADE,
repository_id TEXT NOT NULL,
position INTEGER NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL,
UNIQUE(automation_id, repository_id)
);
CREATE INDEX IF NOT EXISTS idx_automation_repositories_automation ON automation_repositories(automation_id);
`
// In-branch column additions. The canonical CREATE TABLE covers fresh
// installs; these ALTERs cover DBs already initialised from an earlier
// commit on this branch (the original PR #406 schema). SQLite returns a
// duplicate-column error when the column already exists, which we swallow.
//
// automations.repository_id is retained as a legacy, write-once column: it
// is never read or written by current code (repository selection now lives
// in automation_repositories), but dropping a column referenced by two
// FK-child tables (automation_triggers, automation_runs) under
// foreign_keys=on would require table-recreate migration infrastructure
// this package doesn't have yet. Every query that scans a full Automation
// row uses the explicit automationColumns list, which omits it, so its
// continued presence in the table is inert.
//
// migrateExecutionModeSQL still runs because the notice derivation in
// automationColumns needs the column to exist on every DB it queries,
// including one initialised before the column was ever added.
const (
migrateTaskTitleSQL = `ALTER TABLE automations ADD COLUMN task_title_template TEXT DEFAULT ''`
migrateExecutionModeSQL = `ALTER TABLE automations ADD COLUMN execution_mode TEXT NOT NULL DEFAULT 'task'`
migrateRepositoryIDSQL = `ALTER TABLE automations ADD COLUMN repository_id TEXT NOT NULL DEFAULT ''`
)
// automationColumns is the explicit column list for every query that scans a
// full Automation row. Spelled out rather than `SELECT *` because the table
// carries columns the Automation struct does not: the legacy repository_id
// (superseded by automation_repositories — see the comment above
// migrateRepositoryIDSQL) and the withdrawn execution_mode, neither of which
// sqlx could map. sqlx runs in safe mode, so a returned column with no struct
// destination is a runtime error, not a compile-time one.
//
// execution_mode is read here, and only here, as the comparison
// `execution_mode = 'task'` aliased to legacy_board_card. The raw value is
// deliberately never selected: what the reader needs is the one bit "did this
// automation used to put a card on the board", for a migration notice that
// closes once. Projecting the mode itself would hand every future caller a
// mode to branch on, and the whole point of withdrawing it is that no firing
// path has one. See docs/specs/office/requirements/automations-settings.md § Migration.
const automationColumns = `id, workspace_id, name, description, workflow_id, workflow_step_id,
agent_profile_id, executor_profile_id, prompt, task_title_template,
enabled, max_concurrent_runs, webhook_secret, last_triggered_at, created_at, updated_at,
execution_mode = 'task' AS legacy_board_card`
func (s *Store) initSchema() error {
if _, err := s.db.Exec(createTablesSQL); err != nil {
return err
}
s.db.Exec(migrateTaskTitleSQL) //nolint:errcheck // duplicate-column on existing DBs
s.db.Exec(migrateExecutionModeSQL) //nolint:errcheck // duplicate-column on existing DBs
s.db.Exec(migrateRepositoryIDSQL) //nolint:errcheck // duplicate-column on existing DBs
return s.backfillLegacyRepositoryIDs()
}
// backfillLegacyRepositoryIDs copies every non-empty legacy
// automations.repository_id value into automation_repositories (position 0)
// the first time a DB upgrades to this schema. Idempotent: the
// UNIQUE(automation_id, repository_id) constraint plus INSERT OR IGNORE
// means re-running this on an already-migrated DB inserts nothing new.
func (s *Store) backfillLegacyRepositoryIDs() error {
type legacyRow struct {
ID string `db:"id"`
RepositoryID string `db:"repository_id"`
CreatedAt time.Time `db:"created_at"`
}
var rows []legacyRow
err := s.db.Select(&rows,
`SELECT id, repository_id, created_at FROM automations WHERE repository_id != ''`)
if err != nil {
return fmt.Errorf("select legacy repository_id rows: %w", err)
}
if len(rows) == 0 {
return nil
}
tx, err := s.db.Beginx()
if err != nil {
return fmt.Errorf("begin legacy repository_id backfill: %w", err)
}
defer func() { _ = tx.Rollback() }()
for _, row := range rows {
_, err := tx.Exec(
`INSERT OR IGNORE INTO automation_repositories (id, automation_id, repository_id, position, created_at)
VALUES (?, ?, ?, 0, ?)`,
uuid.New().String(), row.ID, row.RepositoryID, row.CreatedAt)
if err != nil {
return fmt.Errorf("backfill automation_repositories for %s: %w", row.ID, err)
}
// Clear the legacy column in the same transaction as the insert.
// Without this, a later UpdateAutomation that removes this exact
// repository from automation_repositories would resurrect it on
// the next boot's backfill pass (INSERT OR IGNORE only blocks
// exact re-insertion, not re-addition after deletion).
if _, err := tx.Exec(`UPDATE automations SET repository_id = '' WHERE id = ?`, row.ID); err != nil {
return fmt.Errorf("clear legacy repository_id for %s: %w", row.ID, err)
}
}
return tx.Commit()
}
// --- Automation CRUD ---
// CreateAutomation persists a new automation and its repository_ids.
func (s *Store) CreateAutomation(ctx context.Context, a *Automation) error {
if a.ID == "" {
a.ID = uuid.New().String()
}
if a.WebhookSecret == "" {
a.WebhookSecret = generateSecret()
}
now := time.Now().UTC()
a.CreatedAt = now
a.UpdatedAt = now
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
// execution_mode is written as the empty string rather than left to the
// column's DEFAULT. Nothing reads the mode to decide anything — but the
// DEFAULT is 'task', which is exactly the value the migration notice
// treats as "this automation used to put a card on the board". Letting
// the DEFAULT fill it would make every automation created from here on
// indistinguishable from a pre-upgrade one, and the one-time notice would
// never stop being true. Empty means "no mode was ever chosen", which is
// the honest record for a row created after the choice was withdrawn.
_, err = tx.ExecContext(ctx, `
INSERT INTO automations (id, workspace_id, name, description, workflow_id, workflow_step_id,
agent_profile_id, executor_profile_id,
prompt, task_title_template, execution_mode,
enabled, max_concurrent_runs,
webhook_secret, last_triggered_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', ?, ?, ?, ?, ?, ?)`,
a.ID, a.WorkspaceID, a.Name, a.Description, a.WorkflowID, a.WorkflowStepID,
a.AgentProfileID, a.ExecutorProfileID,
a.Prompt, a.TaskTitleTemplate,
a.Enabled, a.MaxConcurrentRuns,
a.WebhookSecret, a.LastTriggeredAt, a.CreatedAt, a.UpdatedAt)
if err != nil {
return err
}
if err := insertAutomationRepositories(ctx, tx, a.ID, a.RepositoryIDs); err != nil {
return err
}
return tx.Commit()
}
// insertAutomationRepositories inserts one automation_repositories row per
// ID, preserving slice order via the position column. No-op for an empty
// slice. Shared by CreateAutomation and UpdateAutomation's replace path.
func insertAutomationRepositories(ctx context.Context, tx *sqlx.Tx, automationID string, repositoryIDs []string) error {
now := time.Now().UTC()
for i, repositoryID := range repositoryIDs {
_, err := tx.ExecContext(ctx, `
INSERT INTO automation_repositories (id, automation_id, repository_id, position, created_at)
VALUES (?, ?, ?, ?, ?)`,
uuid.New().String(), automationID, repositoryID, i, now)
if err != nil {
return fmt.Errorf("insert automation_repositories: %w", err)
}
}
return nil
}
// GetAutomation returns an automation by ID with its triggers and
// repository_ids hydrated.
func (s *Store) GetAutomation(ctx context.Context, id string) (*Automation, error) {
var a Automation
err := s.ro.GetContext(ctx, &a, `SELECT `+automationColumns+` FROM automations WHERE id = ?`, id)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
triggers, err := s.ListTriggers(ctx, id)
if err != nil {
return nil, fmt.Errorf("hydrate triggers: %w", err)
}
a.Triggers = triggers
repoIDs, err := s.listRepositoryIDsForAutomations(ctx, []string{id})
if err != nil {
return nil, fmt.Errorf("hydrate repository_ids: %w", err)
}
a.RepositoryIDs = repoIDs[id]
return &a, nil
}
// listRepositoryIDsForAutomations batch-loads ordered repository_ids for
// several automations at once, mirroring listTriggersForAutomations.
func (s *Store) listRepositoryIDsForAutomations(ctx context.Context, automationIDs []string) (map[string][]string, error) {
if len(automationIDs) == 0 {
return make(map[string][]string), nil
}
query, args, err := sqlx.In(
`SELECT automation_id, repository_id FROM automation_repositories
WHERE automation_id IN (?) ORDER BY automation_id, position`, automationIDs)
if err != nil {
return nil, err
}
query = s.ro.Rebind(query)
type row struct {
AutomationID string `db:"automation_id"`
RepositoryID string `db:"repository_id"`
}
var rows []row
if err := s.ro.SelectContext(ctx, &rows, query, args...); err != nil {
return nil, err
}
result := make(map[string][]string, len(automationIDs))
for _, id := range automationIDs {
result[id] = []string{}
}
for _, r := range rows {
result[r.AutomationID] = append(result[r.AutomationID], r.RepositoryID)
}
return result, nil
}
// AgentProfileBinding names one automation bound to an agent profile.
type AgentProfileBinding struct {
ID string `db:"id"`
Name string `db:"name"`
WorkspaceID string `db:"workspace_id"`
}
// ListEnabledByAgentProfile returns the enabled automations that would launch
// against the given agent profile.
//
// Only enabled ones: a disabled automation is not going to fire, so naming it
// as a reason you cannot delete a profile is noise. Triggers and repositories
// are deliberately not hydrated — the caller wants identity, not the object.
func (s *Store) ListEnabledByAgentProfile(ctx context.Context, agentProfileID string) ([]AgentProfileBinding, error) {
if agentProfileID == "" {
return nil, nil
}
var rows []AgentProfileBinding
err := s.ro.SelectContext(ctx, &rows, s.ro.Rebind(`
SELECT id, name, workspace_id FROM automations
WHERE agent_profile_id = ? AND enabled = 1
ORDER BY name
`), agentProfileID)
if err != nil {
return nil, err
}
return rows, nil
}
// DisableByAgentProfile turns off every enabled automation bound to the given
// agent profile, returning the ones it disabled so the caller can report.
//
// Used when a profile is force-deleted: an automation left enabled would keep
// firing on its schedule into a profile that no longer exists. Disabling rather
// than deleting keeps the automation recoverable — the user picks a new profile
// and toggles it back on.
func (s *Store) DisableByAgentProfile(ctx context.Context, agentProfileID string) ([]AgentProfileBinding, error) {
if agentProfileID == "" {
return nil, nil
}
bindings, err := s.ListEnabledByAgentProfile(ctx, agentProfileID)
if err != nil {
return nil, err
}
if len(bindings) == 0 {
return nil, nil
}
_, err = s.db.ExecContext(ctx, s.db.Rebind(`
UPDATE automations SET enabled = 0, updated_at = ?
WHERE agent_profile_id = ? AND enabled = 1
`), time.Now().UTC(), agentProfileID)
if err != nil {
return nil, fmt.Errorf("disable automations for agent profile: %w", err)
}
return bindings, nil
}
// ListAutomations returns all automations for a workspace with triggers and
// repository_ids hydrated.
func (s *Store) ListAutomations(ctx context.Context, workspaceID string) ([]*Automation, error) {
var automations []*Automation
err := s.ro.SelectContext(ctx, &automations,
`SELECT `+automationColumns+` FROM automations WHERE workspace_id = ? ORDER BY created_at DESC`, workspaceID)
if err != nil {
return nil, err
}
return automations, s.hydrateAutomations(ctx, automations)
}
// ListAllEnabled returns all enabled automations (across workspaces).
func (s *Store) ListAllEnabled(ctx context.Context) ([]*Automation, error) {
var automations []*Automation
err := s.ro.SelectContext(ctx, &automations,
`SELECT `+automationColumns+` FROM automations WHERE enabled = 1 ORDER BY created_at`)
if err != nil {
return nil, err
}
return automations, s.hydrateAutomations(ctx, automations)
}
// hydrateAutomations batch-loads triggers and repository_ids onto an
// already-fetched automations slice. Shared by ListAutomations/ListAllEnabled.
func (s *Store) hydrateAutomations(ctx context.Context, automations []*Automation) error {
if len(automations) == 0 {
return nil
}
ids := make([]string, len(automations))
for i, a := range automations {
ids[i] = a.ID
}
triggersByAutomation, err := s.listTriggersForAutomations(ctx, ids)
if err != nil {
return fmt.Errorf("hydrate triggers: %w", err)
}
repoIDsByAutomation, err := s.listRepositoryIDsForAutomations(ctx, ids)
if err != nil {
return fmt.Errorf("hydrate repository_ids: %w", err)
}
for _, a := range automations {
a.Triggers = triggersByAutomation[a.ID]
a.RepositoryIDs = repoIDsByAutomation[a.ID]
}
return nil
}
// UpdateAutomation applies partial updates to an automation. When
// req.RepositoryIDs is non-nil, it atomically replaces the automation's
// automation_repositories rows (nil means "leave unchanged"; an explicit
// empty slice clears the list).
func (s *Store) UpdateAutomation(ctx context.Context, id string, req *UpdateAutomationRequest) error {
a, err := s.GetAutomation(ctx, id)
if err != nil {
return err
}
if a == nil {
return fmt.Errorf("automation not found: %s", id)
}
applyAutomationUpdate(a, req)
a.UpdatedAt = time.Now().UTC()
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
_, err = tx.ExecContext(ctx, `
UPDATE automations SET name = ?, description = ?, workflow_id = ?, workflow_step_id = ?,
agent_profile_id = ?, executor_profile_id = ?,
prompt = ?, task_title_template = ?,
enabled = ?, max_concurrent_runs = ?, updated_at = ?
WHERE id = ?`,
a.Name, a.Description, a.WorkflowID, a.WorkflowStepID,
a.AgentProfileID, a.ExecutorProfileID,
a.Prompt, a.TaskTitleTemplate,
a.Enabled, a.MaxConcurrentRuns, a.UpdatedAt, id)
if err != nil {
return err
}
if req.RepositoryIDs != nil {
if _, err := tx.ExecContext(ctx, `DELETE FROM automation_repositories WHERE automation_id = ?`, id); err != nil {
return fmt.Errorf("clear automation_repositories: %w", err)
}
if err := insertAutomationRepositories(ctx, tx, id, req.RepositoryIDs); err != nil {
return err
}
}
return tx.Commit()
}
func applyAutomationUpdate(a *Automation, req *UpdateAutomationRequest) {
if req.Name != nil {
a.Name = *req.Name
}
if req.Description != nil {
a.Description = *req.Description
}
if req.WorkflowID != nil {
a.WorkflowID = *req.WorkflowID
}
if req.WorkflowStepID != nil {
a.WorkflowStepID = *req.WorkflowStepID
}
if req.AgentProfileID != nil {
a.AgentProfileID = *req.AgentProfileID
}
if req.ExecutorProfileID != nil {
a.ExecutorProfileID = *req.ExecutorProfileID
}
if req.Prompt != nil {
a.Prompt = *req.Prompt
}
if req.Enabled != nil {
a.Enabled = *req.Enabled
}
if req.MaxConcurrentRuns != nil {
a.MaxConcurrentRuns = *req.MaxConcurrentRuns
}
if req.TaskTitleTemplate != nil {
a.TaskTitleTemplate = *req.TaskTitleTemplate
}
}
// DeleteAutomation removes an automation and its triggers/runs (CASCADE).
func (s *Store) DeleteAutomation(ctx context.Context, id string) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM automations WHERE id = ?`, id)
return err
}
// UpdateLastTriggered updates the last_triggered_at timestamp.
func (s *Store) UpdateLastTriggered(ctx context.Context, id string, t time.Time) error {
_, err := s.db.ExecContext(ctx,
`UPDATE automations SET last_triggered_at = ?, updated_at = ? WHERE id = ?`,
t, time.Now().UTC(), id)
return err
}
// --- Trigger CRUD ---
// CreateTrigger adds a trigger to an automation.
func (s *Store) CreateTrigger(ctx context.Context, t *AutomationTrigger) error {
if t.ID == "" {
t.ID = uuid.New().String()
}
now := time.Now().UTC()
t.CreatedAt = now
t.UpdatedAt = now
t.ConfigJSON = string(t.Config)
_, err := s.db.ExecContext(ctx, `
INSERT INTO automation_triggers (id, automation_id, type, config, enabled, last_evaluated_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
t.ID, t.AutomationID, t.Type, t.ConfigJSON, t.Enabled, t.LastEvaluatedAt, t.CreatedAt, t.UpdatedAt)
return err
}
// GetTriggerAutomationID resolves the automation a trigger belongs to. Used by
// the auth layer to authorize trigger mutations by workspace ownership.
func (s *Store) GetTriggerAutomationID(ctx context.Context, triggerID string) (string, error) {
var automationID string
err := s.ro.GetContext(ctx, &automationID,
`SELECT automation_id FROM automation_triggers WHERE id = ?`, triggerID)
return automationID, err
}
// GetTrigger returns a single trigger, or nil when it no longer exists.
func (s *Store) GetTrigger(ctx context.Context, id string) (*AutomationTrigger, error) {
var triggers []AutomationTrigger
if err := s.ro.SelectContext(ctx, &triggers,
`SELECT * FROM automation_triggers WHERE id = ?`, id); err != nil {
return nil, err
}
if len(triggers) == 0 {
return nil, nil
}
hydrateTriggers(triggers)
return &triggers[0], nil
}
// ListTriggers returns all triggers for an automation.
func (s *Store) ListTriggers(ctx context.Context, automationID string) ([]AutomationTrigger, error) {
var triggers []AutomationTrigger
err := s.ro.SelectContext(ctx, &triggers,
`SELECT * FROM automation_triggers WHERE automation_id = ? ORDER BY created_at`, automationID)
hydrateTriggers(triggers)
return triggers, err
}
// hydrateTriggers converts the ConfigJSON string field to the Config json.RawMessage.
func hydrateTriggers(triggers []AutomationTrigger) {
for i := range triggers {
triggers[i].Config = json.RawMessage(triggers[i].ConfigJSON)
}
}
func (s *Store) listTriggersForAutomations(ctx context.Context, automationIDs []string) (map[string][]AutomationTrigger, error) {
if len(automationIDs) == 0 {
return make(map[string][]AutomationTrigger), nil
}
query, args, err := sqlx.In(
`SELECT * FROM automation_triggers WHERE automation_id IN (?) ORDER BY created_at`, automationIDs)
if err != nil {
return nil, err
}
query = s.ro.Rebind(query)
var triggers []AutomationTrigger
if err := s.ro.SelectContext(ctx, &triggers, query, args...); err != nil {
return nil, err
}
hydrateTriggers(triggers)
result := make(map[string][]AutomationTrigger, len(automationIDs))
for i := range triggers {
result[triggers[i].AutomationID] = append(result[triggers[i].AutomationID], triggers[i])
}
return result, nil
}
// UpdateTrigger applies partial updates to a trigger.
func (s *Store) UpdateTrigger(ctx context.Context, id string, req *UpdateTriggerRequest) error {
var t AutomationTrigger
err := s.ro.GetContext(ctx, &t, `SELECT * FROM automation_triggers WHERE id = ?`, id)
if errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("trigger not found: %s", id)
}
if err != nil {
return err
}
if req.Config != nil {
t.ConfigJSON = string(*req.Config)
}
if req.Enabled != nil {
t.Enabled = *req.Enabled
}
t.UpdatedAt = time.Now().UTC()
_, err = s.db.ExecContext(ctx,
`UPDATE automation_triggers SET config = ?, enabled = ?, updated_at = ? WHERE id = ?`,
t.ConfigJSON, t.Enabled, t.UpdatedAt, id)
return err
}
// DeleteTrigger removes a trigger.
func (s *Store) DeleteTrigger(ctx context.Context, id string) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM automation_triggers WHERE id = ?`, id)
return err
}
// UpdateTriggerEvaluatedAt sets the last_evaluated_at timestamp.
func (s *Store) UpdateTriggerEvaluatedAt(ctx context.Context, id string, t time.Time) error {
_, err := s.db.ExecContext(ctx,
`UPDATE automation_triggers SET last_evaluated_at = ?, updated_at = ? WHERE id = ?`,
t, time.Now().UTC(), id)
return err
}
// ListEnabledTriggersByType returns enabled triggers of a specific type (across all enabled automations).
func (s *Store) ListEnabledTriggersByType(ctx context.Context, triggerType TriggerType) ([]AutomationTrigger, error) {
var triggers []AutomationTrigger
err := s.ro.SelectContext(ctx, &triggers, `
SELECT t.* FROM automation_triggers t
JOIN automations a ON a.id = t.automation_id
WHERE t.type = ? AND t.enabled = 1 AND a.enabled = 1
ORDER BY t.created_at ASC, t.id ASC`, string(triggerType))
hydrateTriggers(triggers)
return triggers, err
}
// --- Run operations ---
// CreateRun records a trigger firing.
func (s *Store) CreateRun(ctx context.Context, r *AutomationRun) error {
if r.ID == "" {
r.ID = uuid.New().String()
}
r.CreatedAt = time.Now().UTC()
r.TriggerDataJSON = string(r.TriggerData)
_, err := s.db.ExecContext(ctx, `
INSERT INTO automation_runs (id, automation_id, trigger_id, trigger_type, task_id, status,
dedup_key, trigger_data, error_message, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
r.ID, r.AutomationID, r.TriggerID, r.TriggerType, r.TaskID, r.Status,
r.DedupKey, r.TriggerDataJSON, r.ErrorMessage, r.CreatedAt)
return err
}
// MarkRunFailedByTaskID flips the most recent task_created run for a task
// into the failed state. Used when a downstream condition (e.g. a permission
// prompt an automation run can't answer) makes the run effectively
// dead. No-op if no matching run is found.
func (s *Store) MarkRunFailedByTaskID(ctx context.Context, taskID, errMsg string) error {
return s.updateRunTerminalStatus(ctx, taskID, RunStatusFailed, errMsg)
}
// MarkRunSucceededByTaskID flips the most recent task_created run for a task
// into the succeeded state. Used when an automation-launched agent completes
// without error.
func (s *Store) MarkRunSucceededByTaskID(ctx context.Context, taskID string) error {
return s.updateRunTerminalStatus(ctx, taskID, RunStatusSucceeded, "")
}
// updateRunTerminalStatus is the shared implementation behind MarkRun{Failed,Succeeded}ByTaskID.
func (s *Store) updateRunTerminalStatus(ctx context.Context, taskID string, status RunStatus, errMsg string) error {
if taskID == "" {
return nil
}
_, err := s.db.ExecContext(ctx, `
UPDATE automation_runs SET status = ?, error_message = ?
WHERE id = (
SELECT id FROM automation_runs
WHERE task_id = ? AND status = ?
ORDER BY created_at DESC LIMIT 1
)`,
string(status), errMsg, taskID, string(RunStatusTaskCreated))
return err
}
// ListRuns returns recent runs for an automation. A task_created run whose
// generated task has been archived is reported as archived; one whose task
// is gone entirely or explicitly cancelled is reported as cancelled — see
// listRunsWithTaskState for the full precedence — without touching runs
// that already reached a real terminal status. Falls back to the raw
// stored status when the tasks table isn't present (isolated
// automation-only tests; production always has it, migrated by the task
// repository before automation triggers can fire).
func (s *Store) ListRuns(ctx context.Context, automationID string, limit int) ([]*AutomationRun, error) {
if limit <= 0 {
limit = 50
}
runs, err := s.listRunsWithTaskState(ctx, automationID, limit)
if db.IsMissingTableError(err) {
runs, err = s.listRunsRaw(ctx, automationID, limit)
}
if err != nil {
return nil, err
}
for _, r := range runs {
r.TriggerData = json.RawMessage(r.TriggerDataJSON)
}
return runs, nil
}
// runTaskStateColumnsSQL is the read-time projection of a run, shared
// verbatim by the per-automation and workspace-wide list queries. It is a
// const rather than duplicated SQL because the status derivation below is
// the app's definition of what a run's status *means* to a reader: two
// copies would drift, and the same run would then report one status on the
// automation's settings page and another in the workspace feed. Assumes
// the caller aliases automation_runs as ar and LEFT JOINs tasks as t, and
// binds runTaskStateArgs() ahead of its own WHERE/LIMIT parameters.
//
// Assumes a task_created run always carries a non-empty ar.task_id: the
// sole production write path (orchestrator's recordSuccessRun) sets TaskID
// and Status together in the same INSERT. If that's ever violated, the
// LEFT JOIN never matches an empty task_id against a real task row, so the
// run falls into the "no live task" branch below and displays as cancelled
// rather than its raw stored status — reachable today only through the e2e
// run-seeding endpoint, never in production.
//
// Three read-time overrides of a still-open task_created run, in priority
// order: (1) the task row is gone entirely — deleted, outcome
// unrecoverable — shown as cancelled; (2) the task was archived
// (archived_at set, via the UI or by the agent itself, e.g. an "archive
// this task" instruction) — shown as archived, which takes precedence over
// the session check below; (3) the task's current (is_primary) session is
// CANCELLED — set only when an agent run was manually stopped
// (coordinator/MCP stop_task, or the UI Stop button) — a deliberate
// cancellation, shown as cancelled. Task.state is deliberately NOT
// consulted here: stopping an agent leaves the task itself at whatever
// state the stop caller chose (e.g. REVIEW) and only ever marks the
// *session* CANCELLED — see orchestrator.handleAgentStopped's "we do NOT
// update task state here" note. Filtering on is_primary picks the task's
// current session, so a resumed-and-completed task isn't misclassified by
// a stale cancelled session left over from an earlier stop. EXISTS (rather
// than a LEFT JOIN) keeps the query correct even if the "at most one
// is_primary=1 row per task" invariant is ever violated — a join would fan
// out and duplicate the run.
const runTaskStateColumnsSQL = `
ar.id, ar.automation_id, ar.trigger_id, ar.trigger_type,
-- A run keeps its task_id after the task row is deleted, which reads as a
-- transcript that can still be opened. Report no task rather than a link
-- that dead-ends; the derived cancelled status below already says why.
CASE WHEN t.id IS NULL THEN '' ELSE ar.task_id END AS task_id,
CASE
WHEN ar.status = ? AND t.id IS NULL THEN ?
WHEN ar.status = ? AND t.archived_at IS NOT NULL THEN ?
WHEN ar.status = ? AND EXISTS (
SELECT 1 FROM task_sessions ts
WHERE ts.task_id = ar.task_id AND ts.is_primary = 1 AND ts.state = ?
) THEN ?
ELSE ar.status
END AS status,
ar.dedup_key, ar.trigger_data, ar.error_message, ar.created_at,
COALESCE((
SELECT substr(m.content, 1, 280) FROM task_session_messages m
WHERE m.task_id = ar.task_id
AND m.author_type = 'agent'
AND m.type = 'message'
ORDER BY m.created_at DESC, m.id DESC LIMIT 1
), '') AS summary,
-- The run's conversation. The detail view reads the transcript in place
-- rather than sending the reader to the task page, and the chat panel is
-- driven by a session id, not a task id — resolving it here keeps that
-- one query instead of one per run on the client.
COALESCE((
SELECT ts.id FROM task_sessions ts
WHERE ts.task_id = ar.task_id AND ts.is_primary = 1
LIMIT 1
), '') AS session_id`
// runTaskStateArgs binds the placeholders in runTaskStateColumnsSQL, in
// order. Kept next to the SQL so a new WHEN can't be added without the
// matching argument being obvious.
func runTaskStateArgs() []any {
return []any{
string(RunStatusTaskCreated), string(RunStatusCancelled),
string(RunStatusTaskCreated), string(RunStatusArchived),
string(RunStatusTaskCreated), string(taskmodels.TaskSessionStateCancelled), string(RunStatusCancelled),
}
}
func (s *Store) listRunsWithTaskState(ctx context.Context, automationID string, limit int) ([]*AutomationRun, error) {
var runs []*AutomationRun
err := s.ro.SelectContext(ctx, &runs, `
SELECT`+runTaskStateColumnsSQL+`
FROM automation_runs ar
LEFT JOIN tasks t ON t.id = ar.task_id
WHERE ar.automation_id = ?
ORDER BY `+runOrderSQL("ar")+` LIMIT ?`,
append(runTaskStateArgs(), automationID, limit)...)
return runs, err
}
func (s *Store) listRunsRaw(ctx context.Context, automationID string, limit int) ([]*AutomationRun, error) {
var runs []*AutomationRun
err := s.ro.SelectContext(ctx, &runs,
`SELECT * FROM automation_runs WHERE automation_id = ? ORDER BY created_at DESC, id DESC LIMIT ?`,
automationID, limit)
return runs, err
}
// maxWorkspaceRunsLimit caps the workspace-wide feed. Unlike ListRuns, which
// is scoped to one automation the user is already looking at, this query
// spans every automation in the workspace — an uncapped limit would let a
// single client pull the entire run history over the socket.
const maxWorkspaceRunsLimit = 200
// ListWorkspaceRuns returns recent runs across every automation in a
// workspace, newest first, each attributed to its automation. Status
// derivation and summary are identical to ListRuns — same
// runTaskStateColumnsSQL — so a run reads the same way in the workspace
// feed as it does on its own automation's page. Falls back to raw stored
// status when the tasks table isn't present, for the same reason ListRuns
// does (isolated automation-only tests; production always has it).
func (s *Store) ListWorkspaceRuns(ctx context.Context, workspaceID string, limit int) ([]*WorkspaceAutomationRun, error) {
if limit <= 0 {
limit = 50
}
if limit > maxWorkspaceRunsLimit {
limit = maxWorkspaceRunsLimit
}
runs, err := s.listWorkspaceRunsWithTaskState(ctx, workspaceID, limit)
if db.IsMissingTableError(err) {
runs, err = s.listWorkspaceRunsRaw(ctx, workspaceID, limit)
}
if err != nil {
return nil, err
}
for _, r := range runs {
r.TriggerData = json.RawMessage(r.TriggerDataJSON)
}
return runs, nil
}
func (s *Store) listWorkspaceRunsWithTaskState(ctx context.Context, workspaceID string, limit int) ([]*WorkspaceAutomationRun, error) {
// The automations join is an INNER join, not a LEFT one: a run whose
// automation is gone can't be attributed to anything the reader can
// open, and the ON DELETE CASCADE means it shouldn't exist anyway.
var runs []*WorkspaceAutomationRun
err := s.ro.SelectContext(ctx, &runs, `
SELECT`+runTaskStateColumnsSQL+`,
a.name AS automation_name
FROM automation_runs ar
JOIN automations a ON a.id = ar.automation_id
LEFT JOIN tasks t ON t.id = ar.task_id
WHERE a.workspace_id = ?
ORDER BY `+runOrderSQL("ar")+` LIMIT ?`,
append(runTaskStateArgs(), workspaceID, limit)...)
return runs, err
}
// ListAutomationSummaries returns one row per automation in a workspace that
// has ever run: its newest run and how many of its runs are still open.
//
// The runs list used to derive both by scanning the workspace feed, which is
// capped. Past the cap an automation's newest run falls out of the window and
// its row reports "No runs yet" and idle — the two things a health indicator
// must never get wrong — and the open count backing "won't fire: still
// running" silently drops to zero. Answering per automation makes the row's
// claims independent of how noisy its neighbours are.
func (s *Store) ListAutomationSummaries(ctx context.Context, workspaceID string) ([]*AutomationSummary, error) {
return s.listSummaries(ctx, "a.workspace_id = ?", workspaceID)
}
// GetAutomationSummary returns one automation's summary, or nil if it has never
// run. The detail page needs the same authoritative open count the list uses:
// its own run window is capped too, so an open run older than the window would
// otherwise leave the page reporting that nothing is in flight.
func (s *Store) GetAutomationSummary(ctx context.Context, automationID string) (*AutomationSummary, error) {
summaries, err := s.listSummaries(ctx, "ar.automation_id = ?", automationID)
if err != nil || len(summaries) == 0 {
return nil, err
}
return summaries[0], nil
}
// listSummaries answers both facts in ONE statement.
//
// Two queries would be two snapshots: a run created between them reads as
// `last_run = task_created` with `open_runs = 0`, so the row renders idle and
// the client never starts polling — permanently stale until a manual refresh.
// The open count is a correlated subquery over the same automation, which also
// means an automation with an open run always has a latest run, so no second
// pass is needed to invent rows for counts without runs.
func (s *Store) listSummaries(ctx context.Context, scope string, arg any) ([]*AutomationSummary, error) {
rows, err := s.selectSummaries(ctx, scope, arg)
if db.IsMissingTableError(err) {
rows, err = s.selectSummariesRaw(ctx, scope, arg)
}
if err != nil {
return nil, err
}
summaries := make([]*AutomationSummary, 0, len(rows))
for _, row := range rows {
run := row.AutomationRun
run.TriggerData = json.RawMessage(run.TriggerDataJSON)
summaries = append(summaries, &AutomationSummary{
AutomationID: run.AutomationID,
OpenRuns: row.OpenRuns,
LastRun: &run,
})
}
return summaries, nil
}
// summaryRow is the wire shape of the single summary query: a run plus its
// automation's open count.
type summaryRow struct {
AutomationRun
OpenRuns int `db:"open_runs"`
}
// openRunsSubquerySQL counts an automation's outstanding runs alongside its
// latest one. Aliased aro/tro so it can nest inside a query that already uses
// ar/t, and it binds openRunArgs() like every other user of the predicate.
var openRunsSubquerySQL = `(
SELECT COUNT(*) FROM automation_runs aro
LEFT JOIN tasks tro ON tro.id = aro.task_id
WHERE aro.automation_id = ar.automation_id AND ` + openRunPredicateAliased("aro", "tro") + `
) AS open_runs`
// latestRunPerAutomationSQL picks each automation's newest run by the same
// ordering every other run query uses (created_at then id), so "the newest run"
// means the row that leads the feed rather than an arbitrary tie-break.
var latestRunPerAutomationSQL = `ar.id = (
SELECT ar2.id FROM automation_runs ar2
WHERE ar2.automation_id = ar.automation_id
ORDER BY ` + runOrderSQL("ar2") + ` LIMIT 1
)`
func (s *Store) selectSummaries(ctx context.Context, scope string, arg any) ([]summaryRow, error) {
var rows []summaryRow
args := runTaskStateArgs()
args = append(args, openRunArgs()...)
args = append(args, arg)
err := s.ro.SelectContext(ctx, &rows, `
SELECT`+runTaskStateColumnsSQL+`,
`+openRunsSubquerySQL+`
FROM automation_runs ar
JOIN automations a ON a.id = ar.automation_id
LEFT JOIN tasks t ON t.id = ar.task_id
WHERE `+scope+` AND `+latestRunPerAutomationSQL,
args...)
return rows, err
}
// selectSummariesRaw is the no-tasks-table fallback the run lists also carry
// (isolated automation-only tests; production always has the table). Without
// tasks there is nothing to derive from, so the stored status stands and the
// open count is a plain status match.
func (s *Store) selectSummariesRaw(ctx context.Context, scope string, arg any) ([]summaryRow, error) {
var rows []summaryRow
err := s.ro.SelectContext(ctx, &rows, `
SELECT ar.*, (
SELECT COUNT(*) FROM automation_runs aro
WHERE aro.automation_id = ar.automation_id AND aro.status = ?
) AS open_runs
FROM automation_runs ar
JOIN automations a ON a.id = ar.automation_id
WHERE `+scope+` AND `+latestRunPerAutomationSQL,
string(RunStatusTaskCreated), arg)
return rows, err
}
func (s *Store) listWorkspaceRunsRaw(ctx context.Context, workspaceID string, limit int) ([]*WorkspaceAutomationRun, error) {
var runs []*WorkspaceAutomationRun
err := s.ro.SelectContext(ctx, &runs, `
SELECT ar.*, a.name AS automation_name
FROM automation_runs ar
JOIN automations a ON a.id = ar.automation_id
WHERE a.workspace_id = ?
ORDER BY `+runOrderSQL("ar")+` LIMIT ?`,
workspaceID, limit)
return runs, err
}
// HasRunWithDedupKey checks if a run with the given dedup key already exists.
func (s *Store) HasRunWithDedupKey(ctx context.Context, automationID, dedupKey string) (bool, error) {
if dedupKey == "" {
return false, nil
}
var count int
err := s.ro.GetContext(ctx, &count,
`SELECT COUNT(*) FROM automation_runs WHERE automation_id = ? AND dedup_key = ?`,
automationID, dedupKey)
return count > 0, err
}
// CountActiveRuns returns the number of runs with task_created status for
// an automation whose generated task is still open. A task_created run
// whose task was archived, deleted, or explicitly cancelled no longer
// represents outstanding work — the user (or agent) closed it out some
// other way — so it must not keep counting against max_concurrent_runs
// forever. Falls back to a plain count when the tasks table isn't present
// (isolated automation-only tests; production always has it).
func (s *Store) CountActiveRuns(ctx context.Context, automationID string) (int, error) {
count, err := s.countActiveRunsWithTaskState(ctx, automationID)
if db.IsMissingTableError(err) {
return s.countActiveRunsRaw(ctx, automationID)
}
return count, err
}
// openRunPredicateSQL is what "this run is still outstanding" means, shared by
// the concurrency-cap count and the per-automation summary the runs list reads.
// One definition, for the same reason runTaskStateColumnsSQL is one: the cap
// deciding a run is open while the list shows the automation idle is a
// contradiction the user cannot resolve from the screen. Assumes automation_runs
// aliased ar with tasks LEFT JOINed as t, and binds two arguments —
// RunStatusTaskCreated and the cancelled session state.
//
// Same non-empty-task_id assumption as listRunsWithTaskState: an empty
// ar.task_id never matches a real task row, so such a run falls out of the
// open set instead of erroring. See listRunsWithTaskState for why the current
// (is_primary) session's state, not the task's own state, is the cancellation
// signal, and why NOT EXISTS rather than a LEFT JOIN.
const openRunPredicateSQL = `ar.status = ?
AND t.id IS NOT NULL AND t.archived_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM task_sessions ts
WHERE ts.task_id = ar.task_id AND ts.is_primary = 1 AND ts.state = ?
)`
// openRunPredicateAliased is the same predicate under different table aliases,
// for the summary query where it nests inside a statement already using ar/t.
// Derived from openRunPredicateSQL rather than written twice so the definition
// of "open" stays in one place.
func openRunPredicateAliased(runAlias, taskAlias string) string {
replaced := strings.ReplaceAll(openRunPredicateSQL, "ar.", runAlias+".")
return strings.ReplaceAll(replaced, "t.", taskAlias+".")
}
// runOrderSQL is how every run query orders: newest first, with the id breaking
// ties. Without the tie-break two runs written in the same second can order one
// way in the feed and the other way in the summary, so the list's "last said"
// would disagree with the entry that leads the automation's own activity.
func runOrderSQL(alias string) string {
return alias + ".created_at DESC, " + alias + ".id DESC"
}
// openRunArgs binds openRunPredicateSQL's placeholders, in order.
func openRunArgs() []any {
return []any{string(RunStatusTaskCreated), string(taskmodels.TaskSessionStateCancelled)}
}
func (s *Store) countActiveRunsWithTaskState(ctx context.Context, automationID string) (int, error) {
var count int
err := s.ro.GetContext(ctx, &count, `
SELECT COUNT(*) FROM automation_runs ar
LEFT JOIN tasks t ON t.id = ar.task_id
WHERE ar.automation_id = ? AND `+openRunPredicateSQL,
append([]any{automationID}, openRunArgs()...)...)
return count, err
}
func (s *Store) countActiveRunsRaw(ctx context.Context, automationID string) (int, error) {
var count int
err := s.ro.GetContext(ctx, &count,
`SELECT COUNT(*) FROM automation_runs WHERE automation_id = ? AND status = ?`,
automationID, string(RunStatusTaskCreated))
return count, err
}
// GetRun returns a single run by ID, or nil if not found.
func (s *Store) GetRun(ctx context.Context, id string) (*AutomationRun, error) {
var r AutomationRun
err := s.ro.GetContext(ctx, &r,
`SELECT * FROM automation_runs WHERE id = ?`, id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, err
}
r.TriggerData = json.RawMessage(r.TriggerDataJSON)
return &r, nil
}
// DeleteRun removes a single run row.
func (s *Store) DeleteRun(ctx context.Context, id string) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM automation_runs WHERE id = ?`, id)
return err
}
// ListRunTaskIDs returns all non-empty task_id values for an automation's runs.
// Used by DeleteAllRuns so the service can clean up tasks before purging rows.
func (s *Store) ListRunTaskIDs(ctx context.Context, automationID string) ([]string, error) {
var ids []string
err := s.ro.SelectContext(ctx, &ids,
`SELECT task_id FROM automation_runs WHERE automation_id = ? AND task_id != ''`,
automationID)
return ids, err
}
// DeleteAllRuns removes every run row for an automation.
func (s *Store) DeleteAllRuns(ctx context.Context, automationID string) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM automation_runs WHERE automation_id = ?`, automationID)
return err
}
// DeleteAutomationsByWorkspace removes all automations (and their triggers/runs) for a workspace.
// Used by e2e reset.
func (s *Store) DeleteAutomationsByWorkspace(ctx context.Context, workspaceID string) (int, error) {
// Get automation IDs first for cascade cleanup.
var ids []string
if err := s.ro.SelectContext(ctx, &ids,
`SELECT id FROM automations WHERE workspace_id = ?`, workspaceID); err != nil {
return 0, err
}
if len(ids) == 0 {
return 0, nil
}
for _, id := range ids {
_, _ = s.db.ExecContext(ctx, `DELETE FROM automation_triggers WHERE automation_id = ?`, id)
_, _ = s.db.ExecContext(ctx, `DELETE FROM automation_runs WHERE automation_id = ?`, id)
}
res, err := s.db.ExecContext(ctx, `DELETE FROM automations WHERE workspace_id = ?`, workspaceID)
if err != nil {
return 0, err
}
n, _ := res.RowsAffected()
return int(n), nil
}
// generateSecret creates a random hex string for webhook authentication.
func generateSecret() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return uuid.New().String()
}
return hex.EncodeToString(b)
}