Files
kandev/apps/backend/internal/workflow/repository/sqlite.go
T
João Salavisa 97c1f689e4 feat(workflow): explicit step-completion signal for auto-advance (#1276)
* feat(workflow): explicit step-completion signal for auto-advance

Adds `step_complete_kandev` MCP tool + `WorkflowStep.auto_advance_requires_signal`
column so workflow steps can gate their `on_turn_complete` transitions on an
agent-emitted completion signal instead of inferring "done" from any turn-end.
See docs/decisions/0015-explicit-completion-signal-for-auto-advance.md for the
motivation, alternatives considered, and trade-offs.

* fix(workflow): address PR review feedback for explicit completion signal

- Dedup `LoadPendingStepSignal` into `internal/task/models` so the MCP
  handler (write site) and the orchestrator (read site) share one decoder.
- Gate the out-of-band subscriber on `WorkflowStep.AutoAdvanceRequiresSignal`
  so `step_complete_kandev` is not an unintended manual-advance path for
  signalless steps.
- Fail closed on step-load errors in `processOnTurnCompleteViaEngine`: a
  loader hiccup must block the transition rather than silently bypass the
  gate.
- Reject `step_complete_kandev` for terminal sessions
  (Completed / Failed / Cancelled) so the agent gets a clear error instead
  of `accepted: true` followed by a no-op.
- Surface bus-publish failures back to the agent instead of swallowing
  them — the idempotency guard makes the retry safe and prevents the
  session from getting stuck.
- New `TestOnStepCompletionSignaled` covers the subscriber paths
  (RUNNING / WAITING+match / stale step / not signal-gated).
- ADR doc fixes: payload field list now matches the wire shape, writer
  ownership is documented, the unsubstantiated 250ms-debounce claim is
  removed, and the late-signal re-close race is recorded as a deferred
  open question.
- Drop the new toggle's HelpTip in favour of an inline description — the
  shadcn tooltip portal was intercepting clicks on the agent-profile
  dropdown options in the workflow settings E2E.

* fix(workflow): round-2 review fixes for explicit completion signal

- Split `handleStepComplete` into `resolveStepCompleteTarget` +
  `publishStepCompletionEvent` helpers so the entrypoint clears funlen
  (88 → under 80 lines) and the two halves are independently testable.
- Classify session/task load errors instead of returning `NotFound` for
  every failure: use the exported `taskrepo.ErrTaskNotFound` sentinel for
  task lookups, substring-match for the session repo (no sentinel today),
  and surface anything else as `InternalError` with the raw error logged
  but not leaked to the agent — so a transient DB hiccup triggers a retry
  rather than session abandonment.
- Pin `step_complete_kandev` explicitly in the mode-specific tool-count
  tests: `Contains` in `ModeTask`, `NotContains` in `ModeConfig` and
  `ModeOffice`, so accidental dereg or cross-mode registration is caught
  by the diff instead of by a still-equal count.

* fix(workflow): round-3 review fixes + defer pipeline-editor toggle

- Defer the "Wait for agent completion signal" sub-toggle to a follow-up
  PR. Adding it to the step config card shifts the layout enough that the
  agent profile select's Radix popper opens into a sibling tooltip's hover
  path, breaking workflow-agent-profile.spec.ts on shard 7. The backend
  column ships in this PR — workflows can opt in via the YAML import or
  the MCP `update_workflow_step_kandev` tool — and the UI affordance gets
  its own iteration where the test interference can be investigated
  without blocking the rest of the change.
- Re-publish the bus event on the `already_signaled` short-circuit when
  the session is WAITING_FOR_INPUT. Without this, an agent's retry after
  the first call's publish failed would short-circuit on the persisted
  bag, never fire the subscriber, and leave the session stuck.
- handleStepComplete docstring: drop the non-existent feature-flag claim
  and the "audit entry" wording — gating is purely
  `WorkflowStep.AutoAdvanceRequiresSignal` and the bag is cleared on the
  next turn start with no separate audit trail.
- `step_complete_kandev` tool description: same fix — clarify that the
  call succeeds for non-gated steps but the signal is discarded on the
  next turn start (no audit history to query later).

* test(workflow): cover handleStepComplete + reword defer-comment

- Add `TestHandleStepComplete_*` (6 subtests, all passing) covering the
  uncovered handler paths flagged by Claude/cubic on round 3:
  missing-fields validation, session-does-not-belong-to-task ownership
  guard, terminal-session rejection (Completed/Failed/Cancelled),
  first-call happy path (bag persisted + bus payload shape pinned), dedup
  while RUNNING (no re-publish), and dedup while WAITING_FOR_INPUT (the
  retry-after-publish-failure path — must re-publish so the subscriber
  fires).
- Reword the deferred-toggle comment to drop the YAML-export claim:
  the workflow export model doesn't yet serialize
  `auto_advance_requires_signal`, so MCP `update_workflow_step_kandev`
  is the only configuration path that round-trips today. Note the
  follow-up needs to extend the export schema alongside the UI toggle.

* test(workflow): pin signaled_at + drop redundant GetTaskSession on bag-clear

- Assert `signaled_at` is present and parses as RFC3339Nano in
  `TestHandleStepComplete_FirstCallAccepted`. The field is part of the
  handler's documented response contract; pinning it catches accidental
  drop/rename regressions.
- Add `clearPendingStepSignalByID(ctx, sessionID)` as a thin DB-only
  variant of `clearPendingStepSignal` and use it from
  `executeStepTransition`, where the session struct isn't read after the
  clear. Avoids an extra `GetTaskSession` round-trip per transition.
  `clearPendingStepSignal(session)` still exists for the
  in-memory-mutation case (subscriber's stale-step path).

* fix(workflow): subscriber stale-clear guard + ADR/comment drift fixes

- onStepCompletionSignaled's stale-step path now reads the bag and only
  clears it when its current StepID matches the (stale) event's stepID.
  Previously the unconditional clear could destroy a valid signal that
  the new step's agent had just written when sessions are reused across
  steps without auto_start_agent. (Greptile P1.)
- Drop the "feature flag" phrase from WorkflowStep.AutoAdvanceRequiresSignal
  docstring — leftover from an earlier draft. The gating is purely the
  boolean field; the global flag was removed two rounds ago.
- Merge the two adjacent `if triggerOnEnter` blocks in
  executeStepTransition so the full triggerOnEnter=true path reads top-
  to-bottom in one place. (CodeRabbit + cubic.)
- ADR 0015 Mechanism section: re-label the SessionStepHistory.Metadata
  audit annotation, the `step_complete_signaled` system message, and the
  expvar telemetry counters as deferred. They're planned shapes, not
  current behaviour — calling them "Audit" / "Chat visibility" /
  "Telemetry" in present tense was misleading for future readers.

* fix(workflow): drop subscriber complexity + reload session on stale-clear

- Extract `parseStepCompletionEvent` and `stepIsSignalGated` helpers from
  `onStepCompletionSignaled`. Cyclomatic complexity goes 17 → under 15
  (cyclop on new code, CI lint regression after the round-6
  stale-clear guard).
- The stale-step branch now re-loads the session from the DB before
  deciding whether to clear the bag. The previous round-6 fix checked
  the local `session.Metadata` snapshot, which can be older than a
  concurrent write from the new step's agent — that check could see a
  stale signal that had since been overwritten with a valid one and
  erase the new bag entry. The narrower load-on-clear is cheap and
  closes the cubic P1 hole. (The broader race in
  `processOnTurnCompleteViaEngine` reading session.Metadata is the
  monotonic-marker open question in ADR 0015; out of scope here.)

* test(workflow): pin stale-event guard's preserve-current-bag side + dedup StringFromAny

- Add `TestOnStepCompletionSignaled` subtest "stale event → valid bag
  for CURRENT step is preserved" — fires a stale step-A event while
  the bag holds a valid step-B signal, asserts the bag still contains
  StepID=step_b after the subscriber runs. Pins the negative side of
  the round-6 `existing.StepID == stepID` guard, so a regression that
  drops or inverts the guard (and would silently leave signal-gated
  steps stuck) fails the test instead of slipping through.
- Promote `pendingSignalString` to exported `models.StringFromAny`
  and reuse it from the orchestrator subscriber's
  `parseStepCompletionEvent`. Eliminates the byte-for-byte duplicate
  helper across the two packages; the bag-shape decoder and the bus-
  event decoder now share one source of truth for "missing string".

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-06-05 18:32:27 +01:00

905 lines
30 KiB
Go

// Package repository provides SQLite-based repository implementations for workflow entities.
package repository
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
workflowcfg "github.com/kandev/kandev/config/workflows"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/db"
"github.com/kandev/kandev/internal/db/dialect"
"github.com/kandev/kandev/internal/workflow/models"
)
// Repository provides SQLite-based workflow storage operations.
type Repository struct {
db *sqlx.DB // writer
ro *sqlx.DB // reader
migrate *db.MigrateLogger
}
// NewWithDB creates a new SQLite repository with existing database connections.
func NewWithDB(writer, reader *sqlx.DB, log *logger.Logger) (*Repository, error) {
repo := &Repository{
db: writer,
ro: reader,
migrate: db.NewMigrateLogger(writer, log),
}
if err := repo.initSchema(); err != nil {
return nil, fmt.Errorf("failed to initialize workflow schema: %w", err)
}
return repo, nil
}
// 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 TIMESTAMP NOT NULL,
updated_at TIMESTAMP 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 with new event-driven schema
stepsSchema := `
CREATE TABLE IF NOT EXISTS workflow_steps (
id TEXT PRIMARY KEY,
workflow_id TEXT NOT NULL,
name TEXT NOT NULL,
position INTEGER NOT NULL,
color TEXT,
prompt TEXT,
events TEXT,
allow_manual_move INTEGER DEFAULT 1,
is_start_step INTEGER DEFAULT 0,
show_in_command_panel INTEGER DEFAULT 1,
auto_archive_after_hours INTEGER DEFAULT 0,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
FOREIGN KEY (workflow_id) REFERENCES workflows(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_workflow_steps_workflow ON workflow_steps(workflow_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 (id column differs between SQLite and PostgreSQL)
var idCol string
if r.db.DriverName() == "pgx" {
idCol = "id BIGSERIAL PRIMARY KEY"
} else {
idCol = "id INTEGER PRIMARY KEY AUTOINCREMENT"
}
historySchema := `
CREATE TABLE IF NOT EXISTS session_step_history (
` + idCol + `,
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 TIMESTAMP 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)
}
r.migrate.Apply("workflow_steps.show_in_command_panel", `ALTER TABLE workflow_steps ADD COLUMN show_in_command_panel INTEGER DEFAULT 1`)
r.migrate.Apply("workflow_steps.agent_profile_id", `ALTER TABLE workflow_steps ADD COLUMN agent_profile_id TEXT DEFAULT ''`)
// Phase 2 (ADR-0004) - workflow_steps.stage_type, a UX hint for the
// frontend ("work" | "review" | "approval" | "custom"). Backend code
// MUST NOT branch on it. Idempotent ALTER; default keeps existing rows at "custom".
r.migrate.Apply("workflow_steps.stage_type", `ALTER TABLE workflow_steps ADD COLUMN stage_type TEXT NOT NULL DEFAULT 'custom'`)
// ADR 0015 — gate auto-advance on an explicit `step_complete_kandev`
// MCP signal. Idempotent ALTER; existing rows keep today's behaviour
// (immediate transition on turn-end) until the column is set to 1.
r.migrate.Apply("workflow_steps.auto_advance_requires_signal", `ALTER TABLE workflow_steps ADD COLUMN auto_advance_requires_signal INTEGER NOT NULL DEFAULT 0`)
// Phase 2 — multi-agent participation tables. Empty rows for a step
// preserve today's single-agent behaviour, so existing kanban
// workflows are unaffected.
if err := r.initPhase2Schema(); err != nil {
return 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 workflows that don't have any
if err := r.seedDefaultWorkflowSteps(); err != nil {
return fmt.Errorf("failed to seed default workflow steps: %w", err)
}
// Repair step_id references that were incorrectly saved as template aliases
// instead of UUIDs. This was caused by a frontend bug (fixed in PR #XXX).
if err := r.repairBrokenStepIDReferences(); err != nil {
return fmt.Errorf("failed to repair step_id references: %w", err)
}
return nil
}
// seedDefaultWorkflowSteps creates default workflow steps for workflows that don't have any.
// Uses the simple template as the default workflow.
func (r *Repository) seedDefaultWorkflowSteps() error {
// Find workflows without workflow steps
rows, err := r.db.Query(`
SELECT w.id FROM workflows w
LEFT JOIN workflow_steps ws ON ws.workflow_id = w.id
WHERE ws.id IS NULL
`)
if err != nil {
return err
}
defer func() { _ = rows.Close() }()
var workflowIDs []string
for rows.Next() {
var workflowID string
if err := rows.Scan(&workflowID); err != nil {
return err
}
workflowIDs = append(workflowIDs, workflowID)
}
if err := rows.Err(); err != nil {
return err
}
// Use the simple (kanban) template for default workflow steps
now := time.Now()
simpleTemplate, err := getKanbanTemplate()
if err != nil {
return err
}
for _, workflowID := range workflowIDs {
// Build mapping from template step ID to new UUID
idMap := make(map[string]string, len(simpleTemplate.Steps))
for _, stepDef := range simpleTemplate.Steps {
idMap[stepDef.ID] = uuid.New().String()
}
for _, stepDef := range simpleTemplate.Steps {
events := models.RemapStepEvents(stepDef.Events, idMap)
eventsJSON, err := json.Marshal(events)
if err != nil {
return fmt.Errorf("failed to marshal events: %w", err)
}
if _, err := r.db.Exec(r.db.Rebind(`
INSERT INTO workflow_steps (
id, workflow_id, name, position, color,
prompt, events, allow_manual_move, is_start_step, show_in_command_panel, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`),
idMap[stepDef.ID], workflowID, stepDef.Name, stepDef.Position, stepDef.Color,
stepDef.Prompt, string(eventsJSON), dialect.BoolToInt(stepDef.AllowManualMove),
dialect.BoolToInt(stepDef.IsStartStep), dialect.BoolToInt(stepDef.ShowInCommandPanel), now, now,
); err != nil {
return err
}
}
}
return nil
}
// repairBrokenStepIDReferences fixes workflow steps that have template aliases
// (like "review", "in-progress") instead of actual UUIDs in their step_id config.
// This repairs data corrupted by a frontend bug where template events overwrote
// the backend's properly remapped events.
func (r *Repository) repairBrokenStepIDReferences() error {
byWorkflow, err := r.loadStepsWithStepIDRefs()
if err != nil {
return err
}
for workflowID, steps := range byWorkflow {
nameToUUID, err := r.buildStepNameMapping(workflowID)
if err != nil {
return err
}
if err := r.repairWorkflowSteps(steps, nameToUUID); err != nil {
return err
}
}
return nil
}
type brokenStepRow struct {
id, workflowID, events string
}
func (r *Repository) loadStepsWithStepIDRefs() (map[string][]brokenStepRow, error) {
rows, err := r.db.Query(`
SELECT ws.id, ws.workflow_id, ws.events
FROM workflow_steps ws
WHERE ws.events LIKE '%"step_id"%'
`)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
byWorkflow := make(map[string][]brokenStepRow)
for rows.Next() {
var s brokenStepRow
if err := rows.Scan(&s.id, &s.workflowID, &s.events); err != nil {
return nil, err
}
byWorkflow[s.workflowID] = append(byWorkflow[s.workflowID], s)
}
return byWorkflow, rows.Err()
}
func (r *Repository) buildStepNameMapping(workflowID string) (map[string]string, error) {
rows, err := r.db.Query(r.db.Rebind(`
SELECT id, name FROM workflow_steps WHERE workflow_id = ?
`), workflowID)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
nameToUUID := make(map[string]string)
for rows.Next() {
var id, name string
if err := rows.Scan(&id, &name); err != nil {
return nil, err
}
nameToUUID[normalizeStepName(name)] = id
}
return nameToUUID, rows.Err()
}
func (r *Repository) repairWorkflowSteps(steps []brokenStepRow, nameToUUID map[string]string) error {
for _, step := range steps {
if err := r.repairSingleStep(step, nameToUUID); err != nil {
return err
}
}
return nil
}
func (r *Repository) repairSingleStep(step brokenStepRow, nameToUUID map[string]string) error {
var events models.StepEvents
if err := json.Unmarshal([]byte(step.events), &events); err != nil {
return nil // Skip malformed JSON
}
if !repairEventsStepIDs(&events, nameToUUID) {
return nil // No repairs needed
}
data, err := json.Marshal(events)
if err != nil {
return nil
}
_, err = r.db.Exec(r.db.Rebind(`UPDATE workflow_steps SET events = ? WHERE id = ?`), string(data), step.id)
if err != nil {
return fmt.Errorf("repair step %s: %w", step.id, err)
}
return nil
}
func repairEventsStepIDs(events *models.StepEvents, nameToUUID map[string]string) bool {
modified := false
for i, a := range events.OnTurnStart {
if a.Type == models.OnTurnStartMoveToStep && a.Config != nil {
if repairStepIDConfig(a.Config, nameToUUID) {
events.OnTurnStart[i] = a
modified = true
}
}
}
for i, a := range events.OnTurnComplete {
if a.Type == models.OnTurnCompleteMoveToStep && a.Config != nil {
if repairStepIDConfig(a.Config, nameToUUID) {
events.OnTurnComplete[i] = a
modified = true
}
}
}
return modified
}
// normalizeStepName converts a step name to a lookup key (lowercase, spaces→hyphens)
func normalizeStepName(name string) string {
return strings.ToLower(strings.ReplaceAll(name, " ", "-"))
}
// repairStepIDConfig fixes a step_id config value if it's a template alias.
// Returns true if a repair was made.
func repairStepIDConfig(cfg map[string]interface{}, nameToUUID map[string]string) bool {
stepID, ok := cfg["step_id"].(string)
if !ok {
return false
}
// Check if it's already a UUID (36 chars with hyphens at expected positions)
if len(stepID) == 36 && stepID[8] == '-' && stepID[13] == '-' {
return false
}
// Look up the real UUID by normalized name
if realUUID, found := nameToUUID[normalizeStepName(stepID)]; found {
cfg["step_id"] = realUUID
return true
}
return false
}
// getKanbanTemplate loads the kanban ("simple") template from embedded YAML.
// Used by migration code to seed default workflow steps.
func getKanbanTemplate() (*models.WorkflowTemplate, error) {
templates, err := workflowcfg.LoadTemplates()
if err != nil {
return nil, fmt.Errorf("workflows: load embedded templates: %w", err)
}
for _, t := range templates {
if t.ID == "simple" {
return t, nil
}
}
return nil, fmt.Errorf("workflows: kanban template (id=simple) not found in embedded YAML")
}
// seedSystemTemplates inserts the default system workflow templates.
func (r *Repository) seedSystemTemplates() error {
templates, err := r.getSystemTemplates()
if err != nil {
return err
}
for _, tmpl := range templates {
// Always upsert system templates to keep them current
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(r.db.Rebind(`
INSERT INTO workflow_templates (id, name, description, is_system, steps, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO NOTHING
`), tmpl.ID, tmpl.Name, tmpl.Description, dialect.BoolToInt(tmpl.IsSystem), string(stepsJSON), tmpl.CreatedAt, tmpl.UpdatedAt)
if err != nil {
return fmt.Errorf("failed to upsert template %s: %w", tmpl.ID, err)
}
}
return nil
}
// getSystemTemplates returns the predefined system workflow templates loaded from embedded YAML files.
func (r *Repository) getSystemTemplates() ([]*models.WorkflowTemplate, error) {
return workflowcfg.LoadTemplates()
}
// ============================================================================
// 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, r.db.Rebind(`
INSERT INTO workflow_templates (id, name, description, is_system, steps, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`), template.ID, template.Name, template.Description, dialect.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.ro.QueryRowContext(ctx, r.ro.Rebind(`
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, r.db.Rebind(`
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, r.db.Rebind(`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
}
func scanTemplateRows(rows *sql.Rows) ([]*models.WorkflowTemplate, error) {
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()
}
// ListTemplates returns all workflow templates.
func (r *Repository) ListTemplates(ctx context.Context) ([]*models.WorkflowTemplate, error) {
rows, err := r.ro.QueryContext(ctx, `
SELECT id, name, description, is_system, steps, created_at, updated_at
FROM workflow_templates
ORDER BY is_system DESC,
CASE
WHEN id = 'simple' THEN 1
WHEN id = 'standard' THEN 2
WHEN id = 'architecture' THEN 3
ELSE 999
END,
name ASC
`)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
return scanTemplateRows(rows)
}
// GetSystemTemplates returns only system workflow templates.
func (r *Repository) GetSystemTemplates(ctx context.Context) ([]*models.WorkflowTemplate, error) {
rows, err := r.ro.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() }()
return scanTemplateRows(rows)
}
// ============================================================================
// 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
eventsJSON, err := json.Marshal(step.Events)
if err != nil {
return fmt.Errorf("failed to marshal events: %w", err)
}
_, err = r.db.ExecContext(ctx, r.db.Rebind(`
INSERT INTO workflow_steps (
id, workflow_id, name, position, color,
prompt, events, allow_manual_move, is_start_step, show_in_command_panel, auto_archive_after_hours, agent_profile_id, stage_type, auto_advance_requires_signal, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`), step.ID, step.WorkflowID, step.Name, step.Position, step.Color,
step.Prompt, string(eventsJSON), dialect.BoolToInt(step.AllowManualMove),
dialect.BoolToInt(step.IsStartStep), dialect.BoolToInt(step.ShowInCommandPanel), step.AutoArchiveAfterHours, step.AgentProfileID, normalizeStageType(step.StageType), dialect.BoolToInt(step.AutoAdvanceRequiresSignal), step.CreatedAt, step.UpdatedAt)
return err
}
// normalizeStageType returns a string fit for the workflow_steps.stage_type
// column. Empty / invalid input collapses to "custom" so existing callers
// who do not set the field stay schema-compliant.
func normalizeStageType(s models.StageType) string {
switch s {
case models.StageTypeWork, models.StageTypeReview, models.StageTypeApproval, models.StageTypeCustom:
return string(s)
}
return string(models.StageTypeCustom)
}
// scanStep scans a single workflow step row including JSON events parsing.
func (r *Repository) scanStep(row interface {
Scan(dest ...interface{}) error
}) (*models.WorkflowStep, error) {
step := &models.WorkflowStep{}
var allowManualMove, isStartStep, showInCommandPanel, autoAdvanceRequiresSignal int
var autoArchiveAfterHours sql.NullInt64
var color, prompt, eventsJSON, agentProfileID, stageType sql.NullString
err := row.Scan(&step.ID, &step.WorkflowID, &step.Name, &step.Position, &color,
&prompt, &eventsJSON, &allowManualMove, &isStartStep, &showInCommandPanel, &autoArchiveAfterHours, &agentProfileID, &stageType, &autoAdvanceRequiresSignal, &step.CreatedAt, &step.UpdatedAt)
if err != nil {
return nil, err
}
step.AllowManualMove = allowManualMove == 1
step.IsStartStep = isStartStep == 1
step.ShowInCommandPanel = showInCommandPanel == 1
step.AutoAdvanceRequiresSignal = autoAdvanceRequiresSignal == 1
if autoArchiveAfterHours.Valid {
step.AutoArchiveAfterHours = int(autoArchiveAfterHours.Int64)
}
if agentProfileID.Valid {
step.AgentProfileID = agentProfileID.String
}
if stageType.Valid && stageType.String != "" {
step.StageType = models.StageType(stageType.String)
} else {
step.StageType = models.StageTypeCustom
}
if color.Valid {
step.Color = color.String
}
if prompt.Valid {
step.Prompt = prompt.String
}
if eventsJSON.Valid && eventsJSON.String != "" {
if err := json.Unmarshal([]byte(eventsJSON.String), &step.Events); err != nil {
return nil, fmt.Errorf("failed to unmarshal events: %w", err)
}
}
return step, nil
}
const stepSelectColumns = `id, workflow_id, name, position, color, prompt, events, allow_manual_move, is_start_step, show_in_command_panel, auto_archive_after_hours, agent_profile_id, stage_type, auto_advance_requires_signal, created_at, updated_at`
// GetStep retrieves a workflow step by ID.
func (r *Repository) GetStep(ctx context.Context, id string) (*models.WorkflowStep, error) {
row := r.ro.QueryRowContext(ctx, r.ro.Rebind(`
SELECT `+stepSelectColumns+`
FROM workflow_steps WHERE id = ?
`), id)
step, err := r.scanStep(row)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("workflow step not found: %s", id)
}
if err != nil {
return nil, err
}
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()
eventsJSON, err := json.Marshal(step.Events)
if err != nil {
return fmt.Errorf("failed to marshal events: %w", err)
}
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE workflow_steps SET
name = ?, position = ?, color = ?,
prompt = ?, events = ?,
allow_manual_move = ?, is_start_step = ?, show_in_command_panel = ?, auto_archive_after_hours = ?, agent_profile_id = ?, stage_type = ?, auto_advance_requires_signal = ?, updated_at = ?
WHERE id = ?
`), step.Name, step.Position, step.Color,
step.Prompt, string(eventsJSON),
dialect.BoolToInt(step.AllowManualMove), dialect.BoolToInt(step.IsStartStep), dialect.BoolToInt(step.ShowInCommandPanel), step.AutoArchiveAfterHours, step.AgentProfileID, normalizeStageType(step.StageType), dialect.BoolToInt(step.AutoAdvanceRequiresSignal), 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
}
// ClearStartStepFlag clears the is_start_step flag for all steps in a workflow except the given step.
func (r *Repository) ClearStartStepFlag(ctx context.Context, workflowID, exceptStepID string) error {
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE workflow_steps SET is_start_step = 0
WHERE workflow_id = ? AND id != ?
`), workflowID, exceptStepID)
return err
}
// GetStartStep returns the step marked as is_start_step for a workflow.
// Returns nil if no step is marked.
func (r *Repository) GetStartStep(ctx context.Context, workflowID string) (*models.WorkflowStep, error) {
row := r.ro.QueryRowContext(ctx, r.ro.Rebind(`
SELECT `+stepSelectColumns+`
FROM workflow_steps WHERE workflow_id = ? AND is_start_step = 1
LIMIT 1
`), workflowID)
step, err := r.scanStep(row)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return step, nil
}
// ClearStepReferences clears any move_to_step event config references
// to the given step ID within the specified workflow.
func (r *Repository) ClearStepReferences(ctx context.Context, workflowID, stepID string) error {
// Load all steps in the workflow and update any that reference the deleted step
steps, err := r.ListStepsByWorkflow(ctx, workflowID)
if err != nil {
return err
}
for _, step := range steps {
modified := false
for i, action := range step.Events.OnTurnComplete {
if action.Type == models.OnTurnCompleteMoveToStep && action.Config != nil {
if refID, ok := action.Config["step_id"].(string); ok && refID == stepID {
// Remove this action
step.Events.OnTurnComplete = append(step.Events.OnTurnComplete[:i], step.Events.OnTurnComplete[i+1:]...)
modified = true
break
}
}
}
if modified {
if err := r.UpdateStep(ctx, step); err != nil {
return fmt.Errorf("failed to clear step reference from step %s: %w", step.ID, err)
}
}
}
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, r.db.Rebind(`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
}
// ListStepsByWorkflow returns all workflow steps for a workflow.
func (r *Repository) ListStepsByWorkflow(ctx context.Context, workflowID string) ([]*models.WorkflowStep, error) {
rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(`
SELECT `+stepSelectColumns+`
FROM workflow_steps WHERE workflow_id = ? ORDER BY position
`), workflowID)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
return r.scanSteps(rows)
}
// ListStepsByWorkspaceID returns all workflow steps for all workflows in a workspace.
func (r *Repository) ListStepsByWorkspaceID(ctx context.Context, workspaceID string) ([]*models.WorkflowStep, error) {
rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(`
SELECT ws.id, ws.workflow_id, ws.name, ws.position, ws.color, ws.prompt, ws.events,
ws.allow_manual_move, ws.is_start_step, ws.show_in_command_panel, ws.auto_archive_after_hours, ws.agent_profile_id, ws.stage_type, ws.auto_advance_requires_signal, ws.created_at, ws.updated_at
FROM workflow_steps ws
JOIN workflows w ON ws.workflow_id = w.id
WHERE w.workspace_id = ?
ORDER BY ws.workflow_id, ws.position
`), workspaceID)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
return r.scanSteps(rows)
}
// StepEventsRow projects (step_id, workflow_id, raw events JSON) for a
// single workflow step. Used by the Phase 5 heartbeat / budget cron
// handlers (see internal/scheduler/cron) so they can detect new
// trigger keys (on_heartbeat, on_budget_alert, …) that the Phase 2
// StepEvents struct does not yet model. The handlers parse the raw
// JSON themselves to keep the repository decoupled from the engine's
// trigger taxonomy.
type StepEventsRow struct {
StepID string
WorkflowID string
EventsJSON string
}
// ListAllStepEventsJSON returns the raw events JSON for every workflow
// step in the database. Cheap enough for the Phase 5 cron tick because
// kanban deployments have on the order of tens to low hundreds of
// steps; the SQL filter on `events LIKE '%on_heartbeat%'` keeps the
// scan tight even as office workflows multiply.
func (r *Repository) ListAllStepEventsJSON(ctx context.Context) ([]StepEventsRow, error) {
rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(`
SELECT id, workflow_id, COALESCE(events, '')
FROM workflow_steps
WHERE events LIKE '%on_heartbeat%' OR events LIKE '%on_budget_alert%'
`))
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
var out []StepEventsRow
for rows.Next() {
var row StepEventsRow
if err := rows.Scan(&row.StepID, &row.WorkflowID, &row.EventsJSON); err != nil {
return nil, err
}
out = append(out, row)
}
return out, rows.Err()
}
// 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, err := r.scanStep(rows)
if err != nil {
return nil, err
}
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
}
id, err := dialect.InsertReturningID(ctx, r.db,
`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
}
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.ro.QueryContext(ctx, r.ro.Rebind(`
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()
}