kunchenguid--no-mistakes
26f897c1ec
release / release-please (push) Failing after 1m49s
docs / build (push) Failing after 6m34s
release / build-and-upload (arm64, linux) (push) Has been cancelled
release / build-and-upload (arm64, windows) (push) Has been cancelled
release / build-darwin (amd64, darwin) (push) Has been cancelled
release / checksums (push) Has been cancelled
release / finalize (push) Has been cancelled
release / build-darwin (arm64, darwin) (push) Has been cancelled
release / build-and-upload (amd64, linux) (push) Has been cancelled
release / build-and-upload (amd64, windows) (push) Has been cancelled
docs / deploy (push) Has been cancelled
378 行
12 KiB
Go
378 行
12 KiB
Go
package db
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/kunchenguid/no-mistakes/internal/types"
|
|
)
|
|
|
|
// Run represents a pipeline run.
|
|
type Run struct {
|
|
ID string
|
|
RepoID string
|
|
Branch string
|
|
HeadSHA string
|
|
BaseSHA string
|
|
Status types.RunStatus
|
|
PRURL *string
|
|
Error *string
|
|
// AwaitingAgentSince is the unix-seconds timestamp at which the run parked
|
|
// at a gate awaiting the driving agent's response (an awaiting_approval or
|
|
// fix_review step). It is nil whenever the run is not parked: the executor
|
|
// sets it on gate entry and clears it the moment the agent responds (or the
|
|
// wait is cancelled). It is observability only and does not affect gate
|
|
// resolution.
|
|
AwaitingAgentSince *int64
|
|
// ParkedMS accumulates the run's total parked-at-gate wall time in
|
|
// milliseconds across every gate wait (local performance telemetry;
|
|
// step duration_ms values exclude this time).
|
|
ParkedMS int64
|
|
Intent *string
|
|
IntentSource *string
|
|
IntentSessionID *string
|
|
IntentScore *float64
|
|
CreatedAt int64
|
|
UpdatedAt int64
|
|
}
|
|
|
|
const runColumns = `id, repo_id, branch, head_sha, base_sha, status, pr_url, error, awaiting_agent_since, COALESCE(parked_ms, 0), intent, intent_source, intent_session_id, intent_score, created_at, updated_at`
|
|
|
|
func scanRun(row interface {
|
|
Scan(...any) error
|
|
}, r *Run) error {
|
|
return row.Scan(
|
|
&r.ID, &r.RepoID, &r.Branch, &r.HeadSHA, &r.BaseSHA, &r.Status,
|
|
&r.PRURL, &r.Error, &r.AwaitingAgentSince, &r.ParkedMS,
|
|
&r.Intent, &r.IntentSource, &r.IntentSessionID, &r.IntentScore,
|
|
&r.CreatedAt, &r.UpdatedAt,
|
|
)
|
|
}
|
|
|
|
// InsertRun creates a new run record.
|
|
func (d *DB) InsertRun(repoID, branch, headSHA, baseSHA string) (*Run, error) {
|
|
ts := now()
|
|
r := &Run{
|
|
ID: newID(),
|
|
RepoID: repoID,
|
|
Branch: branch,
|
|
HeadSHA: headSHA,
|
|
BaseSHA: baseSHA,
|
|
Status: types.RunPending,
|
|
CreatedAt: ts,
|
|
UpdatedAt: ts,
|
|
}
|
|
_, err := d.sql.Exec(
|
|
`INSERT INTO runs (id, repo_id, branch, head_sha, base_sha, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
r.ID, r.RepoID, r.Branch, r.HeadSHA, r.BaseSHA, r.Status, r.CreatedAt, r.UpdatedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("insert run: %w", err)
|
|
}
|
|
return r, nil
|
|
}
|
|
|
|
// GetRun returns a run by ID.
|
|
func (d *DB) GetRun(id string) (*Run, error) {
|
|
r := &Run{}
|
|
err := scanRun(d.sql.QueryRow(`SELECT `+runColumns+` FROM runs WHERE id = ?`, id), r)
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get run: %w", err)
|
|
}
|
|
return r, nil
|
|
}
|
|
|
|
// GetRunsByRepo returns all runs for a repo, newest first.
|
|
func (d *DB) GetRunsByRepo(repoID string) ([]*Run, error) {
|
|
rows, err := d.sql.Query(`SELECT `+runColumns+` FROM runs WHERE repo_id = ? ORDER BY created_at DESC, id DESC`, repoID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get runs by repo: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var runs []*Run
|
|
for rows.Next() {
|
|
r := &Run{}
|
|
if err := scanRun(rows, r); err != nil {
|
|
return nil, fmt.Errorf("scan run: %w", err)
|
|
}
|
|
runs = append(runs, r)
|
|
}
|
|
return runs, rows.Err()
|
|
}
|
|
|
|
// GetRunsByRepoHead returns the runs for a repo matching an exact branch and
|
|
// head SHA, newest first. It lets a caller detect the run created by a specific
|
|
// push without scanning (and rebuilding step data for) the repo's entire run
|
|
// history, so the cost stays bounded to the handful of runs for one head.
|
|
func (d *DB) GetRunsByRepoHead(repoID, branch, headSHA string) ([]*Run, error) {
|
|
rows, err := d.sql.Query(
|
|
`SELECT `+runColumns+` FROM runs WHERE repo_id = ? AND branch = ? AND head_sha = ? ORDER BY created_at DESC, id DESC`,
|
|
repoID, branch, headSHA,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get runs by repo head: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var runs []*Run
|
|
for rows.Next() {
|
|
r := &Run{}
|
|
if err := scanRun(rows, r); err != nil {
|
|
return nil, fmt.Errorf("scan run: %w", err)
|
|
}
|
|
runs = append(runs, r)
|
|
}
|
|
return runs, rows.Err()
|
|
}
|
|
|
|
// GetActiveRun returns the currently active run (pending or running) for a repo,
|
|
// if any. When branch is non-empty, only a run on that exact branch is returned
|
|
// - the setup wizard relies on this to decide whether a new run is needed for
|
|
// the current branch. When branch is empty, returns the most recently created
|
|
// active run across any branch.
|
|
func (d *DB) GetActiveRun(repoID, branch string) (*Run, error) {
|
|
r := &Run{}
|
|
var err error
|
|
if branch == "" {
|
|
err = scanRun(d.sql.QueryRow(
|
|
`SELECT `+runColumns+` FROM runs WHERE repo_id = ? AND status IN ('pending', 'running') ORDER BY created_at DESC, id DESC LIMIT 1`, repoID,
|
|
), r)
|
|
} else {
|
|
err = scanRun(d.sql.QueryRow(
|
|
`SELECT `+runColumns+` FROM runs WHERE repo_id = ? AND branch = ? AND status IN ('pending', 'running') ORDER BY created_at DESC, id DESC LIMIT 1`, repoID, branch,
|
|
), r)
|
|
}
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get active run: %w", err)
|
|
}
|
|
return r, nil
|
|
}
|
|
|
|
// GetActiveRuns returns all pending or running runs across all repos, newest first.
|
|
func (d *DB) GetActiveRuns() ([]*Run, error) {
|
|
rows, err := d.sql.Query(
|
|
`SELECT `+runColumns+` FROM runs WHERE status IN (?, ?) ORDER BY created_at DESC, id DESC`,
|
|
types.RunPending, types.RunRunning,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get active runs: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var runs []*Run
|
|
for rows.Next() {
|
|
r := &Run{}
|
|
if err := scanRun(rows, r); err != nil {
|
|
return nil, fmt.Errorf("scan run: %w", err)
|
|
}
|
|
runs = append(runs, r)
|
|
}
|
|
return runs, rows.Err()
|
|
}
|
|
|
|
// UpdateRunStatus updates a run's status and updated_at timestamp.
|
|
func (d *DB) UpdateRunStatus(id string, status types.RunStatus) error {
|
|
_, err := d.sql.Exec(`UPDATE runs SET status = ?, updated_at = ? WHERE id = ?`, status, now(), id)
|
|
if err != nil {
|
|
return fmt.Errorf("update run status: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UpdateRunPRURL sets the PR URL on a run.
|
|
func (d *DB) UpdateRunPRURL(id, prURL string) error {
|
|
_, err := d.sql.Exec(`UPDATE runs SET pr_url = ?, updated_at = ? WHERE id = ?`, prURL, now(), id)
|
|
if err != nil {
|
|
return fmt.Errorf("update run pr url: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UpdateRunHeadSHA updates the run head SHA and timestamp.
|
|
func (d *DB) UpdateRunHeadSHA(id, headSHA string) error {
|
|
_, err := d.sql.Exec(`UPDATE runs SET head_sha = ?, updated_at = ? WHERE id = ?`, headSHA, now(), id)
|
|
if err != nil {
|
|
return fmt.Errorf("update run head sha: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UpdateRunError sets the error message on a run.
|
|
func (d *DB) UpdateRunError(id, errMsg string) error {
|
|
return d.UpdateRunErrorStatus(id, errMsg, types.RunFailed)
|
|
}
|
|
|
|
// UpdateRunErrorStatus sets the error message and terminal status on a run.
|
|
func (d *DB) UpdateRunErrorStatus(id, errMsg string, status types.RunStatus) error {
|
|
_, err := d.sql.Exec(`UPDATE runs SET error = ?, status = ?, updated_at = ? WHERE id = ?`, errMsg, status, now(), id)
|
|
if err != nil {
|
|
return fmt.Errorf("update run error: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RunIntentSourceAgent is the intent_source value stamped when the driving
|
|
// agent supplied the intent explicitly via `axi run --intent`. It marks an
|
|
// authoritative, author-stated goal (score 1) as opposed to a transcript
|
|
// inference (whose source is the matched agent name: "claude", "codex", ...).
|
|
// Prompt-construction code branches on this to frame an explicit intent as
|
|
// authoritative acceptance criteria rather than a low-confidence hint.
|
|
const RunIntentSourceAgent = "agent"
|
|
|
|
// RunIntent carries the four intent-related columns persisted on a run.
|
|
type RunIntent struct {
|
|
Summary string
|
|
Source string
|
|
SessionID string
|
|
Score float64
|
|
}
|
|
|
|
// UpdateRunIntent persists the inferred user intent for a run.
|
|
func (d *DB) UpdateRunIntent(id string, intent RunIntent) error {
|
|
_, err := d.sql.Exec(
|
|
`UPDATE runs SET intent = ?, intent_source = ?, intent_session_id = ?, intent_score = ?, updated_at = ? WHERE id = ?`,
|
|
intent.Summary, intent.Source, intent.SessionID, intent.Score, now(), id,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("update run intent: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SetRunAwaitingAgent marks a run as parked awaiting the driving agent,
|
|
// stamping awaiting_agent_since with the current time. Called by the executor
|
|
// when a step enters a gate (awaiting_approval / fix_review). This is a pollable
|
|
// observability signal only; it does not change gate resolution.
|
|
func (d *DB) SetRunAwaitingAgent(id string) error {
|
|
ts := now()
|
|
_, err := d.sql.Exec(`UPDATE runs SET awaiting_agent_since = ?, updated_at = ? WHERE id = ?`, ts, ts, id)
|
|
if err != nil {
|
|
return fmt.Errorf("set run awaiting agent: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ClearRunAwaitingAgent clears the awaiting-agent marker on a run. Called by the
|
|
// executor the moment the agent responds (or the approval wait is cancelled) and
|
|
// the run resumes, so awaiting_agent_since is non-nil exactly while a gate is
|
|
// actually parked.
|
|
func (d *DB) ClearRunAwaitingAgent(id string) error {
|
|
_, err := d.sql.Exec(`UPDATE runs SET awaiting_agent_since = NULL, updated_at = ? WHERE id = ?`, now(), id)
|
|
if err != nil {
|
|
return fmt.Errorf("clear run awaiting agent: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// AddRunParkedDuration accumulates parked-at-gate wall time onto a run's
|
|
// total. Called by the executor when a gate wait ends.
|
|
func (d *DB) AddRunParkedDuration(id string, ms int64) error {
|
|
if ms <= 0 {
|
|
return nil
|
|
}
|
|
_, err := d.sql.Exec(`UPDATE runs SET parked_ms = COALESCE(parked_ms, 0) + ?, updated_at = ? WHERE id = ?`, ms, now(), id)
|
|
if err != nil {
|
|
return fmt.Errorf("add run parked duration: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (d *DB) CompleteRunAwaitingAgent(id string, ms int64) error {
|
|
if ms < 0 {
|
|
ms = 0
|
|
}
|
|
_, err := d.sql.Exec(
|
|
`UPDATE runs SET awaiting_agent_since = NULL, parked_ms = COALESCE(parked_ms, 0) + ?, updated_at = ? WHERE id = ?`,
|
|
ms, now(), id,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("complete run awaiting agent: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RecoverStaleRuns marks any runs stuck in pending/running status as failed
|
|
// and fails any in-progress steps. This is called at daemon startup to clean
|
|
// up after a previous crash. Returns the number of recovered runs.
|
|
func (d *DB) RecoverStaleRuns(errMsg string) (int, error) {
|
|
return d.RecoverStaleRunsExcept(errMsg, nil)
|
|
}
|
|
|
|
// RecoverStaleRunsExcept marks active runs as failed unless their IDs appear
|
|
// in preserved. Callers use preserved only after independently proving a run
|
|
// can be reconstructed safely.
|
|
func (d *DB) RecoverStaleRunsExcept(errMsg string, preserved map[string]struct{}) (int, error) {
|
|
ts := now()
|
|
|
|
tx, err := d.sql.Begin()
|
|
if err != nil {
|
|
return 0, fmt.Errorf("begin transaction: %w", err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
placeholders, args := recoveryExclusionClause(preserved)
|
|
stepArgs := []any{
|
|
types.StepStatusFailed, errMsg, ts,
|
|
types.StepStatusRunning, types.StepStatusAwaitingApproval, types.StepStatusFixing, types.StepStatusFixReview,
|
|
types.RunPending, types.RunRunning,
|
|
}
|
|
stepArgs = append(stepArgs, args...)
|
|
_, err = tx.Exec(
|
|
`UPDATE step_results SET status = ?, error = ?, completed_at = ?
|
|
WHERE status IN (?, ?, ?, ?) AND run_id IN (
|
|
SELECT id FROM runs WHERE status IN (?, ?)`+placeholders+`
|
|
)`,
|
|
stepArgs...,
|
|
)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("recover stale steps: %w", err)
|
|
}
|
|
|
|
// Fail stale runs. Clear any awaiting-agent marker so a recovered (now
|
|
// failed) run is never reported as still parked awaiting the agent,
|
|
// accumulating the marker's elapsed time into the run's parked total so
|
|
// the parked evidence survives the crash.
|
|
runArgs := []any{types.RunFailed, errMsg, ts, ts, ts, types.RunPending, types.RunRunning}
|
|
runArgs = append(runArgs, args...)
|
|
result, err := tx.Exec(
|
|
`UPDATE runs SET status = ?, error = ?,
|
|
parked_ms = COALESCE(parked_ms, 0) + CASE
|
|
WHEN awaiting_agent_since IS NOT NULL AND ? > awaiting_agent_since
|
|
THEN (? - awaiting_agent_since) * 1000 ELSE 0 END,
|
|
awaiting_agent_since = NULL, updated_at = ? WHERE status IN (?, ?)`+placeholders,
|
|
runArgs...,
|
|
)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("recover stale runs: %w", err)
|
|
}
|
|
|
|
count, err := result.RowsAffected()
|
|
if err != nil {
|
|
return 0, fmt.Errorf("rows affected: %w", err)
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return 0, fmt.Errorf("commit transaction: %w", err)
|
|
}
|
|
return int(count), nil
|
|
}
|
|
|
|
func recoveryExclusionClause(preserved map[string]struct{}) (string, []any) {
|
|
if len(preserved) == 0 {
|
|
return "", nil
|
|
}
|
|
args := make([]any, 0, len(preserved))
|
|
placeholders := make([]string, 0, len(preserved))
|
|
for id := range preserved {
|
|
placeholders = append(placeholders, "?")
|
|
args = append(args, id)
|
|
}
|
|
return " AND id NOT IN (" + strings.Join(placeholders, ", ") + ")", args
|
|
}
|