Files
kandev/apps/backend/internal/task/repository/sqlite/step_transitions.go
T
2026-09-10 16:32:41 +01:00

227 lines
9.2 KiB
Go

package sqlite
import (
"context"
"database/sql"
"strconv"
"time"
"github.com/kandev/kandev/internal/db/dialect"
"github.com/kandev/kandev/internal/steptelemetry"
)
// 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
}
// 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
}