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>
225 lines
5.9 KiB
Go
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)
|
|
}
|