Major changes: - Add internal/logging package with field constants, context propagation, sensitive data auto-redaction, and per-component log levels - Add worker timeout constants (TimeoutQuickOp, TimeoutHealthCheck, etc.) - Extend SDLC with callback handlers, generate endpoints, and executor - Add new cookbook trees for aeries and slackpath progression - Add skeleton templates for queue, realtime, and microservices - Add worker component template with async job processing - Refactor services and handlers to use new logging infrastructure - Split component.go into component_infra.go and component_listing.go Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
453 lines
15 KiB
Go
453 lines
15 KiB
Go
// Package sdlc provides SDLC-related adapters.
|
|
package sdlc
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"github.com/orchard9/rdev/internal/domain"
|
|
"github.com/orchard9/rdev/internal/port"
|
|
"github.com/orchard9/rdev/internal/sdlc"
|
|
)
|
|
|
|
// WorkerSDLCExecutor implements port.SDLCExecutor by routing commands through
|
|
// the worker pool. Used for skeleton/monorepo projects that don't have a
|
|
// dedicated pod.
|
|
type WorkerSDLCExecutor struct {
|
|
workQueue port.WorkQueue
|
|
db *sql.DB
|
|
timeout time.Duration
|
|
logger *slog.Logger
|
|
}
|
|
|
|
// WorkerSDLCExecutorConfig configures the worker SDLC executor.
|
|
type WorkerSDLCExecutorConfig struct {
|
|
// WorkQueue for enqueueing SDLC tasks.
|
|
WorkQueue port.WorkQueue
|
|
|
|
// DB for fetching project git clone URLs.
|
|
DB *sql.DB
|
|
|
|
// Timeout is the maximum wait time for task completion (default: 2 minutes).
|
|
Timeout time.Duration
|
|
|
|
Logger *slog.Logger
|
|
}
|
|
|
|
// NewWorkerSDLCExecutor creates a new worker-based SDLC executor.
|
|
func NewWorkerSDLCExecutor(cfg WorkerSDLCExecutorConfig) *WorkerSDLCExecutor {
|
|
timeout := cfg.Timeout
|
|
if timeout == 0 {
|
|
timeout = 2 * time.Minute
|
|
}
|
|
logger := cfg.Logger
|
|
if logger == nil {
|
|
logger = slog.Default()
|
|
}
|
|
return &WorkerSDLCExecutor{
|
|
workQueue: cfg.WorkQueue,
|
|
db: cfg.DB,
|
|
timeout: timeout,
|
|
logger: logger.With("component", "worker-sdlc-executor"),
|
|
}
|
|
}
|
|
|
|
// getGitCloneURL fetches the HTTP clone URL for a project from the database.
|
|
func (e *WorkerSDLCExecutor) getGitCloneURL(ctx context.Context, projectID string) (string, error) {
|
|
if e.db == nil {
|
|
return "", fmt.Errorf("database not configured for worker SDLC executor")
|
|
}
|
|
var gitCloneHTTP sql.NullString
|
|
err := e.db.QueryRowContext(ctx,
|
|
`SELECT git_clone_http FROM projects WHERE id = $1`,
|
|
projectID,
|
|
).Scan(&gitCloneHTTP)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return "", domain.ErrProjectNotFound
|
|
}
|
|
return "", fmt.Errorf("failed to get project git URL: %w", err)
|
|
}
|
|
if !gitCloneHTTP.Valid || gitCloneHTTP.String == "" {
|
|
return "", fmt.Errorf("project %s has no git clone URL configured", projectID)
|
|
}
|
|
return gitCloneHTTP.String, nil
|
|
}
|
|
|
|
// enqueueAndWait enqueues an SDLC task and waits for completion.
|
|
func (e *WorkerSDLCExecutor) enqueueAndWait(ctx context.Context, projectID string, spec domain.SDLCTaskSpec) (string, error) {
|
|
specMap := map[string]any{
|
|
"command": spec.Command,
|
|
"args": spec.Args,
|
|
"git_clone_url": spec.GitCloneURL,
|
|
"auto_commit": spec.AutoCommit,
|
|
"auto_push": spec.AutoPush,
|
|
}
|
|
|
|
task := &domain.WorkTask{
|
|
ProjectID: projectID,
|
|
Type: domain.WorkTaskTypeSDLC,
|
|
Spec: specMap,
|
|
MaxRetries: 1,
|
|
}
|
|
|
|
taskID, err := e.workQueue.Enqueue(ctx, task)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to enqueue SDLC task: %w", err)
|
|
}
|
|
|
|
e.logger.Info("enqueued SDLC task",
|
|
"task_id", taskID,
|
|
"project_id", projectID,
|
|
"command", spec.Command,
|
|
)
|
|
|
|
return e.waitForCompletion(ctx, taskID)
|
|
}
|
|
|
|
// waitForCompletion polls the work queue until the task completes or times out.
|
|
func (e *WorkerSDLCExecutor) waitForCompletion(ctx context.Context, taskID string) (string, error) {
|
|
deadline := time.Now().Add(e.timeout)
|
|
ticker := time.NewTicker(500 * time.Millisecond)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return "", ctx.Err()
|
|
case <-ticker.C:
|
|
if time.Now().After(deadline) {
|
|
return "", fmt.Errorf("timeout waiting for SDLC task %s", taskID)
|
|
}
|
|
|
|
task, err := e.workQueue.GetTask(ctx, taskID)
|
|
if err != nil {
|
|
continue // Task might not exist yet
|
|
}
|
|
|
|
switch task.Status {
|
|
case domain.WorkTaskStatusCompleted:
|
|
if task.Result != nil {
|
|
return task.Result.Output, nil
|
|
}
|
|
return "", nil
|
|
case domain.WorkTaskStatusFailed:
|
|
return "", fmt.Errorf("SDLC task failed: %s", task.Error)
|
|
case domain.WorkTaskStatusCancelled:
|
|
return "", fmt.Errorf("SDLC task was cancelled")
|
|
}
|
|
// Still pending or running, continue polling
|
|
}
|
|
}
|
|
}
|
|
|
|
// executeCommand is a helper that builds and executes an SDLC command.
|
|
func (e *WorkerSDLCExecutor) executeCommand(ctx context.Context, projectID, command string, args ...string) (string, error) {
|
|
gitURL, err := e.getGitCloneURL(ctx, projectID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
spec := domain.SDLCTaskSpec{
|
|
Command: command,
|
|
Args: args,
|
|
GitCloneURL: gitURL,
|
|
AutoCommit: true,
|
|
AutoPush: true,
|
|
}
|
|
|
|
return e.enqueueAndWait(ctx, projectID, spec)
|
|
}
|
|
|
|
// executeReadOnlyCommand executes an SDLC command that doesn't modify state.
|
|
func (e *WorkerSDLCExecutor) executeReadOnlyCommand(ctx context.Context, projectID, command string, args ...string) (string, error) {
|
|
gitURL, err := e.getGitCloneURL(ctx, projectID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
spec := domain.SDLCTaskSpec{
|
|
Command: command,
|
|
Args: args,
|
|
GitCloneURL: gitURL,
|
|
AutoCommit: false,
|
|
AutoPush: false,
|
|
}
|
|
|
|
return e.enqueueAndWait(ctx, projectID, spec)
|
|
}
|
|
|
|
// GetState returns the global SDLC state for a project.
|
|
func (e *WorkerSDLCExecutor) GetState(ctx context.Context, projectID string) (*sdlc.State, error) {
|
|
output, err := e.executeReadOnlyCommand(ctx, projectID, "state")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var state sdlc.State
|
|
if err := json.Unmarshal([]byte(output), &state); err != nil {
|
|
return nil, fmt.Errorf("parse sdlc state: %w", err)
|
|
}
|
|
return &state, nil
|
|
}
|
|
|
|
// GetNext returns the classifier's recommendation for the next action.
|
|
func (e *WorkerSDLCExecutor) GetNext(ctx context.Context, projectID, feature string) (*sdlc.Classification, error) {
|
|
args := []string{}
|
|
if feature != "" {
|
|
args = append(args, "--feature", feature)
|
|
}
|
|
output, err := e.executeReadOnlyCommand(ctx, projectID, "next", args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var cl sdlc.Classification
|
|
if err := json.Unmarshal([]byte(output), &cl); err != nil {
|
|
return nil, fmt.Errorf("parse sdlc classification: %w", err)
|
|
}
|
|
return &cl, nil
|
|
}
|
|
|
|
// ListFeatures returns all features in the project.
|
|
func (e *WorkerSDLCExecutor) ListFeatures(ctx context.Context, projectID string) ([]*sdlc.Feature, error) {
|
|
output, err := e.executeReadOnlyCommand(ctx, projectID, "feature", "list")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var features []*sdlc.Feature
|
|
if err := json.Unmarshal([]byte(output), &features); err != nil {
|
|
return nil, fmt.Errorf("parse sdlc features: %w", err)
|
|
}
|
|
return features, nil
|
|
}
|
|
|
|
// GetFeature returns a single feature by slug.
|
|
func (e *WorkerSDLCExecutor) GetFeature(ctx context.Context, projectID, slug string) (*sdlc.Feature, error) {
|
|
output, err := e.executeReadOnlyCommand(ctx, projectID, "feature", "show", slug)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var f sdlc.Feature
|
|
if err := json.Unmarshal([]byte(output), &f); err != nil {
|
|
return nil, fmt.Errorf("parse sdlc feature: %w", err)
|
|
}
|
|
return &f, nil
|
|
}
|
|
|
|
// CreateFeature creates a new feature with the given slug and title.
|
|
func (e *WorkerSDLCExecutor) CreateFeature(ctx context.Context, projectID, slug, title string) (*sdlc.Feature, error) {
|
|
output, err := e.executeCommand(ctx, projectID, "feature", "create", slug, "--title", title)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var f sdlc.Feature
|
|
if err := json.Unmarshal([]byte(output), &f); err != nil {
|
|
return nil, fmt.Errorf("parse sdlc feature: %w", err)
|
|
}
|
|
return &f, nil
|
|
}
|
|
|
|
// TransitionFeature moves a feature to the specified phase.
|
|
func (e *WorkerSDLCExecutor) TransitionFeature(ctx context.Context, projectID, slug string, phase sdlc.FeaturePhase) error {
|
|
_, err := e.executeCommand(ctx, projectID, "feature", "transition", slug, string(phase))
|
|
return err
|
|
}
|
|
|
|
// BlockFeature adds a blocker reason to a feature.
|
|
func (e *WorkerSDLCExecutor) BlockFeature(ctx context.Context, projectID, slug, reason string) error {
|
|
_, err := e.executeCommand(ctx, projectID, "feature", "block", slug, "--reason", reason)
|
|
return err
|
|
}
|
|
|
|
// UnblockFeature removes all blockers from a feature.
|
|
func (e *WorkerSDLCExecutor) UnblockFeature(ctx context.Context, projectID, slug string) error {
|
|
_, err := e.executeCommand(ctx, projectID, "feature", "unblock", slug)
|
|
return err
|
|
}
|
|
|
|
// DeleteFeature removes a feature entirely.
|
|
func (e *WorkerSDLCExecutor) DeleteFeature(ctx context.Context, projectID, slug string) error {
|
|
_, err := e.executeCommand(ctx, projectID, "feature", "delete", slug, "--force")
|
|
return err
|
|
}
|
|
|
|
// GetArtifactStatus returns artifact statuses for a feature.
|
|
func (e *WorkerSDLCExecutor) GetArtifactStatus(ctx context.Context, projectID, slug string) (map[sdlc.ArtifactType]*sdlc.Artifact, error) {
|
|
output, err := e.executeReadOnlyCommand(ctx, projectID, "artifact", "status", slug)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var artifacts map[sdlc.ArtifactType]*sdlc.Artifact
|
|
if err := json.Unmarshal([]byte(output), &artifacts); err != nil {
|
|
return nil, fmt.Errorf("parse sdlc artifacts: %w", err)
|
|
}
|
|
return artifacts, nil
|
|
}
|
|
|
|
// ApproveArtifact approves a feature artifact.
|
|
func (e *WorkerSDLCExecutor) ApproveArtifact(ctx context.Context, projectID, slug string, artType sdlc.ArtifactType) error {
|
|
_, err := e.executeCommand(ctx, projectID, "artifact", "approve", slug, string(artType))
|
|
return err
|
|
}
|
|
|
|
// RejectArtifact rejects a feature artifact.
|
|
func (e *WorkerSDLCExecutor) RejectArtifact(ctx context.Context, projectID, slug string, artType sdlc.ArtifactType) error {
|
|
_, err := e.executeCommand(ctx, projectID, "artifact", "reject", slug, string(artType))
|
|
return err
|
|
}
|
|
|
|
// PassArtifact marks a feature artifact as passed.
|
|
func (e *WorkerSDLCExecutor) PassArtifact(ctx context.Context, projectID, slug string, artType sdlc.ArtifactType) error {
|
|
_, err := e.executeCommand(ctx, projectID, "artifact", "pass", slug, string(artType))
|
|
return err
|
|
}
|
|
|
|
// FailArtifact marks a feature artifact as failed.
|
|
func (e *WorkerSDLCExecutor) FailArtifact(ctx context.Context, projectID, slug string, artType sdlc.ArtifactType) error {
|
|
_, err := e.executeCommand(ctx, projectID, "artifact", "fail", slug, string(artType))
|
|
return err
|
|
}
|
|
|
|
// NeedsFixArtifact marks a feature artifact as needing fixes.
|
|
func (e *WorkerSDLCExecutor) NeedsFixArtifact(ctx context.Context, projectID, slug string, artType sdlc.ArtifactType) error {
|
|
_, err := e.executeCommand(ctx, projectID, "artifact", "needs-fix", slug, string(artType))
|
|
return err
|
|
}
|
|
|
|
// ListTasks returns all tasks for a feature.
|
|
func (e *WorkerSDLCExecutor) ListTasks(ctx context.Context, projectID, slug string) ([]sdlc.Task, error) {
|
|
output, err := e.executeReadOnlyCommand(ctx, projectID, "task", "list", slug)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var tasks []sdlc.Task
|
|
if err := json.Unmarshal([]byte(output), &tasks); err != nil {
|
|
return nil, fmt.Errorf("parse sdlc tasks: %w", err)
|
|
}
|
|
return tasks, nil
|
|
}
|
|
|
|
// AddTask adds a new task to a feature.
|
|
func (e *WorkerSDLCExecutor) AddTask(ctx context.Context, projectID, slug, title string) (*sdlc.Task, error) {
|
|
output, err := e.executeCommand(ctx, projectID, "task", "add", slug, "--title", title)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var t sdlc.Task
|
|
if err := json.Unmarshal([]byte(output), &t); err != nil {
|
|
return nil, fmt.Errorf("parse sdlc task: %w", err)
|
|
}
|
|
return &t, nil
|
|
}
|
|
|
|
// StartTask marks a task as in-progress.
|
|
func (e *WorkerSDLCExecutor) StartTask(ctx context.Context, projectID, slug, taskID string) error {
|
|
_, err := e.executeCommand(ctx, projectID, "task", "start", slug, taskID)
|
|
return err
|
|
}
|
|
|
|
// CompleteTask marks a task as complete.
|
|
func (e *WorkerSDLCExecutor) CompleteTask(ctx context.Context, projectID, slug, taskID string) error {
|
|
_, err := e.executeCommand(ctx, projectID, "task", "complete", slug, taskID)
|
|
return err
|
|
}
|
|
|
|
// BlockTask marks a task as blocked.
|
|
func (e *WorkerSDLCExecutor) BlockTask(ctx context.Context, projectID, slug, taskID string) error {
|
|
_, err := e.executeCommand(ctx, projectID, "task", "block", slug, taskID)
|
|
return err
|
|
}
|
|
|
|
// QueryBlocked returns all blocked features.
|
|
func (e *WorkerSDLCExecutor) QueryBlocked(ctx context.Context, projectID string) ([]port.BlockedInfo, error) {
|
|
output, err := e.executeReadOnlyCommand(ctx, projectID, "query", "blocked")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var blocked []port.BlockedInfo
|
|
if err := json.Unmarshal([]byte(output), &blocked); err != nil {
|
|
return nil, fmt.Errorf("parse sdlc blocked query: %w", err)
|
|
}
|
|
return blocked, nil
|
|
}
|
|
|
|
// QueryReady returns features ready for work.
|
|
func (e *WorkerSDLCExecutor) QueryReady(ctx context.Context, projectID string) ([]port.ReadyInfo, error) {
|
|
output, err := e.executeReadOnlyCommand(ctx, projectID, "query", "ready")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var ready []port.ReadyInfo
|
|
if err := json.Unmarshal([]byte(output), &ready); err != nil {
|
|
return nil, fmt.Errorf("parse sdlc ready query: %w", err)
|
|
}
|
|
return ready, nil
|
|
}
|
|
|
|
// QueryNeedsApproval returns features awaiting approval.
|
|
func (e *WorkerSDLCExecutor) QueryNeedsApproval(ctx context.Context, projectID string) ([]port.ApprovalInfo, error) {
|
|
output, err := e.executeReadOnlyCommand(ctx, projectID, "query", "needs-approval")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var pending []port.ApprovalInfo
|
|
if err := json.Unmarshal([]byte(output), &pending); err != nil {
|
|
return nil, fmt.Errorf("parse sdlc approval query: %w", err)
|
|
}
|
|
return pending, nil
|
|
}
|
|
|
|
// CreateBranch creates a feature branch and its manifest.
|
|
func (e *WorkerSDLCExecutor) CreateBranch(ctx context.Context, projectID, slug string) (*sdlc.BranchManifest, error) {
|
|
output, err := e.executeCommand(ctx, projectID, "branch", "create", slug)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var manifest sdlc.BranchManifest
|
|
if err := json.Unmarshal([]byte(output), &manifest); err != nil {
|
|
return nil, fmt.Errorf("parse sdlc branch manifest: %w", err)
|
|
}
|
|
return &manifest, nil
|
|
}
|
|
|
|
// GetBranchStatus returns the full branch status including checklist.
|
|
func (e *WorkerSDLCExecutor) GetBranchStatus(ctx context.Context, projectID, slug string) (*port.BranchStatus, error) {
|
|
output, err := e.executeReadOnlyCommand(ctx, projectID, "branch", "status", slug)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var result port.BranchStatus
|
|
if err := json.Unmarshal([]byte(output), &result); err != nil {
|
|
return nil, fmt.Errorf("parse sdlc branch status: %w", err)
|
|
}
|
|
return &result, nil
|
|
}
|
|
|
|
// SyncBranch syncs a feature branch with its base branch.
|
|
func (e *WorkerSDLCExecutor) SyncBranch(ctx context.Context, projectID, slug string) error {
|
|
_, err := e.executeCommand(ctx, projectID, "branch", "sync", slug)
|
|
return err
|
|
}
|
|
|
|
// MergeFeature merges a feature branch after all gates pass.
|
|
func (e *WorkerSDLCExecutor) MergeFeature(ctx context.Context, projectID, slug, strategy string) error {
|
|
args := []string{slug}
|
|
if strategy != "" {
|
|
args = append(args, "--strategy", strategy)
|
|
}
|
|
_, err := e.executeCommand(ctx, projectID, "merge", args...)
|
|
return err
|
|
}
|
|
|
|
// ArchiveFeature archives a released feature.
|
|
func (e *WorkerSDLCExecutor) ArchiveFeature(ctx context.Context, projectID, slug string) error {
|
|
_, err := e.executeCommand(ctx, projectID, "archive", slug)
|
|
return err
|
|
}
|
|
|
|
// Compile-time interface check.
|
|
var _ port.SDLCExecutor = (*WorkerSDLCExecutor)(nil)
|