Files
kandev/apps/backend/internal/workflow/repository/sqlite.go
T

876 lines
28 KiB
Go

// Package repository provides SQLite-based repository implementations for workflow entities.
package repository
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
"github.com/kandev/kandev/internal/common/sqlite"
"github.com/kandev/kandev/internal/workflow/models"
)
// Repository provides SQLite-based workflow storage operations.
type Repository struct {
db *sql.DB
}
// NewWithDB creates a new SQLite repository with an existing database connection.
func NewWithDB(db *sql.DB) (*Repository, error) {
repo := &Repository{db: db}
if err := repo.initSchema(); err != nil {
return nil, fmt.Errorf("failed to initialize workflow schema: %w", err)
}
return repo, nil
}
// DB returns the underlying sql.DB instance for shared access.
func (r *Repository) DB() *sql.DB {
return r.db
}
// ensureColumn adds a column to a table if it doesn't exist.
func (r *Repository) ensureColumn(table, column, definition string) error {
return sqlite.EnsureColumn(r.db, table, column, definition)
}
// initSchema creates the database tables if they don't exist.
func (r *Repository) initSchema() error {
// Create workflow_templates table
templatesSchema := `
CREATE TABLE IF NOT EXISTS workflow_templates (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
is_system INTEGER DEFAULT 0,
steps TEXT NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
`
if _, err := r.db.Exec(templatesSchema); err != nil {
return fmt.Errorf("failed to create workflow_templates table: %w", err)
}
// Create workflow_steps table
stepsSchema := `
CREATE TABLE IF NOT EXISTS workflow_steps (
id TEXT PRIMARY KEY,
board_id TEXT NOT NULL,
name TEXT NOT NULL,
step_type TEXT NOT NULL,
position INTEGER NOT NULL,
color TEXT,
auto_start_agent INTEGER DEFAULT 0,
plan_mode INTEGER DEFAULT 0,
require_approval INTEGER DEFAULT 0,
prompt_prefix TEXT,
prompt_suffix TEXT,
on_complete_step_id TEXT,
on_approval_step_id TEXT,
allow_manual_move INTEGER DEFAULT 1,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
FOREIGN KEY (board_id) REFERENCES boards(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_workflow_steps_board ON workflow_steps(board_id);
`
if _, err := r.db.Exec(stepsSchema); err != nil {
return fmt.Errorf("failed to create workflow_steps table: %w", err)
}
// Create session_step_history table
historySchema := `
CREATE TABLE IF NOT EXISTS session_step_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
from_step_id TEXT,
to_step_id TEXT NOT NULL,
trigger TEXT NOT NULL,
actor_id TEXT,
metadata TEXT,
created_at DATETIME NOT NULL,
FOREIGN KEY (session_id) REFERENCES task_sessions(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_session_step_history_session ON session_step_history(session_id);
`
if _, err := r.db.Exec(historySchema); err != nil {
return fmt.Errorf("failed to create session_step_history table: %w", err)
}
// Run migrations for existing tables
if err := r.runMigrations(); err != nil {
return fmt.Errorf("failed to run migrations: %w", err)
}
// Seed system templates
if err := r.seedSystemTemplates(); err != nil {
return fmt.Errorf("failed to seed system templates: %w", err)
}
// Seed default workflow steps for boards that don't have any
if err := r.seedDefaultWorkflowSteps(); err != nil {
return fmt.Errorf("failed to seed default workflow steps: %w", err)
}
return nil
}
// runMigrations adds columns to existing tables.
func (r *Repository) runMigrations() error {
// Add workflow_template_id to boards table
if err := r.ensureColumn("boards", "workflow_template_id", "TEXT"); err != nil {
return fmt.Errorf("failed to add workflow_template_id to boards: %w", err)
}
// Add workflow-related columns to task_sessions table
if err := r.ensureColumn("task_sessions", "is_primary", "INTEGER DEFAULT 0"); err != nil {
return fmt.Errorf("failed to add is_primary to task_sessions: %w", err)
}
if err := r.ensureColumn("task_sessions", "workflow_step_id", "TEXT"); err != nil {
return fmt.Errorf("failed to add workflow_step_id to task_sessions: %w", err)
}
if err := r.ensureColumn("task_sessions", "review_status", "TEXT"); err != nil {
return fmt.Errorf("failed to add review_status to task_sessions: %w", err)
}
return nil
}
// seedDefaultWorkflowSteps creates default workflow steps for boards that don't have any.
func (r *Repository) seedDefaultWorkflowSteps() error {
// Find boards without workflow steps
rows, err := r.db.Query(`
SELECT b.id FROM boards b
LEFT JOIN workflow_steps ws ON ws.board_id = b.id
WHERE ws.id IS NULL
`)
if err != nil {
return err
}
defer func() { _ = rows.Close() }()
var boardIDs []string
for rows.Next() {
var boardID string
if err := rows.Scan(&boardID); err != nil {
return err
}
boardIDs = append(boardIDs, boardID)
}
if err := rows.Err(); err != nil {
return err
}
// Create default workflow steps for each board
now := time.Now()
type stepSeed struct {
name string
position int
stepType string
color string
}
defaultSteps := []stepSeed{
{name: "Todo", position: 0, stepType: "backlog", color: "bg-neutral-400"},
{name: "In Progress", position: 1, stepType: "implementation", color: "bg-blue-500"},
{name: "Review", position: 2, stepType: "review", color: "bg-yellow-500"},
{name: "Done", position: 3, stepType: "done", color: "bg-green-500"},
}
for _, boardID := range boardIDs {
for _, step := range defaultSteps {
if _, err := r.db.Exec(`
INSERT INTO workflow_steps (id, board_id, name, step_type, position, color, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`, uuid.New().String(), boardID, step.name, step.stepType, step.position, step.color, now, now); err != nil {
return err
}
}
}
return nil
}
// seedSystemTemplates inserts the default system workflow templates.
func (r *Repository) seedSystemTemplates() error {
templates := r.getSystemTemplates()
for _, tmpl := range templates {
// Check if template already exists
var exists bool
err := r.db.QueryRow("SELECT 1 FROM workflow_templates WHERE id = ?", tmpl.ID).Scan(&exists)
if err != nil && err != sql.ErrNoRows {
return err
}
if exists {
continue
}
stepsJSON, err := json.Marshal(tmpl.Steps)
if err != nil {
return fmt.Errorf("failed to marshal steps for template %s: %w", tmpl.ID, err)
}
_, err = r.db.Exec(`
INSERT INTO workflow_templates (id, name, description, is_system, steps, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`, tmpl.ID, tmpl.Name, tmpl.Description, sqlite.BoolToInt(tmpl.IsSystem), string(stepsJSON), tmpl.CreatedAt, tmpl.UpdatedAt)
if err != nil {
return fmt.Errorf("failed to insert template %s: %w", tmpl.ID, err)
}
}
return nil
}
// getSystemTemplates returns the predefined system workflow templates.
func (r *Repository) getSystemTemplates() []*models.WorkflowTemplate {
now := time.Now().UTC()
return []*models.WorkflowTemplate{
r.getDevTemplate(now),
r.getArchitectureTemplate(now),
r.getSimpleTemplate(now),
}
}
// getDevTemplate returns the full development workflow template.
func (r *Repository) getDevTemplate(now time.Time) *models.WorkflowTemplate {
return &models.WorkflowTemplate{
ID: "dev",
Name: "Development",
Description: "Standard software development workflow with planning phase",
IsSystem: true,
CreatedAt: now,
UpdatedAt: now,
Steps: []models.StepDefinition{
{
ID: "backlog",
Name: "Backlog",
StepType: models.StepTypeBacklog,
Position: 0,
Color: "bg-neutral-400",
TaskState: "TODO",
AutoStartAgent: false,
AllowManualMove: true,
},
{
ID: "planning",
Name: "Planning",
StepType: models.StepTypePlanning,
Position: 1,
Color: "bg-purple-500",
TaskState: "IN_PROGRESS",
AutoStartAgent: true,
PlanMode: true,
PromptPrefix: "[PLANNING PHASE]\nAnalyze this task and create a detailed implementation plan.\nDo NOT make any code changes yet - only analyze and plan.\n\nCreate a plan that includes:\n1. Understanding of the requirements\n2. Files that need to be modified or created\n3. Step-by-step implementation approach\n4. Potential risks or considerations\n\nIMPORTANT: Save your plan to ONLY this file: `.kandev/plans/{task_id}.md`\nDo not create any other files during this phase - only the plan file above.",
OnCompleteStepID: "review-plan",
AllowManualMove: true,
},
{
ID: "review-plan",
Name: "Review Plan",
StepType: models.StepTypeReview,
Position: 2,
Color: "bg-yellow-500",
TaskState: "REVIEW",
AutoStartAgent: false,
RequireApproval: true,
OnApprovalStepID: "implementation",
AllowManualMove: true,
},
{
ID: "implementation",
Name: "Implementation",
StepType: models.StepTypeImplementation,
Position: 3,
Color: "bg-blue-500",
TaskState: "IN_PROGRESS",
AutoStartAgent: true,
PromptPrefix: "[IMPLEMENTATION PHASE]\nRead the plan from `.kandev/plans/{task_id}.md` and implement the task according to the plan.\nFollow the plan step by step.",
OnCompleteStepID: "review-code",
AllowManualMove: true,
},
{
ID: "review-code",
Name: "Code Review",
StepType: models.StepTypeReview,
Position: 4,
Color: "bg-orange-500",
TaskState: "REVIEW",
AutoStartAgent: false,
RequireApproval: true,
OnApprovalStepID: "done",
AllowManualMove: true,
},
{
ID: "done",
Name: "Done",
StepType: models.StepTypeDone,
Position: 5,
Color: "bg-green-500",
TaskState: "COMPLETED",
AutoStartAgent: false,
AllowManualMove: true,
},
},
}
}
// getArchitectureTemplate returns the architecture workflow template.
func (r *Repository) getArchitectureTemplate(now time.Time) *models.WorkflowTemplate {
return &models.WorkflowTemplate{
ID: "architecture",
Name: "Architecture",
Description: "Architecture focused: Backlog → Planning → Review → Done",
IsSystem: true,
CreatedAt: now,
UpdatedAt: now,
Steps: []models.StepDefinition{
{
ID: "backlog",
Name: "Ideas",
StepType: models.StepTypeBacklog,
Position: 0,
Color: "bg-neutral-400",
TaskState: "TODO",
AutoStartAgent: false,
AllowManualMove: true,
},
{
ID: "planning",
Name: "Planning",
StepType: models.StepTypePlanning,
Position: 1,
Color: "bg-purple-500",
TaskState: "IN_PROGRESS",
AutoStartAgent: true,
PlanMode: true,
PromptPrefix: "[ARCHITECTURE PHASE]\nAnalyze and design the architecture for this task.\n\nIMPORTANT: Document your design in ONLY this file: `.kandev/designs/{task_id}.md`\nDo not create any other files during this phase - only the design file above.",
OnCompleteStepID: "review",
AllowManualMove: true,
},
{
ID: "review",
Name: "Review",
StepType: models.StepTypeReview,
Position: 2,
Color: "bg-yellow-500",
TaskState: "REVIEW",
AutoStartAgent: false,
RequireApproval: true,
OnApprovalStepID: "done",
AllowManualMove: true,
},
{
ID: "done",
Name: "Approved",
StepType: models.StepTypeDone,
Position: 3,
Color: "bg-green-500",
TaskState: "COMPLETED",
AutoStartAgent: false,
AllowManualMove: true,
},
},
}
}
// getSimpleTemplate returns the simple workflow template.
func (r *Repository) getSimpleTemplate(now time.Time) *models.WorkflowTemplate {
return &models.WorkflowTemplate{
ID: "simple",
Name: "Simple",
Description: "Minimal workflow: Backlog → In Progress → Done",
IsSystem: true,
CreatedAt: now,
UpdatedAt: now,
Steps: []models.StepDefinition{
{
ID: "backlog",
Name: "Backlog",
StepType: models.StepTypeBacklog,
Position: 0,
Color: "bg-neutral-400",
TaskState: "TODO",
AutoStartAgent: false,
AllowManualMove: true,
},
{
ID: "in-progress",
Name: "In Progress",
StepType: models.StepTypeImplementation,
Position: 1,
Color: "bg-blue-500",
TaskState: "IN_PROGRESS",
AutoStartAgent: true,
OnCompleteStepID: "done",
AllowManualMove: true,
},
{
ID: "done",
Name: "Done",
StepType: models.StepTypeDone,
Position: 2,
Color: "bg-green-500",
TaskState: "COMPLETED",
AutoStartAgent: false,
AllowManualMove: true,
},
},
}
}
// ============================================================================
// WorkflowTemplate CRUD Operations
// ============================================================================
// CreateTemplate creates a new workflow template.
func (r *Repository) CreateTemplate(ctx context.Context, template *models.WorkflowTemplate) error {
if template.ID == "" {
template.ID = uuid.New().String()
}
now := time.Now().UTC()
template.CreatedAt = now
template.UpdatedAt = now
stepsJSON, err := json.Marshal(template.Steps)
if err != nil {
return fmt.Errorf("failed to marshal steps: %w", err)
}
_, err = r.db.ExecContext(ctx, `
INSERT INTO workflow_templates (id, name, description, is_system, steps, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`, template.ID, template.Name, template.Description, sqlite.BoolToInt(template.IsSystem), string(stepsJSON), template.CreatedAt, template.UpdatedAt)
return err
}
// GetTemplate retrieves a workflow template by ID.
func (r *Repository) GetTemplate(ctx context.Context, id string) (*models.WorkflowTemplate, error) {
template := &models.WorkflowTemplate{}
var stepsJSON string
var isSystem int
err := r.db.QueryRowContext(ctx, `
SELECT id, name, description, is_system, steps, created_at, updated_at
FROM workflow_templates WHERE id = ?
`, id).Scan(&template.ID, &template.Name, &template.Description, &isSystem, &stepsJSON, &template.CreatedAt, &template.UpdatedAt)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("workflow template not found: %s", id)
}
if err != nil {
return nil, err
}
template.IsSystem = isSystem == 1
if err := json.Unmarshal([]byte(stepsJSON), &template.Steps); err != nil {
return nil, fmt.Errorf("failed to unmarshal steps: %w", err)
}
return template, nil
}
// UpdateTemplate updates an existing workflow template.
func (r *Repository) UpdateTemplate(ctx context.Context, template *models.WorkflowTemplate) error {
template.UpdatedAt = time.Now().UTC()
stepsJSON, err := json.Marshal(template.Steps)
if err != nil {
return fmt.Errorf("failed to marshal steps: %w", err)
}
result, err := r.db.ExecContext(ctx, `
UPDATE workflow_templates SET name = ?, description = ?, steps = ?, updated_at = ?
WHERE id = ? AND is_system = 0
`, template.Name, template.Description, string(stepsJSON), template.UpdatedAt, template.ID)
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("workflow template not found or is a system template: %s", template.ID)
}
return nil
}
// DeleteTemplate deletes a workflow template by ID.
func (r *Repository) DeleteTemplate(ctx context.Context, id string) error {
result, err := r.db.ExecContext(ctx, `DELETE FROM workflow_templates WHERE id = ? AND is_system = 0`, id)
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("workflow template not found or is a system template: %s", id)
}
return nil
}
// ListTemplates returns all workflow templates.
func (r *Repository) ListTemplates(ctx context.Context) ([]*models.WorkflowTemplate, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, name, description, is_system, steps, created_at, updated_at
FROM workflow_templates ORDER BY is_system DESC, name ASC
`)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
var result []*models.WorkflowTemplate
for rows.Next() {
template := &models.WorkflowTemplate{}
var stepsJSON string
var isSystem int
err := rows.Scan(&template.ID, &template.Name, &template.Description, &isSystem, &stepsJSON, &template.CreatedAt, &template.UpdatedAt)
if err != nil {
return nil, err
}
template.IsSystem = isSystem == 1
if err := json.Unmarshal([]byte(stepsJSON), &template.Steps); err != nil {
return nil, fmt.Errorf("failed to unmarshal steps for template %s: %w", template.ID, err)
}
result = append(result, template)
}
return result, rows.Err()
}
// GetSystemTemplates returns only system workflow templates.
func (r *Repository) GetSystemTemplates(ctx context.Context) ([]*models.WorkflowTemplate, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, name, description, is_system, steps, created_at, updated_at
FROM workflow_templates WHERE is_system = 1 ORDER BY name ASC
`)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
var result []*models.WorkflowTemplate
for rows.Next() {
template := &models.WorkflowTemplate{}
var stepsJSON string
var isSystem int
err := rows.Scan(&template.ID, &template.Name, &template.Description, &isSystem, &stepsJSON, &template.CreatedAt, &template.UpdatedAt)
if err != nil {
return nil, err
}
template.IsSystem = isSystem == 1
if err := json.Unmarshal([]byte(stepsJSON), &template.Steps); err != nil {
return nil, fmt.Errorf("failed to unmarshal steps for template %s: %w", template.ID, err)
}
result = append(result, template)
}
return result, rows.Err()
}
// ============================================================================
// WorkflowStep CRUD Operations
// ============================================================================
// CreateStep creates a new workflow step.
func (r *Repository) CreateStep(ctx context.Context, step *models.WorkflowStep) error {
if step.ID == "" {
step.ID = uuid.New().String()
}
now := time.Now().UTC()
step.CreatedAt = now
step.UpdatedAt = now
_, err := r.db.ExecContext(ctx, `
INSERT INTO workflow_steps (
id, board_id, name, step_type, position, color,
auto_start_agent, plan_mode, require_approval,
prompt_prefix, prompt_suffix, on_complete_step_id, on_approval_step_id,
allow_manual_move, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, step.ID, step.BoardID, step.Name, step.StepType, step.Position, step.Color,
sqlite.BoolToInt(step.AutoStartAgent), sqlite.BoolToInt(step.PlanMode), sqlite.BoolToInt(step.RequireApproval),
step.PromptPrefix, step.PromptSuffix, step.OnCompleteStepID, step.OnApprovalStepID,
sqlite.BoolToInt(step.AllowManualMove), step.CreatedAt, step.UpdatedAt)
return err
}
// GetStep retrieves a workflow step by ID.
func (r *Repository) GetStep(ctx context.Context, id string) (*models.WorkflowStep, error) {
step := &models.WorkflowStep{}
var autoStartAgent, planMode, requireApproval, allowManualMove int
var onCompleteStepID, onApprovalStepID, promptPrefix, promptSuffix, color sql.NullString
err := r.db.QueryRowContext(ctx, `
SELECT id, board_id, name, step_type, position, color,
auto_start_agent, plan_mode, require_approval,
prompt_prefix, prompt_suffix, on_complete_step_id, on_approval_step_id,
allow_manual_move, created_at, updated_at
FROM workflow_steps WHERE id = ?
`, id).Scan(&step.ID, &step.BoardID, &step.Name, &step.StepType, &step.Position, &color,
&autoStartAgent, &planMode, &requireApproval,
&promptPrefix, &promptSuffix, &onCompleteStepID, &onApprovalStepID,
&allowManualMove, &step.CreatedAt, &step.UpdatedAt)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("workflow step not found: %s", id)
}
if err != nil {
return nil, err
}
step.AutoStartAgent = autoStartAgent == 1
step.PlanMode = planMode == 1
step.RequireApproval = requireApproval == 1
step.AllowManualMove = allowManualMove == 1
if color.Valid {
step.Color = color.String
}
if promptPrefix.Valid {
step.PromptPrefix = promptPrefix.String
}
if promptSuffix.Valid {
step.PromptSuffix = promptSuffix.String
}
if onCompleteStepID.Valid {
step.OnCompleteStepID = &onCompleteStepID.String
}
if onApprovalStepID.Valid {
step.OnApprovalStepID = &onApprovalStepID.String
}
return step, nil
}
// UpdateStep updates an existing workflow step.
func (r *Repository) UpdateStep(ctx context.Context, step *models.WorkflowStep) error {
step.UpdatedAt = time.Now().UTC()
result, err := r.db.ExecContext(ctx, `
UPDATE workflow_steps SET
name = ?, step_type = ?, position = ?, color = ?,
auto_start_agent = ?, plan_mode = ?, require_approval = ?,
prompt_prefix = ?, prompt_suffix = ?, on_complete_step_id = ?, on_approval_step_id = ?,
allow_manual_move = ?, updated_at = ?
WHERE id = ?
`, step.Name, step.StepType, step.Position, step.Color,
sqlite.BoolToInt(step.AutoStartAgent), sqlite.BoolToInt(step.PlanMode), sqlite.BoolToInt(step.RequireApproval),
step.PromptPrefix, step.PromptSuffix, step.OnCompleteStepID, step.OnApprovalStepID,
sqlite.BoolToInt(step.AllowManualMove), step.UpdatedAt, step.ID)
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("workflow step not found: %s", step.ID)
}
return nil
}
// DeleteStep deletes a workflow step by ID.
func (r *Repository) DeleteStep(ctx context.Context, id string) error {
result, err := r.db.ExecContext(ctx, `DELETE FROM workflow_steps WHERE id = ?`, id)
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("workflow step not found: %s", id)
}
return nil
}
// ListStepsByBoard returns all workflow steps for a board.
func (r *Repository) ListStepsByBoard(ctx context.Context, boardID string) ([]*models.WorkflowStep, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, board_id, name, step_type, position, color,
auto_start_agent, plan_mode, require_approval,
prompt_prefix, prompt_suffix, on_complete_step_id, on_approval_step_id,
allow_manual_move, created_at, updated_at
FROM workflow_steps WHERE board_id = ? ORDER BY position
`, boardID)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
return r.scanSteps(rows)
}
// GetStepByBoardAndType retrieves a workflow step by board ID and step type.
func (r *Repository) GetStepByBoardAndType(ctx context.Context, boardID string, stepType models.StepType) (*models.WorkflowStep, error) {
step := &models.WorkflowStep{}
var autoStartAgent, planMode, requireApproval, allowManualMove int
var onCompleteStepID, onApprovalStepID, promptPrefix, promptSuffix, color sql.NullString
err := r.db.QueryRowContext(ctx, `
SELECT id, board_id, name, step_type, position, color,
auto_start_agent, plan_mode, require_approval,
prompt_prefix, prompt_suffix, on_complete_step_id, on_approval_step_id,
allow_manual_move, created_at, updated_at
FROM workflow_steps WHERE board_id = ? AND step_type = ?
`, boardID, stepType).Scan(&step.ID, &step.BoardID, &step.Name, &step.StepType, &step.Position, &color,
&autoStartAgent, &planMode, &requireApproval,
&promptPrefix, &promptSuffix, &onCompleteStepID, &onApprovalStepID,
&allowManualMove, &step.CreatedAt, &step.UpdatedAt)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("workflow step not found for board %s with type %s", boardID, stepType)
}
if err != nil {
return nil, err
}
step.AutoStartAgent = autoStartAgent == 1
step.PlanMode = planMode == 1
step.RequireApproval = requireApproval == 1
step.AllowManualMove = allowManualMove == 1
if color.Valid {
step.Color = color.String
}
if promptPrefix.Valid {
step.PromptPrefix = promptPrefix.String
}
if promptSuffix.Valid {
step.PromptSuffix = promptSuffix.String
}
if onCompleteStepID.Valid {
step.OnCompleteStepID = &onCompleteStepID.String
}
if onApprovalStepID.Valid {
step.OnApprovalStepID = &onApprovalStepID.String
}
return step, nil
}
// scanSteps is a helper to scan multiple workflow step rows.
func (r *Repository) scanSteps(rows *sql.Rows) ([]*models.WorkflowStep, error) {
var result []*models.WorkflowStep
for rows.Next() {
step := &models.WorkflowStep{}
var autoStartAgent, planMode, requireApproval, allowManualMove int
var onCompleteStepID, onApprovalStepID, promptPrefix, promptSuffix, color sql.NullString
err := rows.Scan(&step.ID, &step.BoardID, &step.Name, &step.StepType, &step.Position, &color,
&autoStartAgent, &planMode, &requireApproval,
&promptPrefix, &promptSuffix, &onCompleteStepID, &onApprovalStepID,
&allowManualMove, &step.CreatedAt, &step.UpdatedAt)
if err != nil {
return nil, err
}
step.AutoStartAgent = autoStartAgent == 1
step.PlanMode = planMode == 1
step.RequireApproval = requireApproval == 1
step.AllowManualMove = allowManualMove == 1
if color.Valid {
step.Color = color.String
}
if promptPrefix.Valid {
step.PromptPrefix = promptPrefix.String
}
if promptSuffix.Valid {
step.PromptSuffix = promptSuffix.String
}
if onCompleteStepID.Valid {
step.OnCompleteStepID = &onCompleteStepID.String
}
if onApprovalStepID.Valid {
step.OnApprovalStepID = &onApprovalStepID.String
}
result = append(result, step)
}
return result, rows.Err()
}
// ============================================================================
// SessionStepHistory Operations
// ============================================================================
// CreateHistory creates a new session step history entry.
func (r *Repository) CreateHistory(ctx context.Context, history *models.SessionStepHistory) error {
now := time.Now().UTC()
history.CreatedAt = now
var metadataJSON *string
if history.Metadata != nil {
data, err := json.Marshal(history.Metadata)
if err != nil {
return fmt.Errorf("failed to marshal metadata: %w", err)
}
s := string(data)
metadataJSON = &s
}
result, err := r.db.ExecContext(ctx, `
INSERT INTO session_step_history (session_id, from_step_id, to_step_id, trigger, actor_id, metadata, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`, history.SessionID, history.FromStepID, history.ToStepID, history.Trigger, history.ActorID, metadataJSON, history.CreatedAt)
if err != nil {
return err
}
// Get the auto-incremented ID
id, err := result.LastInsertId()
if err != nil {
return err
}
history.ID = id
return nil
}
// ListHistoryBySession returns all step history entries for a session.
func (r *Repository) ListHistoryBySession(ctx context.Context, sessionID string) ([]*models.SessionStepHistory, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, session_id, from_step_id, to_step_id, trigger, actor_id, metadata, created_at
FROM session_step_history WHERE session_id = ? ORDER BY created_at
`, sessionID)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
var result []*models.SessionStepHistory
for rows.Next() {
history := &models.SessionStepHistory{}
var fromStepID, actorID, metadataJSON sql.NullString
err := rows.Scan(&history.ID, &history.SessionID, &fromStepID, &history.ToStepID, &history.Trigger, &actorID, &metadataJSON, &history.CreatedAt)
if err != nil {
return nil, err
}
if fromStepID.Valid {
history.FromStepID = &fromStepID.String
}
if actorID.Valid {
history.ActorID = &actorID.String
}
if metadataJSON.Valid && metadataJSON.String != "" {
if err := json.Unmarshal([]byte(metadataJSON.String), &history.Metadata); err != nil {
return nil, fmt.Errorf("failed to unmarshal metadata: %w", err)
}
}
result = append(result, history)
}
return result, rows.Err()
}