ariadne/internal/integration/iop/executor.go
2026-07-24 05:45:04 +09:00

351 lines
10 KiB
Go

package iop
import (
"context"
"errors"
"fmt"
"strings"
"time"
"git.toki-labs.com/toki/ariadne/internal/domain/errorrecord"
"git.toki-labs.com/toki/ariadne/internal/domain/failure"
"git.toki-labs.com/toki/ariadne/internal/domain/organization"
"git.toki-labs.com/toki/ariadne/internal/domain/remediation"
"github.com/google/uuid"
)
// Executor is the Ariadne-owned boundary for LLM and CLI work. Implementations
// may use IOP's native protocol without leaking that protocol into the domain.
type Executor interface {
Start(context.Context, Request) (Execution, error)
Events(
context.Context,
organization.ID,
string,
uint64,
) (EventStream, error)
Cancel(context.Context, organization.ID, string) error
}
type EventStream interface {
Receive(context.Context) (Event, error)
Close() error
}
type Intent string
const (
IntentAnalyze Intent = "analyze"
IntentApplyChange Intent = "apply_change"
)
type RepositoryAccess string
const (
RepositoryAccessReadOnly RepositoryAccess = "read_only"
RepositoryAccessIsolatedBranch RepositoryAccess = "isolated_branch"
)
const RuntimeAccessReadOnly = "read_only"
type Request struct {
ExecutionID string
OrganizationID organization.ID
ProjectID string
IngestionSourceID string
ErrorGroupID string
Intent Intent
Mode remediation.Mode
ProposalDigest string
Error errorrecord.Event
Repository RepositoryTarget
RuntimeTargets []RuntimeTarget
Policy Policy
Deadline time.Time
}
type RepositoryTarget struct {
RepositoryID string
WorkspaceReference string
BaseRevision string
}
type RuntimeTarget struct {
EnvironmentID string
ConnectionReference string
}
type Policy struct {
RepositoryAccess RepositoryAccess
RuntimeAccess string
AllowMerge bool
}
type Execution struct {
ID string
StartedAt time.Time
}
type EventType string
const (
EventStarted EventType = "started"
EventProgress EventType = "progress"
EventAnalysisResult EventType = "analysis_result"
EventChangeResult EventType = "change_result"
EventValidationResult EventType = "validation_result"
EventCompleted EventType = "completed"
EventFailed EventType = "failed"
EventCancelled EventType = "cancelled"
)
type Event struct {
ExecutionID string
Sequence uint64
Type EventType
Message string
Analysis *AnalysisResult
Change *ChangeResult
Validation *ValidationResult
Failure *failure.Failure
OccurredAt time.Time
}
type AnalysisResult struct {
Fixability remediation.Fixability
Report string
ProposalDigest string
}
type ChangeResult struct {
Changed bool
Branch string
PatchReference string
}
type ValidationResult struct {
Passed bool
Summary string
ArtifactReferences []string
}
func (request Request) Validate() error {
var validationErrors []error
if _, err := uuid.Parse(request.ExecutionID); err != nil {
validationErrors = append(validationErrors, fmt.Errorf("execution id: %w", err))
}
if err := request.OrganizationID.Validate(); err != nil {
validationErrors = append(validationErrors, fmt.Errorf("organization id: %w", err))
}
for field, value := range map[string]string{
"project id": request.ProjectID,
"ingestion source id": request.IngestionSourceID,
"error group id": request.ErrorGroupID,
"repository id": request.Repository.RepositoryID,
} {
if _, err := uuid.Parse(value); err != nil {
validationErrors = append(validationErrors, fmt.Errorf("%s: %w", field, err))
}
}
if request.Intent != IntentAnalyze && request.Intent != IntentApplyChange {
validationErrors = append(validationErrors, errors.New("execution intent is invalid"))
}
if _, err := remediation.ParseMode(string(request.Mode)); err != nil {
validationErrors = append(validationErrors, err)
}
if err := request.Error.Validate(); err != nil {
validationErrors = append(validationErrors, fmt.Errorf("error event: %w", err))
}
if request.Error.OrganizationID != request.OrganizationID ||
request.Error.ProjectID != request.ProjectID ||
request.Error.IngestionSourceID != request.IngestionSourceID {
validationErrors = append(
validationErrors,
errors.New("error event scope does not match execution scope"),
)
}
if strings.TrimSpace(request.Repository.WorkspaceReference) == "" {
validationErrors = append(
validationErrors,
errors.New("repository workspace reference is required"),
)
}
if strings.TrimSpace(request.Repository.BaseRevision) == "" {
validationErrors = append(
validationErrors,
errors.New("repository base revision is required"),
)
}
for index, target := range request.RuntimeTargets {
if _, err := uuid.Parse(target.EnvironmentID); err != nil {
validationErrors = append(
validationErrors,
fmt.Errorf("runtime target %d environment id: %w", index, err),
)
}
if strings.TrimSpace(target.ConnectionReference) == "" {
validationErrors = append(
validationErrors,
fmt.Errorf("runtime target %d connection reference is required", index),
)
}
}
if request.Policy.RuntimeAccess != RuntimeAccessReadOnly {
validationErrors = append(
validationErrors,
errors.New("runtime access must be read-only"),
)
}
if request.Policy.AllowMerge {
validationErrors = append(
validationErrors,
errors.New("IOP must not receive merge permission"),
)
}
switch request.Policy.RepositoryAccess {
case RepositoryAccessReadOnly, RepositoryAccessIsolatedBranch:
default:
validationErrors = append(
validationErrors,
errors.New("repository access policy is invalid"),
)
}
if request.Intent == IntentAnalyze &&
request.Policy.RepositoryAccess != RepositoryAccessReadOnly {
validationErrors = append(
validationErrors,
errors.New("analysis execution must use read-only repository access"),
)
}
if request.Intent == IntentApplyChange &&
request.Policy.RepositoryAccess != RepositoryAccessIsolatedBranch {
validationErrors = append(
validationErrors,
errors.New("change execution must use an isolated branch"),
)
}
switch request.Intent {
case IntentAnalyze:
if strings.TrimSpace(request.ProposalDigest) != "" {
validationErrors = append(
validationErrors,
errors.New("analysis execution must not include a proposal digest"),
)
}
case IntentApplyChange:
if strings.TrimSpace(request.ProposalDigest) == "" {
validationErrors = append(
validationErrors,
errors.New("change execution requires the authorized proposal digest"),
)
}
}
if request.Deadline.IsZero() {
validationErrors = append(validationErrors, errors.New("execution deadline is required"))
}
return errors.Join(validationErrors...)
}
func (event Event) Validate() error {
var validationErrors []error
if _, err := uuid.Parse(event.ExecutionID); err != nil {
validationErrors = append(validationErrors, fmt.Errorf("execution id: %w", err))
}
if event.OccurredAt.IsZero() {
validationErrors = append(validationErrors, errors.New("event occurrence time is required"))
}
switch event.Type {
case EventStarted, EventProgress, EventCompleted, EventCancelled:
case EventAnalysisResult:
if event.Analysis == nil {
validationErrors = append(validationErrors, errors.New("analysis result is required"))
break
}
if _, err := remediation.ParseFixability(string(event.Analysis.Fixability)); err != nil {
validationErrors = append(validationErrors, err)
}
if strings.TrimSpace(event.Analysis.Report) == "" {
validationErrors = append(validationErrors, errors.New("analysis report is required"))
}
if event.Analysis.Fixability == remediation.FixabilityFixable &&
strings.TrimSpace(event.Analysis.ProposalDigest) == "" {
validationErrors = append(
validationErrors,
errors.New("fixable analysis requires a proposal digest"),
)
}
if event.Analysis.Fixability != remediation.FixabilityFixable &&
strings.TrimSpace(event.Analysis.ProposalDigest) != "" {
validationErrors = append(
validationErrors,
errors.New("only a fixable analysis may include a proposal digest"),
)
}
case EventChangeResult:
if event.Change == nil {
validationErrors = append(validationErrors, errors.New("change result is required"))
break
}
if event.Change.Changed &&
(strings.TrimSpace(event.Change.Branch) == "" ||
strings.TrimSpace(event.Change.PatchReference) == "") {
validationErrors = append(
validationErrors,
errors.New("a changed result requires branch and patch references"),
)
}
if !event.Change.Changed &&
(strings.TrimSpace(event.Change.Branch) != "" ||
strings.TrimSpace(event.Change.PatchReference) != "") {
validationErrors = append(
validationErrors,
errors.New("an unchanged result must not include branch or patch references"),
)
}
case EventValidationResult:
if event.Validation == nil {
validationErrors = append(validationErrors, errors.New("validation result is required"))
} else if strings.TrimSpace(event.Validation.Summary) == "" {
validationErrors = append(validationErrors, errors.New("validation summary is required"))
}
case EventFailed:
if event.Failure == nil {
validationErrors = append(validationErrors, errors.New("failure is required"))
} else if err := event.Failure.Validate(); err != nil {
validationErrors = append(validationErrors, fmt.Errorf("failure: %w", err))
}
default:
validationErrors = append(validationErrors, errors.New("execution event type is invalid"))
}
payloadCount := 0
for _, present := range []bool{
event.Analysis != nil,
event.Change != nil,
event.Validation != nil,
event.Failure != nil,
} {
if present {
payloadCount++
}
}
expectedPayloadCount := 0
switch event.Type {
case EventAnalysisResult, EventChangeResult, EventValidationResult, EventFailed:
expectedPayloadCount = 1
}
if payloadCount != expectedPayloadCount {
validationErrors = append(
validationErrors,
fmt.Errorf(
"execution event payload count = %d, want %d for %q",
payloadCount,
expectedPayloadCount,
event.Type,
),
)
}
return errors.Join(validationErrors...)
}