rdev/internal/service/operation_service.go
jordan c280a92012 feat: add operations audit system and template improvements
Operations Audit (new feature):
- Add Operation domain model with status tracking (pending, running, completed, failed, cancelled)
- Add OperationRepository with PostgreSQL implementation
- Add OperationService for CRUD and lifecycle management
- Add operations handlers (list, get, cancel endpoints)
- Add migration 015_operations.sql for operations table
- Add operation cleanup worker for stale operation handling
- Add ErrOperationNotFound to domain errors

Template Improvements:
- Add CLAUDE.md configuration files to astro-landing, default, and go-api templates
- Fix PORT template variable usage in nginx configs for app templates
- Add replace directives for local pkg module in Go templates
- Simplify Go service/worker Dockerfiles for workspace builds
- Fix TypeScript error in logger template

Other:
- Refactor landing-test.sh cookbook script
- Update CLAUDE.md version reference

Note: Some files exceed 500-line limit (pre-existing debt + new feature)
- component.go: 550 lines (unchanged, pre-existing)
- main.go: 522 lines (added operations wiring)
- operation_repo.go: 569 lines (new, needs splitting)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 19:08:57 -07:00

225 lines
5.9 KiB
Go

// Package service provides business logic services.
package service
import (
"context"
"log/slog"
"time"
"github.com/google/uuid"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/port"
)
// OperationService provides business logic for tracking operations.
// It wraps the repository with convenient methods for step-by-step tracking.
type OperationService struct {
repo port.OperationRepository
logger *slog.Logger
}
// NewOperationService creates a new operation service.
func NewOperationService(repo port.OperationRepository, logger *slog.Logger) *OperationService {
if logger == nil {
logger = slog.Default()
}
return &OperationService{
repo: repo,
logger: logger.With("service", "operation"),
}
}
// StartOperation creates a new operation and returns its ID.
// The operation starts in "running" status.
func (s *OperationService) StartOperation(
ctx context.Context,
projectID string,
opType domain.OperationType,
input map[string]any,
requestID string,
) (string, error) {
op := &domain.Operation{
ID: uuid.New().String(),
ProjectID: projectID,
Type: opType,
Status: domain.OperationStatusRunning,
RequestID: requestID,
StartedAt: time.Now(),
Input: input,
Steps: []domain.OperationStep{},
}
if err := s.repo.Create(ctx, op); err != nil {
s.logger.Error("failed to create operation",
"error", err,
"project_id", projectID,
"type", opType,
)
return "", err
}
s.logger.Info("operation started",
"operation_id", op.ID,
"project_id", projectID,
"type", opType,
)
return op.ID, nil
}
// StartStep adds a new step to an operation and marks it as running.
func (s *OperationService) StartStep(ctx context.Context, operationID, stepName string) error {
step := domain.OperationStep{
Name: stepName,
Status: domain.OperationStatusRunning,
StartedAt: time.Now(),
}
if err := s.repo.AddStep(ctx, operationID, step); err != nil {
s.logger.Error("failed to start step",
"error", err,
"operation_id", operationID,
"step", stepName,
)
return err
}
return nil
}
// CompleteStep marks a step as completed with optional output.
func (s *OperationService) CompleteStep(
ctx context.Context,
operationID, stepName string,
startedAt time.Time,
output map[string]any,
) error {
step := domain.OperationStep{
Name: stepName,
Status: domain.OperationStatusCompleted,
StartedAt: startedAt,
DurationMs: time.Since(startedAt).Milliseconds(),
Output: output,
}
if err := s.repo.UpdateStep(ctx, operationID, step); err != nil {
s.logger.Error("failed to complete step",
"error", err,
"operation_id", operationID,
"step", stepName,
)
return err
}
return nil
}
// FailStep marks a step as failed with error details.
func (s *OperationService) FailStep(
ctx context.Context,
operationID, stepName string,
startedAt time.Time,
errMsg, errDetail string,
) error {
step := domain.OperationStep{
Name: stepName,
Status: domain.OperationStatusFailed,
StartedAt: startedAt,
DurationMs: time.Since(startedAt).Milliseconds(),
Error: errMsg,
ErrorDetail: domain.TruncateErrorDetail(errDetail),
}
if err := s.repo.UpdateStep(ctx, operationID, step); err != nil {
s.logger.Error("failed to fail step",
"error", err,
"operation_id", operationID,
"step", stepName,
)
return err
}
return nil
}
// CompleteOperation marks the operation as completed with optional output.
func (s *OperationService) CompleteOperation(
ctx context.Context,
operationID string,
output map[string]any,
) error {
if err := s.repo.Complete(ctx, operationID, domain.OperationStatusCompleted, output, "", ""); err != nil {
s.logger.Error("failed to complete operation",
"error", err,
"operation_id", operationID,
)
return err
}
s.logger.Info("operation completed",
"operation_id", operationID,
)
return nil
}
// FailOperation marks the operation as failed with error details.
func (s *OperationService) FailOperation(
ctx context.Context,
operationID string,
errMsg, errDetail string,
) error {
if err := s.repo.Complete(ctx, operationID, domain.OperationStatusFailed, nil, errMsg, errDetail); err != nil {
s.logger.Error("failed to fail operation",
"error", err,
"operation_id", operationID,
)
return err
}
s.logger.Info("operation failed",
"operation_id", operationID,
"error", errMsg,
)
return nil
}
// SetCommitSHA updates the commit SHA for an operation.
// Called after a git commit is created as part of the operation.
func (s *OperationService) SetCommitSHA(ctx context.Context, operationID, sha string) error {
return s.repo.SetCommitSHA(ctx, operationID, sha)
}
// SetExternalRef updates the external reference for an operation.
// Called when linking to external systems like Woodpecker builds.
func (s *OperationService) SetExternalRef(ctx context.Context, operationID, ref string) error {
op, err := s.repo.Get(ctx, operationID)
if err != nil {
return err
}
op.ExternalRef = ref
return s.repo.Update(ctx, op)
}
// FindByCommit finds the operation that created a specific commit.
// Used to link builds to the operation that triggered them.
func (s *OperationService) FindByCommit(ctx context.Context, projectID, sha string) (*domain.Operation, error) {
return s.repo.GetByCommitSHA(ctx, projectID, sha)
}
// Get retrieves an operation by ID.
func (s *OperationService) Get(ctx context.Context, operationID string) (*domain.Operation, error) {
return s.repo.Get(ctx, operationID)
}
// List returns operations matching the filter criteria.
func (s *OperationService) List(ctx context.Context, filter domain.OperationFilters) ([]*domain.Operation, error) {
return s.repo.List(ctx, filter)
}
// LinkToParent sets the triggered_by field to link to a parent operation.
func (s *OperationService) LinkToParent(ctx context.Context, operationID, parentID string) error {
return s.repo.SetTriggeredBy(ctx, operationID, parentID)
}