Files
2026-06-22 08:55:57 +08:00

382 lines
11 KiB
Go

package orchestrator
import (
"context"
"encoding/json"
"errors"
"time"
"github.com/google/uuid"
"github.com/jackc/pgerrcode"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
orchsqlc "github.com/yan1h/agent-coding-workflow/internal/orchestrator/sqlc"
"github.com/yan1h/agent-coding-workflow/internal/project"
)
// Repository 是 orchestrator 模块对 PG 的全部依赖。Service 单测通过手写 fakeRepo 满足。
type Repository interface {
// Run
InsertRun(ctx context.Context, r *Run) (*Run, error)
GetRun(ctx context.Context, id uuid.UUID) (*Run, error)
GetActiveRunByRequirement(ctx context.Context, requirementID uuid.UUID) (*Run, error)
ListRuns(ctx context.Context, f RunFilter) ([]*Run, error)
UpdateRunStatus(ctx context.Context, id uuid.UUID, status RunStatus, lastErr *string) (*Run, error)
UpdateRunPhase(ctx context.Context, id uuid.UUID, phase project.Phase) (*Run, error)
// Step
InsertStep(ctx context.Context, s *Step) (*Step, error)
GetStep(ctx context.Context, id uuid.UUID) (*Step, error)
GetStepBySession(ctx context.Context, sessionID uuid.UUID) (*Step, error)
ListStepsByRun(ctx context.Context, runID uuid.UUID) ([]*Step, error)
UpdateStepStatus(ctx context.Context, s *Step) (*Step, error)
IncrementStepAttempt(ctx context.Context, id uuid.UUID) (*Step, error)
CountActiveChildSteps(ctx context.Context, parentStepID uuid.UUID) (int64, error)
ResetStuckStepsOnRestart(ctx context.Context) ([]*Step, error)
InTx(ctx context.Context, fn func(ctx context.Context, tx pgx.Tx) error) error
WithTx(tx pgx.Tx) Repository
}
// IsUniqueViolation 报告 err 是否为 PG 唯一约束冲突(用于一 requirement 一活跃 run 的 409 翻译)。
func IsUniqueViolation(err error) bool {
var pg *pgconn.PgError
return errors.As(err, &pg) && pg.Code == pgerrcode.UniqueViolation
}
type pgRepo struct {
pool *pgxpool.Pool
q *orchsqlc.Queries
}
// NewPostgresRepository 用 pgxpool 构造 Repository。
func NewPostgresRepository(pool *pgxpool.Pool) Repository {
return &pgRepo{pool: pool, q: orchsqlc.New(pool)}
}
// InTx 在单事务内执行 fn。nil error commit,否则 rollback。StartRun 用它原子地
// 插入 run + 第一个 step。
func (r *pgRepo) InTx(ctx context.Context, fn func(ctx context.Context, tx pgx.Tx) error) error {
if r.pool == nil {
return errs.New(errs.CodeInternal, "orchestrator.Repository.InTx: nil pool (tx-bound instance)")
}
return pgx.BeginFunc(ctx, r.pool, func(tx pgx.Tx) error {
return fn(ctx, tx)
})
}
// WithTx 返回绑定到指定事务的 Repository 副本。
func (r *pgRepo) WithTx(tx pgx.Tx) Repository {
return &pgRepo{pool: nil, q: r.q.WithTx(tx)}
}
// ===== 类型转换 helper =====
func toPgUUID(u uuid.UUID) pgtype.UUID { return pgtype.UUID{Bytes: u, Valid: true} }
func toPgUUIDPtr(u *uuid.UUID) pgtype.UUID {
if u == nil {
return pgtype.UUID{}
}
return pgtype.UUID{Bytes: *u, Valid: true}
}
func fromPgUUID(p pgtype.UUID) uuid.UUID {
if !p.Valid {
return uuid.Nil
}
return uuid.UUID(p.Bytes)
}
func fromPgUUIDPtr(p pgtype.UUID) *uuid.UUID {
if !p.Valid {
return nil
}
u := uuid.UUID(p.Bytes)
return &u
}
func timePtr(p pgtype.Timestamptz) *time.Time {
if !p.Valid {
return nil
}
t := p.Time
return &t
}
func marshalConfig(c RunConfig) ([]byte, error) {
b, err := json.Marshal(c)
if err != nil {
return nil, errs.Wrap(err, errs.CodeInternal, "marshal run config")
}
return b, nil
}
func unmarshalConfig(b []byte) RunConfig {
var c RunConfig
if len(b) > 0 {
_ = json.Unmarshal(b, &c)
}
return c
}
func marshalGate(g *GateResult) ([]byte, error) {
if g == nil {
return nil, nil
}
b, err := json.Marshal(g)
if err != nil {
return nil, errs.Wrap(err, errs.CodeInternal, "marshal gate result")
}
return b, nil
}
func unmarshalGate(b []byte) *GateResult {
if len(b) == 0 {
return nil
}
var g GateResult
if err := json.Unmarshal(b, &g); err != nil {
return nil
}
return &g
}
// ===== Run =====
func (r *pgRepo) InsertRun(ctx context.Context, run *Run) (*Run, error) {
cfg, err := marshalConfig(run.Config)
if err != nil {
return nil, err
}
row, err := r.q.InsertRun(ctx, orchsqlc.InsertRunParams{
ID: toPgUUID(run.ID),
ProjectID: toPgUUID(run.ProjectID),
RequirementID: toPgUUID(run.RequirementID),
WorkspaceID: toPgUUID(run.WorkspaceID),
OwnerID: toPgUUID(run.OwnerID),
Status: string(run.Status),
CurrentPhase: string(run.CurrentPhase),
Config: cfg,
LastError: run.LastError,
})
if err != nil {
return nil, errs.Wrap(err, errs.CodeInternal, "insert run")
}
return rowToRun(row), nil
}
func (r *pgRepo) GetRun(ctx context.Context, id uuid.UUID) (*Run, error) {
row, err := r.q.GetRun(ctx, toPgUUID(id))
if err != nil {
return nil, pgxNoRowsToNotFound(err, "orchestrator run not found")
}
return rowToRun(row), nil
}
func (r *pgRepo) GetActiveRunByRequirement(ctx context.Context, requirementID uuid.UUID) (*Run, error) {
row, err := r.q.GetActiveRunByRequirement(ctx, toPgUUID(requirementID))
if err != nil {
return nil, pgxNoRowsToNotFound(err, "no active orchestrator run for requirement")
}
return rowToRun(row), nil
}
func (r *pgRepo) ListRuns(ctx context.Context, f RunFilter) ([]*Run, error) {
limit := int32(f.Limit)
if limit <= 0 {
limit = 100
}
rows, err := r.q.ListRuns(ctx, orchsqlc.ListRunsParams{
Column1: toPgUUIDPtr(f.ProjectID),
Column2: toPgUUIDPtr(f.RequirementID),
Column3: string(f.Status),
Limit: limit,
})
if err != nil {
return nil, errs.Wrap(err, errs.CodeInternal, "list runs")
}
out := make([]*Run, 0, len(rows))
for _, row := range rows {
out = append(out, rowToRun(row))
}
return out, nil
}
func (r *pgRepo) UpdateRunStatus(ctx context.Context, id uuid.UUID, status RunStatus, lastErr *string) (*Run, error) {
row, err := r.q.UpdateRunStatus(ctx, orchsqlc.UpdateRunStatusParams{
ID: toPgUUID(id),
Status: string(status),
LastError: lastErr,
})
if err != nil {
return nil, pgxNoRowsToNotFound(err, "orchestrator run not found")
}
return rowToRun(row), nil
}
func (r *pgRepo) UpdateRunPhase(ctx context.Context, id uuid.UUID, phase project.Phase) (*Run, error) {
row, err := r.q.UpdateRunPhase(ctx, orchsqlc.UpdateRunPhaseParams{
ID: toPgUUID(id),
CurrentPhase: string(phase),
})
if err != nil {
return nil, pgxNoRowsToNotFound(err, "orchestrator run not found")
}
return rowToRun(row), nil
}
// ===== Step =====
func (r *pgRepo) InsertStep(ctx context.Context, s *Step) (*Step, error) {
gate, err := marshalGate(s.GateResult)
if err != nil {
return nil, err
}
row, err := r.q.InsertStep(ctx, orchsqlc.InsertStepParams{
ID: toPgUUID(s.ID),
RunID: toPgUUID(s.RunID),
Phase: string(s.Phase),
Attempt: int32(s.Attempt),
ParentStepID: toPgUUIDPtr(s.ParentStepID),
Depth: int32(s.Depth),
Status: string(s.Status),
AgentKindID: toPgUUID(s.AgentKindID),
AcpSessionID: toPgUUIDPtr(s.ACPSessionID),
Prompt: s.Prompt,
StopReason: s.StopReason,
GateResult: gate,
LastError: s.LastError,
JobID: toPgUUIDPtr(s.JobID),
})
if err != nil {
return nil, errs.Wrap(err, errs.CodeInternal, "insert step")
}
return rowToStep(row), nil
}
func (r *pgRepo) GetStep(ctx context.Context, id uuid.UUID) (*Step, error) {
row, err := r.q.GetStep(ctx, toPgUUID(id))
if err != nil {
return nil, pgxNoRowsToNotFound(err, "orchestrator step not found")
}
return rowToStep(row), nil
}
func (r *pgRepo) GetStepBySession(ctx context.Context, sessionID uuid.UUID) (*Step, error) {
row, err := r.q.GetStepBySession(ctx, toPgUUID(sessionID))
if err != nil {
return nil, pgxNoRowsToNotFound(err, "orchestrator step not found for session")
}
return rowToStep(row), nil
}
func (r *pgRepo) ListStepsByRun(ctx context.Context, runID uuid.UUID) ([]*Step, error) {
rows, err := r.q.ListStepsByRun(ctx, toPgUUID(runID))
if err != nil {
return nil, errs.Wrap(err, errs.CodeInternal, "list steps by run")
}
out := make([]*Step, 0, len(rows))
for _, row := range rows {
out = append(out, rowToStep(row))
}
return out, nil
}
func (r *pgRepo) UpdateStepStatus(ctx context.Context, s *Step) (*Step, error) {
gate, err := marshalGate(s.GateResult)
if err != nil {
return nil, err
}
row, err := r.q.UpdateStepStatus(ctx, orchsqlc.UpdateStepStatusParams{
ID: toPgUUID(s.ID),
Status: string(s.Status),
AcpSessionID: toPgUUIDPtr(s.ACPSessionID),
StopReason: s.StopReason,
GateResult: gate,
LastError: s.LastError,
JobID: toPgUUIDPtr(s.JobID),
})
if err != nil {
return nil, pgxNoRowsToNotFound(err, "orchestrator step not found")
}
return rowToStep(row), nil
}
func (r *pgRepo) IncrementStepAttempt(ctx context.Context, id uuid.UUID) (*Step, error) {
row, err := r.q.IncrementStepAttempt(ctx, toPgUUID(id))
if err != nil {
return nil, pgxNoRowsToNotFound(err, "orchestrator step not found")
}
return rowToStep(row), nil
}
func (r *pgRepo) CountActiveChildSteps(ctx context.Context, parentStepID uuid.UUID) (int64, error) {
n, err := r.q.CountActiveChildSteps(ctx, toPgUUIDPtr(&parentStepID))
if err != nil {
return 0, errs.Wrap(err, errs.CodeInternal, "count active child steps")
}
return n, nil
}
func (r *pgRepo) ResetStuckStepsOnRestart(ctx context.Context) ([]*Step, error) {
rows, err := r.q.ResetStuckStepsOnRestart(ctx)
if err != nil {
return nil, errs.Wrap(err, errs.CodeInternal, "reset stuck steps on restart")
}
out := make([]*Step, 0, len(rows))
for _, row := range rows {
out = append(out, rowToStep(row))
}
return out, nil
}
// ===== row → domain =====
func rowToRun(row orchsqlc.OrchestratorRun) *Run {
return &Run{
ID: fromPgUUID(row.ID),
ProjectID: fromPgUUID(row.ProjectID),
RequirementID: fromPgUUID(row.RequirementID),
WorkspaceID: fromPgUUID(row.WorkspaceID),
OwnerID: fromPgUUID(row.OwnerID),
Status: RunStatus(row.Status),
CurrentPhase: project.Phase(row.CurrentPhase),
Config: unmarshalConfig(row.Config),
LastError: row.LastError,
CreatedAt: row.CreatedAt.Time,
UpdatedAt: row.UpdatedAt.Time,
EndedAt: timePtr(row.EndedAt),
}
}
func rowToStep(row orchsqlc.OrchestratorStep) *Step {
return &Step{
ID: fromPgUUID(row.ID),
RunID: fromPgUUID(row.RunID),
Phase: project.Phase(row.Phase),
Attempt: int(row.Attempt),
ParentStepID: fromPgUUIDPtr(row.ParentStepID),
Depth: int(row.Depth),
Status: StepStatus(row.Status),
AgentKindID: fromPgUUID(row.AgentKindID),
ACPSessionID: fromPgUUIDPtr(row.AcpSessionID),
Prompt: row.Prompt,
StopReason: row.StopReason,
GateResult: unmarshalGate(row.GateResult),
LastError: row.LastError,
JobID: fromPgUUIDPtr(row.JobID),
CreatedAt: row.CreatedAt.Time,
UpdatedAt: row.UpdatedAt.Time,
EndedAt: timePtr(row.EndedAt),
}
}
func pgxNoRowsToNotFound(err error, msg string) error {
if errors.Is(err, pgx.ErrNoRows) {
return errs.New(errs.CodeNotFound, msg)
}
return errs.Wrap(err, errs.CodeInternal, msg)
}