351 lines
10 KiB
Go
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...)
|
|
}
|