Files

275 lines
9.5 KiB
Go

package app
import (
"context"
"time"
)
const operationTimeout = 5 * time.Minute
// 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
)
// OperationStatus is concise status text related to an optional Target.
type OperationStatus struct {
Kind StatusKind
Summary 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
// Token binds acceptance to the exact state and operation scope that was
// presented. Presentation layers must return it with the confirmed Intent.
Token string
}
// TargetProgressPhase describes one Target's part in an operation stream.
type TargetProgressPhase uint8
const (
TargetProgressRunning TargetProgressPhase = iota + 1
TargetProgressSucceeded
TargetProgressFailed
)
// OperationState is a closed tagged set. Its concrete value makes an update
// exactly one of running, confirmation, succeeded, failed, or closed.
type OperationState interface {
operationState()
decode() DecodedOperationUpdate
}
// OperationUpdateKind is the decoded variant of one operation stream update.
// Consumers decode once and handle the payload without repeating type switches.
type OperationUpdateKind uint8
const (
OperationRunning OperationUpdateKind = iota + 1
OperationConfirmation
OperationSucceeded
OperationFailed
OperationClosed
)
// DecodedOperationUpdate is the complete behavior-bearing projection of an
// OperationUpdate variant. Fields that do not apply to Kind retain zero values.
type DecodedOperationUpdate struct {
Kind OperationUpdateKind
Status OperationStatus
Progress []TargetProgress
Confirmation *Confirmation
Err error
FirstFailedTargetID string
}
func (u DecodedOperationUpdate) IsTerminal() bool {
return u.Kind != OperationRunning
}
type operationRunning struct {
status OperationStatus
progress []TargetProgress
}
func (operationRunning) operationState() {}
func (state operationRunning) decode() DecodedOperationUpdate {
return DecodedOperationUpdate{Kind: OperationRunning, Status: state.status, Progress: cloneProgress(state.progress)}
}
type operationConfirmation struct{ request Confirmation }
func (operationConfirmation) operationState() {}
func (state operationConfirmation) decode() DecodedOperationUpdate {
request := state.request
request.Details = append([]string(nil), request.Details...)
return DecodedOperationUpdate{Kind: OperationConfirmation, Confirmation: &request}
}
type operationSucceeded struct {
status OperationStatus
progress []TargetProgress
}
func (operationSucceeded) operationState() {}
func (state operationSucceeded) decode() DecodedOperationUpdate {
return DecodedOperationUpdate{Kind: OperationSucceeded, Status: state.status, Progress: cloneProgress(state.progress)}
}
type operationFailed struct {
status OperationStatus
progress []TargetProgress
firstFailedTargetID string
err error
}
func (operationFailed) operationState() {}
func (state operationFailed) decode() DecodedOperationUpdate {
return DecodedOperationUpdate{
Kind: OperationFailed, Status: state.status, Progress: cloneProgress(state.progress),
Err: state.err, FirstFailedTargetID: state.firstFailedTargetID,
}
}
type operationClosed struct{ status OperationStatus }
func (operationClosed) operationState() {}
func (state operationClosed) decode() DecodedOperationUpdate {
return DecodedOperationUpdate{Kind: OperationClosed, Status: state.status}
}
// OperationUpdate is the common progressive contract used by interactive
// detection, Target mutation, and Active Profile transition operations. Build
// values with the constructors below so contradictory states are unrepresentable.
type OperationUpdate struct {
Snapshot Snapshot
State OperationState
}
func RunningOperationUpdate(snapshot Snapshot, status OperationStatus, progress []TargetProgress) OperationUpdate {
status.Kind = StatusProgress
return OperationUpdate{Snapshot: snapshot, State: operationRunning{status: status, progress: cloneProgress(progress)}}
}
func ConfirmationOperationUpdate(snapshot Snapshot, request Confirmation) OperationUpdate {
return OperationUpdate{Snapshot: snapshot, State: operationConfirmation{request: request}}
}
func SucceededOperationUpdate(snapshot Snapshot, status OperationStatus, progress []TargetProgress) OperationUpdate {
status.Kind = StatusSuccess
return OperationUpdate{Snapshot: snapshot, State: operationSucceeded{status: status, progress: cloneProgress(progress)}}
}
func FailedOperationUpdate(snapshot Snapshot, status OperationStatus, progress []TargetProgress, firstFailedTargetID string, err error) OperationUpdate {
status.Kind = StatusError
return OperationUpdate{Snapshot: snapshot, State: operationFailed{
status: status, progress: cloneProgress(progress), firstFailedTargetID: firstFailedTargetID, err: err,
}}
}
// ClosedOperationUpdate reports a request that cannot start, such as an
// overlapping interactive operation. It is terminal and has no progress or
// error payload.
func ClosedOperationUpdate(snapshot Snapshot, status OperationStatus) OperationUpdate {
status.Kind = StatusBlocked
return OperationUpdate{Snapshot: snapshot, State: operationClosed{status: status}}
}
func cloneProgress(progress []TargetProgress) []TargetProgress {
return append([]TargetProgress(nil), progress...)
}
// Decode centralizes the closed-set state decoding for all consumers.
func (u OperationUpdate) Decode() DecodedOperationUpdate {
if u.State == nil {
return DecodedOperationUpdate{}
}
return u.State.decode()
}
func (u OperationUpdate) Status() OperationStatus { return u.Decode().Status }
func (u OperationUpdate) Progress() []TargetProgress { return u.Decode().Progress }
func (u OperationUpdate) Confirmation() *Confirmation { return u.Decode().Confirmation }
func (u OperationUpdate) Err() error { return u.Decode().Err }
func (u OperationUpdate) FirstFailedTargetID() string { return u.Decode().FirstFailedTargetID }
func (u OperationUpdate) IsRunning() bool { return u.Decode().Kind == OperationRunning }
func (u OperationUpdate) IsConfirmation() bool { return u.Decode().Kind == OperationConfirmation }
func (u OperationUpdate) IsSucceeded() bool { return u.Decode().Kind == OperationSucceeded }
func (u OperationUpdate) IsFailed() bool { return u.Decode().Kind == OperationFailed }
func (u OperationUpdate) IsTerminal() bool { return u.Decode().IsTerminal() }
// OperationStream exposes the useful initial frame synchronously and all
// subsequent structured changes through Updates. Updates closes after a
// terminal update.
type OperationStream struct {
Snapshot Snapshot
Updates <-chan OperationUpdate
Cancel context.CancelFunc
}
// 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 returns before dispatch serialization is acquired. This keeps
// Bubble Tea Update non-blocking even when a synchronous caller currently owns
// the serialized Application entry.
func (a *Application) StartOperation(intent Intent) OperationStream {
initial := a.Snapshot()
a.interactiveMu.Lock()
if a.interactiveOperation {
a.interactiveMu.Unlock()
updates := make(chan OperationUpdate, 1)
updates <- ClosedOperationUpdate(initial, OperationStatus{
Summary: "Another system operation is already in progress",
})
close(updates)
return OperationStream{Snapshot: initial, Updates: updates}
}
a.interactiveOperation = true
a.interactiveMu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), operationTimeout)
updates := make(chan OperationUpdate, 2*len(a.targets)+2)
go func() {
defer close(updates)
defer cancel()
final := a.dispatch(ctx, intent, func(result Result) {
updates <- runningOperationUpdate(result)
})
// Release the Application gate before publishing the terminal update so
// an immediate confirmation retry cannot observe a stale busy state.
a.finishInteractiveOperation()
updates <- terminalOperationUpdate(final)
}()
return OperationStream{Snapshot: initial, Updates: updates, Cancel: cancel}
}
func (a *Application) finishInteractiveOperation() {
a.interactiveMu.Lock()
a.interactiveOperation = false
a.interactiveMu.Unlock()
}
func runningOperationUpdate(result Result) OperationUpdate {
if result.ConfirmationRequest != nil {
return ConfirmationOperationUpdate(result.Snapshot, *result.ConfirmationRequest)
}
return RunningOperationUpdate(result.Snapshot, result.Status, result.Progress)
}
func terminalOperationUpdate(result Result) OperationUpdate {
if result.ConfirmationRequest != nil {
return ConfirmationOperationUpdate(result.Snapshot, *result.ConfirmationRequest)
}
status := result.Status
if result.Err != nil {
if status.Summary == "" {
status.Summary = "Operation failed"
}
return FailedOperationUpdate(result.Snapshot, status, result.Progress, result.FirstFailedTargetID, result.Err)
}
if status.Summary == "" {
status.Summary = result.Notice
}
return SucceededOperationUpdate(result.Snapshot, status, result.Progress)
}