This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/store/sqlite/workflows.go
Jeffrey Smith 1b08769f09
Some checks failed
CI/CD / detect-changes (pull_request) Successful in 4s
CI/CD / test-frontend (pull_request) Successful in 5s
CI/CD / test-go-pg (pull_request) Failing after 1m56s
CI/CD / test-sqlite (pull_request) Successful in 2m47s
CI/CD / build-and-deploy (pull_request) Has been skipped
Feat v0.3.0 workflow schema redesign + stage CRUD modernization
Drop chat-era columns (persona_id, history_mode) from workflow_stages.
Rename transition_rules → stage_config. Add audience, stage_type,
starlark_hook, branch_rules columns. Update stage_mode CHECK to
(form, review, delegated, automated). All Go stores, handlers,
routing engine, Starlark module, and frontend editors updated.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 17:48:14 +00:00

417 lines
13 KiB
Go

package sqlite
import (
"context"
"database/sql"
"encoding/json"
"time"
"switchboard-core/models"
"switchboard-core/store"
)
// WorkflowStore implements store.WorkflowStore for SQLite.
type WorkflowStore struct{}
func NewWorkflowStore() *WorkflowStore { return &WorkflowStore{} }
// ── Workflow CRUD ───────────────────────────
func (s *WorkflowStore) Create(ctx context.Context, w *models.Workflow) error {
w.ID = store.NewID()
now := time.Now().UTC()
w.CreatedAt = now
w.UpdatedAt = now
w.Version = 1
branding := jsonOrEmpty(w.Branding)
retention := jsonOrDefault(w.Retention, `{"mode":"archive"}`)
_, err := DB.ExecContext(ctx, `
INSERT INTO workflows (id, team_id, name, slug, description, branding, entry_mode,
is_active, version, on_complete, retention, webhook_url, webhook_secret,
created_by, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
w.ID, w.TeamID, w.Name, w.Slug, w.Description, branding, w.EntryMode,
boolToInt(w.IsActive), w.Version, jsonOrNull(w.OnComplete), retention,
nullIfEmpty(w.WebhookURL), nullIfEmpty(w.WebhookSecret),
w.CreatedBy, w.CreatedAt.Format(time.RFC3339), w.UpdatedAt.Format(time.RFC3339))
return err
}
func (s *WorkflowStore) GetByID(ctx context.Context, id string) (*models.Workflow, error) {
return s.scanOne(ctx, `
SELECT id, team_id, name, slug, description, branding, entry_mode, is_active,
version, on_complete, retention, webhook_url, webhook_secret,
created_by, created_at, updated_at
FROM workflows WHERE id = ?`, id)
}
func (s *WorkflowStore) GetBySlug(ctx context.Context, teamID *string, slug string) (*models.Workflow, error) {
if teamID != nil {
return s.scanOne(ctx, `
SELECT id, team_id, name, slug, description, branding, entry_mode, is_active,
version, on_complete, retention, webhook_url, webhook_secret,
created_by, created_at, updated_at
FROM workflows WHERE team_id = ? AND slug = ?`, *teamID, slug)
}
return s.scanOne(ctx, `
SELECT id, team_id, name, slug, description, branding, entry_mode, is_active,
version, on_complete, retention, webhook_url, webhook_secret,
created_by, created_at, updated_at
FROM workflows WHERE team_id IS NULL AND slug = ?`, slug)
}
func (s *WorkflowStore) Update(ctx context.Context, id string, patch models.WorkflowPatch) error {
sets := []string{}
args := []interface{}{}
if patch.Name != nil {
sets = append(sets, "name = ?")
args = append(args, *patch.Name)
}
if patch.Description != nil {
sets = append(sets, "description = ?")
args = append(args, *patch.Description)
}
if patch.Branding != nil {
sets = append(sets, "branding = ?")
args = append(args, string(*patch.Branding))
}
if patch.EntryMode != nil {
sets = append(sets, "entry_mode = ?")
args = append(args, *patch.EntryMode)
}
if patch.IsActive != nil {
sets = append(sets, "is_active = ?")
args = append(args, boolToInt(*patch.IsActive))
}
if patch.OnComplete != nil {
sets = append(sets, "on_complete = ?")
args = append(args, string(*patch.OnComplete))
}
if patch.Retention != nil {
sets = append(sets, "retention = ?")
args = append(args, string(*patch.Retention))
}
if patch.WebhookURL != nil {
sets = append(sets, "webhook_url = ?")
args = append(args, *patch.WebhookURL)
}
if patch.WebhookSecret != nil {
sets = append(sets, "webhook_secret = ?")
args = append(args, *patch.WebhookSecret)
}
if len(sets) == 0 {
return nil
}
sets = append(sets, "version = version + 1")
sets = append(sets, "updated_at = ?")
args = append(args, time.Now().UTC().Format(time.RFC3339))
q := "UPDATE workflows SET "
for i, s := range sets {
if i > 0 {
q += ", "
}
q += s
}
q += " WHERE id = ?"
args = append(args, id)
_, err := DB.ExecContext(ctx, q, args...)
return err
}
func (s *WorkflowStore) Delete(ctx context.Context, id string) error {
_, err := DB.ExecContext(ctx, `DELETE FROM workflows WHERE id = ?`, id)
return err
}
func (s *WorkflowStore) ListForTeam(ctx context.Context, teamID string) ([]models.Workflow, error) {
return s.queryWorkflows(ctx, `
SELECT id, team_id, name, slug, description, branding, entry_mode, is_active,
version, on_complete, retention, webhook_url, webhook_secret,
created_by, created_at, updated_at
FROM workflows WHERE team_id = ?
ORDER BY name ASC`, teamID)
}
func (s *WorkflowStore) ListGlobal(ctx context.Context) ([]models.Workflow, error) {
return s.queryWorkflows(ctx, `
SELECT id, team_id, name, slug, description, branding, entry_mode, is_active,
version, on_complete, retention, webhook_url, webhook_secret,
created_by, created_at, updated_at
FROM workflows WHERE team_id IS NULL
ORDER BY name ASC`)
}
// ── Stages ──────────────────────────────────
func (s *WorkflowStore) CreateStage(ctx context.Context, st *models.WorkflowStage) error {
st.ID = store.NewID()
st.CreatedAt = time.Now().UTC()
formTpl := jsonOrEmpty(st.FormTemplate)
stageConfig := jsonOrEmpty(st.StageConfig)
branchRules := jsonOrEmpty(st.BranchRules)
stageMode := st.StageMode
if stageMode == "" {
stageMode = models.StageModeForm
}
audience := st.Audience
if audience == "" {
audience = models.AudienceTeam
}
stageType := st.StageType
if stageType == "" {
stageType = models.StageTypeSimple
}
_, err := DB.ExecContext(ctx, `
INSERT INTO workflow_stages (id, workflow_id, ordinal, name, assignment_team_id,
form_template, stage_mode, audience, stage_type,
auto_transition, stage_config, branch_rules, starlark_hook,
surface_pkg_id, sla_seconds, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
st.ID, st.WorkflowID, st.Ordinal, st.Name, st.AssignmentTeamID,
formTpl, stageMode, audience, stageType,
boolToInt(st.AutoTransition), stageConfig, branchRules, st.StarlarkHook,
st.SurfacePkgID, st.SLASeconds, st.CreatedAt.Format(time.RFC3339))
return err
}
func (s *WorkflowStore) ListStages(ctx context.Context, workflowID string) ([]models.WorkflowStage, error) {
rows, err := DB.QueryContext(ctx, `
SELECT id, workflow_id, ordinal, name, assignment_team_id,
form_template, stage_mode, audience, stage_type,
auto_transition, stage_config, branch_rules, starlark_hook,
surface_pkg_id, sla_seconds, created_at
FROM workflow_stages WHERE workflow_id = ?
ORDER BY ordinal ASC`, workflowID)
if err != nil {
return nil, err
}
defer rows.Close()
var result []models.WorkflowStage
for rows.Next() {
var stg models.WorkflowStage
var formTpl, stageConfig, branchRules string
var autoTrans int
if err := rows.Scan(&stg.ID, &stg.WorkflowID, &stg.Ordinal, &stg.Name,
&stg.AssignmentTeamID, &formTpl, &stg.StageMode, &stg.Audience, &stg.StageType,
&autoTrans, &stageConfig, &branchRules, &stg.StarlarkHook,
&stg.SurfacePkgID, &stg.SLASeconds, st(&stg.CreatedAt)); err != nil {
return nil, err
}
stg.FormTemplate = json.RawMessage(formTpl)
stg.StageConfig = json.RawMessage(stageConfig)
stg.BranchRules = json.RawMessage(branchRules)
stg.AutoTransition = autoTrans != 0
result = append(result, stg)
}
return result, rows.Err()
}
func (s *WorkflowStore) UpdateStage(ctx context.Context, st *models.WorkflowStage) error {
formTpl := jsonOrEmpty(st.FormTemplate)
stageConfig := jsonOrEmpty(st.StageConfig)
branchRules := jsonOrEmpty(st.BranchRules)
stageMode := st.StageMode
if stageMode == "" {
stageMode = models.StageModeForm
}
audience := st.Audience
if audience == "" {
audience = models.AudienceTeam
}
stageType := st.StageType
if stageType == "" {
stageType = models.StageTypeSimple
}
_, err := DB.ExecContext(ctx, `
UPDATE workflow_stages
SET ordinal = ?, name = ?, assignment_team_id = ?,
form_template = ?, stage_mode = ?, audience = ?, stage_type = ?,
auto_transition = ?, stage_config = ?, branch_rules = ?, starlark_hook = ?,
surface_pkg_id = ?, sla_seconds = ?
WHERE id = ?`,
st.Ordinal, st.Name, st.AssignmentTeamID,
formTpl, stageMode, audience, stageType,
boolToInt(st.AutoTransition), stageConfig, branchRules, st.StarlarkHook,
st.SurfacePkgID, st.SLASeconds, st.ID)
return err
}
func (s *WorkflowStore) DeleteStage(ctx context.Context, id string) error {
_, err := DB.ExecContext(ctx, `DELETE FROM workflow_stages WHERE id = ?`, id)
return err
}
func (s *WorkflowStore) ReorderStages(ctx context.Context, workflowID string, orderedIDs []string) error {
tx, err := DB.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
for i, id := range orderedIDs {
if _, err := tx.ExecContext(ctx, `
UPDATE workflow_stages SET ordinal = ? WHERE id = ? AND workflow_id = ?`,
i, id, workflowID); err != nil {
return err
}
}
return tx.Commit()
}
// ── Versions ────────────────────────────────
func (s *WorkflowStore) Publish(ctx context.Context, v *models.WorkflowVersion) error {
v.ID = store.NewID()
v.CreatedAt = time.Now().UTC()
_, err := DB.ExecContext(ctx, `
INSERT INTO workflow_versions (id, workflow_id, version_number, snapshot, created_at)
VALUES (?, ?, ?, ?, ?)`,
v.ID, v.WorkflowID, v.VersionNumber, string(v.Snapshot),
v.CreatedAt.Format(time.RFC3339))
return err
}
func (s *WorkflowStore) GetVersion(ctx context.Context, workflowID string, versionNumber int) (*models.WorkflowVersion, error) {
v := &models.WorkflowVersion{}
var snapshot string
err := DB.QueryRowContext(ctx, `
SELECT id, workflow_id, version_number, snapshot, created_at
FROM workflow_versions WHERE workflow_id = ? AND version_number = ?`,
workflowID, versionNumber,
).Scan(&v.ID, &v.WorkflowID, &v.VersionNumber, &snapshot, st(&v.CreatedAt))
if err != nil {
return nil, err
}
v.Snapshot = json.RawMessage(snapshot)
return v, nil
}
func (s *WorkflowStore) GetLatestVersion(ctx context.Context, workflowID string) (*models.WorkflowVersion, error) {
v := &models.WorkflowVersion{}
var snapshot string
err := DB.QueryRowContext(ctx, `
SELECT id, workflow_id, version_number, snapshot, created_at
FROM workflow_versions WHERE workflow_id = ?
ORDER BY version_number DESC LIMIT 1`,
workflowID,
).Scan(&v.ID, &v.WorkflowID, &v.VersionNumber, &snapshot, st(&v.CreatedAt))
if err != nil {
return nil, err
}
v.Snapshot = json.RawMessage(snapshot)
return v, nil
}
// ── Helpers ─────────────────────────────────
func (s *WorkflowStore) scanOne(ctx context.Context, q string, args ...interface{}) (*models.Workflow, error) {
w := &models.Workflow{}
var branding, retention string
var onComplete sql.NullString
var webhookURL, webhookSecret sql.NullString
var isActive int
err := DB.QueryRowContext(ctx, q, args...).Scan(
&w.ID, &w.TeamID, &w.Name, &w.Slug, &w.Description, &branding,
&w.EntryMode, &isActive, &w.Version, &onComplete, &retention,
&webhookURL, &webhookSecret,
&w.CreatedBy, st(&w.CreatedAt), st(&w.UpdatedAt))
if err != nil {
return nil, err
}
w.Branding = json.RawMessage(branding)
w.Retention = json.RawMessage(retention)
w.IsActive = isActive != 0
if onComplete.Valid {
w.OnComplete = json.RawMessage(onComplete.String)
}
if webhookURL.Valid {
w.WebhookURL = webhookURL.String
}
if webhookSecret.Valid {
w.WebhookSecret = webhookSecret.String
}
return w, nil
}
func (s *WorkflowStore) queryWorkflows(ctx context.Context, q string, args ...interface{}) ([]models.Workflow, error) {
rows, err := DB.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var result []models.Workflow
for rows.Next() {
var w models.Workflow
var branding, retention string
var onComplete sql.NullString
var webhookURL, webhookSecret sql.NullString
var isActive int
if err := rows.Scan(&w.ID, &w.TeamID, &w.Name, &w.Slug, &w.Description,
&branding, &w.EntryMode, &isActive, &w.Version, &onComplete,
&retention, &webhookURL, &webhookSecret,
&w.CreatedBy, st(&w.CreatedAt), st(&w.UpdatedAt)); err != nil {
return nil, err
}
w.Branding = json.RawMessage(branding)
w.Retention = json.RawMessage(retention)
w.IsActive = isActive != 0
if onComplete.Valid {
w.OnComplete = json.RawMessage(onComplete.String)
}
if webhookURL.Valid {
w.WebhookURL = webhookURL.String
}
if webhookSecret.Valid {
w.WebhookSecret = webhookSecret.String
}
result = append(result, w)
}
return result, rows.Err()
}
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}
func jsonOrEmpty(b json.RawMessage) string {
if len(b) == 0 {
return "{}"
}
return string(b)
}
func jsonOrDefault(b json.RawMessage, def string) string {
if len(b) == 0 {
return def
}
return string(b)
}
func jsonOrNull(b json.RawMessage) interface{} {
if len(b) == 0 || string(b) == "null" {
return nil
}
return string(b)
}
func nullIfEmpty(s string) interface{} {
if s == "" {
return nil
}
return s
}
// ── Assignments (v0.29.0-cs3) ───────────────────────────────────────────
// ── Lifecycle operations (v0.37.15) ──
// ── Review Comments (v0.35.0) ───────────────────────────