Files
kandev/apps/backend/internal/task/repository/sqlite/step_transitions.go
T

414 lines
16 KiB
Go

package sqlite
import (
"context"
"database/sql"
"fmt"
"strconv"
"time"
"github.com/jmoiron/sqlx"
"github.com/kandev/kandev/internal/db/dialect"
"github.com/kandev/kandev/internal/steptelemetry"
"github.com/kandev/kandev/internal/task/models"
)
// ListTaskStepTransitions reads newest-first ledger rows using immutable IDs.
func (r *Repository) ListTaskStepTransitions(ctx context.Context, taskID string, beforeID int64, limit int) ([]models.StepTransition, error) {
query := `SELECT id, from_workflow_id, from_workflow_step_id, to_workflow_id, to_workflow_step_id, trigger, occurred_at
FROM task_step_transitions WHERE task_id = ?`
args := []any{taskID}
if beforeID > 0 {
query += ` AND id < ?`
args = append(args, beforeID)
}
query += ` ORDER BY id DESC LIMIT ?`
args = append(args, limit)
rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(query), args...)
if err != nil {
return nil, fmt.Errorf("list task step transitions: %w", err)
}
defer func() { _ = rows.Close() }()
items := make([]models.StepTransition, 0)
for rows.Next() {
var item models.StepTransition
var fromWorkflow, fromStep, toWorkflow, toStep sql.NullString
if err := rows.Scan(&item.ID, &fromWorkflow, &fromStep, &toWorkflow, &toStep, &item.Trigger, &item.OccurredAt); err != nil {
return nil, fmt.Errorf("scan task step transition: %w", err)
}
item.FromWorkflowID = nullableValue(fromWorkflow)
item.FromWorkflowStepID = nullableValue(fromStep)
item.ToWorkflowID = nullableValue(toWorkflow)
item.ToWorkflowStepID = nullableValue(toStep)
items = append(items, item)
}
return items, rows.Err()
}
func nullableValue(value sql.NullString) *string {
if !value.Valid {
return nil
}
return &value.String
}
// ListWorkflowTransitionGroups counts retained routes involving one workflow.
// Rows remain present when a step is removed because the ledger stores IDs.
func (r *Repository) ListWorkflowTransitionGroups(ctx context.Context, workspaceID, workflowID, afterKey string, limit int) ([]models.TransitionGroup, error) {
const query = `WITH scoped AS (
SELECT CASE WHEN h.from_workflow_id = ? AND h.to_workflow_id = ? THEN 'within'
WHEN h.to_workflow_id = ? THEN 'entry' ELSE 'exit' END AS kind,
CASE WHEN h.from_workflow_id = ? THEN h.from_workflow_step_id END AS from_step_id,
CASE WHEN h.to_workflow_id = ? THEN h.to_workflow_step_id END AS to_step_id
FROM task_step_transitions h JOIN tasks t ON t.id = h.task_id
WHERE t.workspace_id = ? AND (h.from_workflow_id = ? OR h.to_workflow_id = ?)
), grouped AS (
SELECT kind, from_step_id, to_step_id, COUNT(*) AS route_count
FROM scoped GROUP BY kind, from_step_id, to_step_id
), ordered AS (
SELECT kind, from_step_id, to_step_id, route_count,
kind || '|' || COALESCE(from_step_id, '') || '|' || COALESCE(to_step_id, '') AS route_key
FROM grouped
)
SELECT kind, from_step_id, to_step_id, route_count FROM ordered
WHERE route_key > ? ORDER BY route_key LIMIT ?`
rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(query), workflowID, workflowID, workflowID,
workflowID, workflowID, workspaceID, workflowID, workflowID, afterKey, limit)
if err != nil {
return nil, fmt.Errorf("list workflow transition groups: %w", err)
}
defer func() { _ = rows.Close() }()
items := make([]models.TransitionGroup, 0)
for rows.Next() {
var item models.TransitionGroup
var fromStep, toStep sql.NullString
if err := rows.Scan(&item.Kind, &fromStep, &toStep, &item.Count); err != nil {
return nil, fmt.Errorf("scan workflow transition group: %w", err)
}
item.FromStepID, item.ToStepID = nullableValue(fromStep), nullableValue(toStep)
items = append(items, item)
}
return items, rows.Err()
}
// stepTransitionTx is satisfied by both *sql.Tx and *sqlx.Tx, the two
// transaction types the mutation paths in this package use.
type stepTransitionTx interface {
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
}
// readTaskStepInTx reads the task's current (workflow_id, workflow_step_id)
// inside the write transaction, taking a row lock on Postgres. Reading the
// old step outside the write transaction — or from an in-memory *models.Task
// the caller handed in — would break the chain invariant under concurrent
// moves: the read of the old step and the write of the new one must be
// serialized against other writers of the same task row. On SQLite the
// writer pool already serializes writers, so the plain read is sufficient.
func (r *Repository) readTaskStepInTx(ctx context.Context, tx stepTransitionTx, taskID string) (workflowID, stepID string, found bool, err error) {
query := `SELECT workflow_id, workflow_step_id FROM tasks WHERE id = ?`
if dialect.IsPostgres(r.db.DriverName()) {
query += ` FOR UPDATE`
}
var wf, step sql.NullString
err = tx.QueryRowContext(ctx, r.db.Rebind(query), taskID).Scan(&wf, &step)
if err == sql.ErrNoRows {
return "", "", false, nil
}
if err != nil {
return "", "", false, err
}
return wf.String, step.String, true, nil
}
// stepTransitionInput is the fully-resolved shape of one ledger row, with
// empty strings still present — recordStepTransition normalizes them to NULL.
type stepTransitionInput struct {
taskID string
fromWorkflowID string
fromWorkflowStepID string
toWorkflowID string
toWorkflowStepID string
// occurredAt is the caller's own timestamp for this transition — the same
// value the caller already stamped onto tasks.updated_at (or created_at
// for the genesis row), computed once after the transactional old-state
// read/lock. Callers MUST NOT leave this zero: recordStepTransition does
// not call time.Now() itself, because a second, independent clock read
// here (after the caller's) can drift from tasks.updated_at under lock
// contention, breaking the spec's "occurred_at and updated_at agree at
// write time" guarantee.
occurredAt time.Time
}
// recordStepTransition writes exactly one ledger row when the step actually
// changed and returns that row's own immutable ledger identifier. Callers
// that need the step-entry requirement's entry identity (AC-OFFICE-STEP-
// ENTRY-001.2, .7) derive it from this id via formatEntryID; callers that
// need the identifier for models.Task.WorkflowStepTransitionID use it
// directly. It is a no-op (id 0, nil error) when fromStepID == toStepID
// (position-only reorder, re-issued move to the current step) and when both
// sides are empty (a task with no workflow at all — a row with both sides
// NULL is forbidden). Retrieval of the identifier is dialect-appropriate
// (ADR-0027): Postgres uses RETURNING id, SQLite uses the exec result's
// LastInsertId, both inside the same statement that commits the row so no
// separate count-then-use read can race it.
//
// The missing-table case needs no detection code and must not get any: if
// the CREATE TABLE was silently swallowed by the migration runner, the
// INSERT fails with "no such table" / "relation does not exist", that error
// returns unchanged, and the enclosing transaction rolls back — which is
// precisely the spec's required behaviour (the step change does not commit
// with no row). Never log-and-continue on this error.
func (r *Repository) recordStepTransition(ctx context.Context, tx stepTransitionTx, in stepTransitionInput) (id int64, err error) {
if in.fromWorkflowStepID == in.toWorkflowStepID {
return 0, nil
}
if in.fromWorkflowStepID == "" && in.toWorkflowStepID == "" {
return 0, nil
}
occurredAt := in.occurredAt
if occurredAt.IsZero() {
// Safety net for a caller that forgot to stamp its own shared
// timestamp, not the expected path — every registered chokepoint
// passes its own tasks.updated_at/created_at value.
occurredAt = time.Now().UTC()
}
attribution := steptelemetry.FromContext(ctx)
insertSQL := `
INSERT INTO task_step_transitions
(task_id, session_id, from_workflow_id, from_workflow_step_id, to_workflow_id, to_workflow_step_id, trigger, actor_kind, actor_id, contract_version, occurred_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`
args := []any{
in.taskID,
nullableString(attribution.SessionID),
nullableString(in.fromWorkflowID),
nullableString(in.fromWorkflowStepID),
nullableString(in.toWorkflowID),
nullableString(in.toWorkflowStepID),
string(attribution.Trigger),
string(attribution.ActorKind),
nullableString(attribution.ActorID),
steptelemetry.ContractVersion,
occurredAt,
}
if dialect.IsPostgres(r.db.DriverName()) {
if err := tx.QueryRowContext(ctx, r.db.Rebind(insertSQL+" RETURNING id"), args...).Scan(&id); err != nil {
return 0, err
}
} else {
result, execErr := tx.ExecContext(ctx, r.db.Rebind(insertSQL), args...)
if execErr != nil {
return 0, execErr
}
id, err = result.LastInsertId()
if err != nil {
return 0, err
}
}
// Counted at INSERT time, not at commit: a later statement in the same
// caller-owned transaction failing after this point (rare — e.g. the
// runner sync in UpdateTask) rolls the row back but the counter still
// bumped. The counter (and its "_inserted_" name) is a health signal
// ("is the writer alive"), not a commit-confirmed row-for-row audit
// trail; the ledger table itself is that audit trail.
steptelemetry.RecordLedgerRow(r.log, attribution.Trigger)
return id, nil
}
// GetLatestTaskStepTransitionID returns the immutable ledger identity of the
// task's latest workflow-step entry. Loaded task projections intentionally do
// not carry the transient transition field, so retryable workflow routing uses
// this read when it needs to reconstruct the current entry after a restart.
func (r *Repository) GetLatestTaskStepTransitionID(ctx context.Context, taskID string) (int64, error) {
var id int64
err := r.ro.QueryRowContext(ctx, r.ro.Rebind(`
SELECT id
FROM task_step_transitions
WHERE task_id = ?
ORDER BY id DESC
LIMIT 1
`), taskID).Scan(&id)
if err == sql.ErrNoRows {
return 0, nil
}
if err != nil {
return 0, err
}
return id, nil
}
// EnsureCurrentTaskStepTransition returns the latest workflow-entry identity,
// creating a durable current-entry row when an older database predates the
// transition ledger. The task-row write guard serializes this backfill with
// workflow moves, so a launch never captures a synthetic identity for an
// obsolete step.
func (r *Repository) EnsureCurrentTaskStepTransition(ctx context.Context, taskID string) (int64, error) {
tx, err := r.db.BeginTxx(ctx, nil)
if err != nil {
return 0, fmt.Errorf("begin workflow entry backfill: %w", err)
}
defer func() { _ = tx.Rollback() }()
entryID, err := r.ensureCurrentTaskStepTransitionTx(ctx, tx, taskID)
if err != nil {
return 0, err
}
if err := tx.Commit(); err != nil {
return 0, err
}
return entryID, nil
}
func (r *Repository) ensureCurrentTaskStepTransitionTx(
ctx context.Context,
tx *sqlx.Tx,
taskID string,
) (int64, error) {
// Acquire the same task-row write boundary used by queue admission. SQLite
// otherwise allows a read transaction to observe the task before a
// concurrent writer commits its move, while PostgreSQL obtains the row lock
// through the same UPDATE and its FOR UPDATE read below.
guard, err := tx.ExecContext(ctx, r.db.Rebind(`
UPDATE tasks SET updated_at = updated_at WHERE id = ?
`), taskID)
if err != nil {
return 0, fmt.Errorf("guard task for workflow entry backfill: %w", err)
}
rows, err := guard.RowsAffected()
if err != nil {
return 0, fmt.Errorf("workflow entry backfill task rows affected: %w", err)
}
if rows == 0 {
return 0, fmt.Errorf("%w: %s", ErrTaskNotFound, taskID)
}
var latestID int64
err = tx.QueryRowContext(ctx, r.db.Rebind(`
SELECT id
FROM task_step_transitions
WHERE task_id = ?
ORDER BY id DESC
LIMIT 1
`), taskID).Scan(&latestID)
if err != nil && err != sql.ErrNoRows {
return 0, fmt.Errorf("read workflow entry during backfill: %w", err)
}
if err == nil && latestID > 0 {
if err := tx.Commit(); err != nil {
return 0, err
}
return latestID, nil
}
workflowID, workflowStepID, found, err := r.readTaskStepInTx(ctx, tx, taskID)
if err != nil {
return 0, err
}
if !found || workflowID == "" || workflowStepID == "" {
if err := tx.Commit(); err != nil {
return 0, err
}
return 0, nil
}
backfilledID, err := r.recordStepTransition(ctx, tx, stepTransitionInput{
taskID: taskID,
toWorkflowID: workflowID,
toWorkflowStepID: workflowStepID,
occurredAt: time.Now().UTC(),
})
if err != nil {
return 0, fmt.Errorf("write workflow entry backfill: %w", err)
}
return backfilledID, nil
}
// CountStepEntries returns the number of committed task_step_transitions
// rows whose to_workflow_step_id is stepID for taskID — the recorded entry
// count REQ-TWS-001 floors at 1 to derive the step-entry number. Recorded
// entries before the ledger's first row (2026-08-16) do not exist and cannot
// be counted, so the result is a lower bound on the true entry count for a
// task whose history predates the ledger. An empty taskID or stepID returns
// (0, nil) without issuing a query: the ledger normalizes "" to NULL, so a
// query would only ever be able to return 0.
func (r *Repository) CountStepEntries(ctx context.Context, taskID, stepID string) (int, error) {
if taskID == "" || stepID == "" {
return 0, nil
}
const query = `SELECT COUNT(*) FROM task_step_transitions WHERE task_id = ? AND to_workflow_step_id = ?`
var count int
if err := r.ro.QueryRowContext(ctx, r.ro.Rebind(query), taskID, stepID).Scan(&count); err != nil {
return 0, fmt.Errorf("count step entries: %w", err)
}
return count, nil
}
// formatEntryID converts recordStepTransition's ledger identifier into the
// step-entry requirement's entry identity string (AC-OFFICE-STEP-ENTRY-
// 001.2, .7). id 0 means recordStepTransition was a no-op — dispatchStepEntry
// relies on "" (not "0") to detect that case, so it must not be formatted.
func formatEntryID(id int64) string {
if id == 0 {
return ""
}
return strconv.FormatInt(id, 10)
}
// genesisAttribution is the task-creation ledger row's attribution: the
// writer hard-codes TriggerTaskCreated (there is exactly one INSERT path, so
// no caller disambiguation is needed the way the other six chokepoints need
// it). See hardcodedTriggerAttribution for the actor-resolution rule shared
// with detachAttribution.
func genesisAttribution(ctx context.Context) steptelemetry.Attribution {
return hardcodedTriggerAttribution(ctx, steptelemetry.TriggerTaskCreated)
}
// detachAttribution is RemoveTaskFromWorkflow's ledger row attribution: the
// writer hard-codes TriggerWorkflowDetached rather than relying on a caller
// to supply it. Unlike AddTaskToWorkflow — whose one production caller
// (office/engine_adapters/workflow_switcher_adapter.go) explicitly sets
// TriggerWorkflowAttached before calling — RemoveTaskFromWorkflow has no
// production caller today, so there is no wrapper anywhere to get this
// right; hardcoding it here means a future caller cannot get it wrong.
func detachAttribution(ctx context.Context) steptelemetry.Attribution {
return hardcodedTriggerAttribution(ctx, steptelemetry.TriggerWorkflowDetached)
}
// hardcodedTriggerAttribution is the actor-resolution rule shared by ledger
// chokepoints whose trigger has exactly one semantic meaning regardless of
// caller. The actor prefers an explicit caller-supplied attribution when one
// is already on ctx (e.g. the watcher dispatch coordinator setting
// ActorIntegration for a Jira/Linear-originated create) and otherwise falls
// back to the identity already on the context — the existing authn seam.
func hardcodedTriggerAttribution(ctx context.Context, trigger steptelemetry.Trigger) steptelemetry.Attribution {
if preset := steptelemetry.FromContext(ctx); preset.ActorKind != steptelemetry.ActorUnknown {
return steptelemetry.Attribution{
Trigger: trigger,
ActorKind: preset.ActorKind,
ActorID: preset.ActorID,
SessionID: preset.SessionID,
}
}
actorKind, actorID := steptelemetry.HumanOrSystemActor(ctx)
return steptelemetry.Attribution{
Trigger: trigger,
ActorKind: actorKind,
ActorID: actorID,
}
}
// nullableString normalizes "" to SQL NULL: tasks.workflow_id and
// tasks.workflow_step_id use "" for "no step", but the ledger stores NULL for
// the same condition in every from_*/to_* column.
func nullableString(s string) interface{} {
if s == "" {
return nil
}
return s
}