Files
proxy/internal/app/operation.go
T

229 lines
7.1 KiB
Go

package app
import (
"errors"
"fmt"
)
// OperationPhase describes coordination state independently of any terminal UI.
type OperationPhase uint8
const (
OperationRunning OperationPhase = iota + 1
OperationAwaitingConfirmation
OperationSucceeded
OperationFailed
OperationBlocked
)
// StatusKind gives presentation layers semantics without requiring them to
// inspect user-facing status text.
type StatusKind uint8
const (
StatusProgress StatusKind = iota + 1
StatusSuccess
StatusError
StatusBlocked
StatusConfirmation
)
// OperationStatus is the current concise status plus optional expandable
// detail. TargetID relates a result to the Target it can explicitly resolve.
type OperationStatus struct {
Kind StatusKind
Summary string
Detail string
TargetID string
}
// Confirmation is a structured, safe-default decision. Presentation layers
// render these fields but never infer confirmation state from their wording.
type Confirmation struct {
Title string
Summary string
Details []string
ConfirmLabel string
CancelLabel string
HighImpact bool
}
// TargetProgressPhase describes one Target's part in an operation stream.
type TargetProgressPhase uint8
const (
TargetProgressRunning TargetProgressPhase = iota + 1
TargetProgressSucceeded
TargetProgressFailed
)
// OperationUpdate is the common progressive contract used by interactive
// detection, Target mutation, and Active Profile transition operations.
type OperationUpdate struct {
Snapshot Snapshot
Phase OperationPhase
Status OperationStatus
Progress []TargetProgress
Confirmation *Confirmation
FirstFailedTargetID string
Err error
}
// OperationStream exposes the useful initial frame synchronously and all
// subsequent structured changes through Updates. Updates closes after a
// terminal phase.
type OperationStream struct {
Snapshot Snapshot
Updates <-chan OperationUpdate
}
// InteractiveModule is the presentation-independent Application seam used by
// the TUI. Module.Dispatch remains available for synchronous callers.
type InteractiveModule interface {
Module
StartOperation(Intent) OperationStream
}
// StartOperation rejects overlap instead of queuing hidden interactive work.
// Synchronous Dispatch callers retain the established serialized behavior.
func (a *Application) StartOperation(intent Intent) OperationStream {
a.interactiveMu.Lock()
if a.interactiveOperation {
a.interactiveMu.Unlock()
updates := make(chan OperationUpdate, 1)
updates <- OperationUpdate{
Snapshot: a.Snapshot(),
Phase: OperationBlocked,
Status: OperationStatus{Kind: StatusBlocked,
Summary: "Another system operation is already in progress",
Detail: "Wait for the current operation to finish before starting another modification."},
}
close(updates)
return OperationStream{Snapshot: a.Snapshot(), Updates: updates}
}
a.interactiveOperation = true
a.interactiveMu.Unlock()
if intent.Kind == RefreshTargets {
return a.startDetectionOperation()
}
initial := a.Snapshot()
updates := make(chan OperationUpdate, 2*len(a.targets)+2)
go func() {
defer close(updates)
defer a.finishInteractiveOperation()
final := a.dispatch(intent, func(result Result) {
updates <- operationUpdate(result, false)
})
updates <- operationUpdate(final, true)
}()
return OperationStream{Snapshot: initial, Updates: updates}
}
func (a *Application) startDetectionOperation() OperationStream {
initial, detection := a.BeginTargetDetection()
updates := make(chan OperationUpdate, len(a.targets)+1)
go func() {
defer close(updates)
defer a.finishInteractiveOperation()
var failures []error
firstFailed := ""
for item := range detection {
if item.Complete {
update := OperationUpdate{
Snapshot: item.Snapshot,
Phase: OperationSucceeded,
Status: OperationStatus{Kind: StatusSuccess, Summary: "Target availability and state checks completed"},
}
if len(failures) > 0 {
update.Phase = OperationFailed
update.Err = errors.Join(failures...)
update.FirstFailedTargetID = firstFailed
update.Status = OperationStatus{Kind: StatusError, Summary: "Some Target checks failed", Detail: update.Err.Error(), TargetID: firstFailed}
}
updates <- update
continue
}
targetSnapshot, name := targetByID(item.Snapshot, item.TargetID)
progress := TargetProgress{TargetID: item.TargetID, State: targetSnapshot.State, Evidence: targetSnapshot.Evidence, Phase: TargetProgressSucceeded}
status := OperationStatus{Kind: StatusProgress, Summary: "Checked " + name + " Target", TargetID: item.TargetID}
if targetSnapshot.DetectionPhase == TargetDetectionFailed {
progress.Phase = TargetProgressFailed
progress.Err = targetSnapshot.DetectionErr
status = OperationStatus{Kind: StatusError, Summary: "Could not check " + name + " Target", TargetID: item.TargetID}
if targetSnapshot.DetectionErr != nil {
status.Detail = targetSnapshot.DetectionErr.Error()
failures = append(failures, fmt.Errorf("%s Target: %w", name, targetSnapshot.DetectionErr))
}
if firstFailed == "" {
firstFailed = item.TargetID
}
}
updates <- OperationUpdate{Snapshot: item.Snapshot, Phase: OperationRunning, Status: status, Progress: []TargetProgress{progress}, Err: progress.Err}
}
}()
return OperationStream{Snapshot: initial, Updates: updates}
}
func (a *Application) finishInteractiveOperation() {
a.interactiveMu.Lock()
a.interactiveOperation = false
a.interactiveMu.Unlock()
}
func targetByID(snapshot Snapshot, id string) (TargetSnapshot, string) {
for _, item := range snapshot.Targets {
if item.ID == id {
name := item.Description.Name
if name == "" {
name = id
}
return item, name
}
}
return TargetSnapshot{ID: id}, id
}
func operationUpdate(result Result, terminal bool) OperationUpdate {
update := OperationUpdate{
Snapshot: result.Snapshot,
Phase: OperationRunning,
Status: result.Status,
Progress: result.Progress,
Confirmation: result.ConfirmationRequest,
FirstFailedTargetID: result.FirstFailedTargetID,
Err: result.Err,
}
if result.ConfirmationRequest != nil {
update.Phase = OperationAwaitingConfirmation
if update.Status.Kind == 0 {
update.Status = OperationStatus{Kind: StatusConfirmation, Summary: result.ConfirmationRequest.Title, Detail: result.ConfirmationRequest.Summary}
}
return update
}
if update.Status.Kind == 0 {
switch {
case result.Err != nil:
update.Status = OperationStatus{Kind: StatusError, Summary: "Operation failed", Detail: result.Err.Error()}
case result.Notice != "":
update.Status = OperationStatus{Kind: StatusSuccess, Summary: result.Notice}
}
}
if !terminal {
return update
}
if result.Err != nil {
update.Phase = OperationFailed
if update.Status.Kind != StatusError {
update.Status = OperationStatus{Kind: StatusError, Summary: "Operation failed", Detail: result.Err.Error(), TargetID: update.Status.TargetID}
}
} else {
update.Phase = OperationSucceeded
if update.Status.Kind == StatusProgress || update.Status.Kind == 0 {
update.Status.Kind = StatusSuccess
}
}
return update
}