pi-agent: Implement operation coordination

This commit is contained in:
proxyctl coordinator
2026-07-17 21:46:06 +08:00
parent c4214ab8b2
commit 177bf34354
11 changed files with 1056 additions and 201 deletions
+78 -29
View File
@@ -70,19 +70,26 @@ type Snapshot struct {
type TargetProgress struct {
TargetID string
// Applying is retained for synchronous DispatchProgress callers. New
// interactive callers use the structured Phase field.
Applying bool
Phase TargetProgressPhase
State target.State
Evidence string
Err error
}
type Result struct {
Snapshot Snapshot
Notice string
Confirmation string
Progress []TargetProgress
Complete bool
Err error
Snapshot Snapshot
Notice string
// Confirmation remains for existing synchronous callers.
Confirmation string
ConfirmationRequest *Confirmation
Status OperationStatus
Progress []TargetProgress
FirstFailedTargetID string
Complete bool
Err error
}
type Module interface {
@@ -105,20 +112,22 @@ type ProgressiveDetectionModule interface {
}
type Application struct {
store profile.Store
state profile.State
targets []target.Target
targetIDs []string
targetDescriptions []target.Description
availabilities map[string]target.Availability
inspections map[string]target.Inspection
detectionPhase map[string]TargetDetectionPhase
detectionErr map[string]error
detectionGeneration uint64
stateMu sync.RWMutex
dispatchMu sync.Mutex
mutationLock MutationLock
logger OperationLogger
store profile.Store
state profile.State
targets []target.Target
targetIDs []string
targetDescriptions []target.Description
availabilities map[string]target.Availability
inspections map[string]target.Inspection
detectionPhase map[string]TargetDetectionPhase
detectionErr map[string]error
detectionGeneration uint64
stateMu sync.RWMutex
dispatchMu sync.Mutex
interactiveMu sync.Mutex
interactiveOperation bool
mutationLock MutationLock
logger OperationLogger
}
func New(store profile.Store) (*Application, error) {
@@ -290,7 +299,7 @@ func (a *Application) dispatch(intent Intent, publish func(Result)) Result {
a.refreshInspections()
}
if intent.Kind == SetTargetDesired {
return a.setTargetDesired(intent)
return a.setTargetDesired(intent, publish)
}
if intent.Kind == RefreshTargets {
a.refreshTargets()
@@ -357,7 +366,13 @@ func (a *Application) activateProfile(intent Intent, publish func(Result)) Resul
}
}
if len(warnings) > 0 && !intent.Confirm {
return Result{Snapshot: a.Snapshot(), Confirmation: "Changing the Active Profile requires confirmation:\n" + strings.Join(warnings, "\n")}
message := "Changing the Active Profile requires confirmation:\n" + strings.Join(warnings, "\n")
confirmation := &Confirmation{
Title: "Confirm Active Profile change", Summary: "Changing the Active Profile has high-impact Target effects.",
Details: warnings, ConfirmLabel: "Change Profile", CancelLabel: "Cancel", HighImpact: true,
}
return Result{Snapshot: a.Snapshot(), Confirmation: message, ConfirmationRequest: confirmation,
Status: OperationStatus{Kind: StatusConfirmation, Summary: confirmation.Title, Detail: confirmation.Summary}}
}
next, err := a.store.Mutate(func(current profile.State) (profile.State, error) {
@@ -395,7 +410,8 @@ func (a *Application) activateProfile(intent Intent, publish func(Result)) Resul
name := a.targetDescriptions[targetIndex].Name
updated[id] = true
if publish != nil {
publish(Result{Snapshot: a.Snapshot(), Progress: []TargetProgress{{TargetID: id, Applying: true, State: a.inspection(id).State}}, Notice: "Updating " + name + " Target"})
publish(Result{Snapshot: a.Snapshot(), Progress: []TargetProgress{{TargetID: id, Applying: true, Phase: TargetProgressRunning, State: a.inspection(id).State}}, Notice: "Updating " + name + " Target",
Status: OperationStatus{Kind: StatusProgress, Summary: "Updating " + name + " Target", TargetID: id}})
}
operation := adapter.SetDesired(context.Background(), true, active)
inspection := operation.Inspection
@@ -407,10 +423,18 @@ func (a *Application) activateProfile(intent Intent, publish func(Result)) Resul
safeErr = fmt.Errorf("%s (fresh inspection: %s — %s)", profile.RedactText(operation.Err.Error()), inspection.State, profile.RedactText(inspection.Evidence))
}
a.replaceInspection(id, inspection)
item := TargetProgress{TargetID: id, State: inspection.State, Evidence: inspection.Evidence, Err: safeErr}
progressPhase := TargetProgressSucceeded
if safeErr != nil {
progressPhase = TargetProgressFailed
}
item := TargetProgress{TargetID: id, Phase: progressPhase, State: inspection.State, Evidence: inspection.Evidence, Err: safeErr}
progress = append(progress, item)
if publish != nil {
publish(Result{Snapshot: a.Snapshot(), Progress: []TargetProgress{item}, Notice: name + " Target update completed", Err: safeErr})
status := OperationStatus{Kind: StatusSuccess, Summary: name + " Target update completed", TargetID: id}
if safeErr != nil {
status = OperationStatus{Kind: StatusError, Summary: name + " Target update failed", Detail: safeErr.Error(), TargetID: id}
}
publish(Result{Snapshot: a.Snapshot(), Progress: []TargetProgress{item}, Notice: name + " Target update completed", Status: status, Err: safeErr})
}
if a.logger != nil {
outcome, detail := "success", inspection.Evidence
@@ -433,10 +457,19 @@ func (a *Application) activateProfile(intent Intent, publish func(Result)) Resul
}
}
a.mergeInspections(remaining)
result := Result{Snapshot: a.Snapshot(), Progress: progress, Notice: fmt.Sprintf("Activated Profile %q", intent.Name)}
result := Result{Snapshot: a.Snapshot(), Progress: progress, Notice: fmt.Sprintf("Activated Profile %q", intent.Name),
Status: OperationStatus{Kind: StatusSuccess, Summary: fmt.Sprintf("Activated Profile %q", intent.Name)}}
if len(failures) > 0 {
result.Err = errors.Join(failures...)
result.Notice = fmt.Sprintf("Activated Profile %q with %d Target update failure(s); successful updates were kept and no global rollback was attempted", intent.Name, len(failures))
result.Status = OperationStatus{Kind: StatusError, Summary: fmt.Sprintf("Activated Profile %q with %d Target update failure(s)", intent.Name, len(failures)), Detail: result.Err.Error()}
for _, item := range progress {
if item.Err != nil {
result.FirstFailedTargetID = item.TargetID
result.Status.TargetID = item.TargetID
break
}
}
}
return result
}
@@ -530,7 +563,7 @@ func (a *Application) refreshInspections() {
a.replaceInspections(inspections)
}
func (a *Application) setTargetDesired(intent Intent) Result {
func (a *Application) setTargetDesired(intent Intent, publish func(Result)) Result {
selectedIndex := -1
for i, id := range a.targetIDs {
if id == intent.TargetID {
@@ -547,7 +580,14 @@ func (a *Application) setTargetDesired(intent Intent) Result {
return Result{Snapshot: a.Snapshot(), Err: errors.New("no Active Profile; open Profile management before enabling a Target")}
}
if warning := selected.Confirmation(intent.Enabled); warning != "" && !intent.Confirm {
return Result{Snapshot: a.Snapshot(), Confirmation: warning}
confirmation := &Confirmation{Title: "Confirm " + a.targetDescriptions[selectedIndex].Name + " Target change", Summary: warning,
ConfirmLabel: "Continue", CancelLabel: "Cancel", HighImpact: true}
return Result{Snapshot: a.Snapshot(), Confirmation: warning, ConfirmationRequest: confirmation,
Status: OperationStatus{Kind: StatusConfirmation, Summary: confirmation.Title, Detail: confirmation.Summary, TargetID: intent.TargetID}}
}
if publish != nil {
publish(Result{Snapshot: a.Snapshot(), Progress: []TargetProgress{{TargetID: intent.TargetID, Applying: true, Phase: TargetProgressRunning, State: a.inspection(intent.TargetID).State}},
Status: OperationStatus{Kind: StatusProgress, Summary: "Updating " + a.targetDescriptions[selectedIndex].Name + " Target", TargetID: intent.TargetID}})
}
result := selected.SetDesired(context.Background(), intent.Enabled, active)
inspection := result.Inspection
@@ -567,10 +607,19 @@ func (a *Application) setTargetDesired(intent Intent) Result {
_ = a.logger.Write(LogEntry{Time: time.Now(), Operation: operation, Target: intent.TargetID, Result: outcome, Detail: RedactLogDetail(detail)})
}
notice := ""
progressPhase := TargetProgressSucceeded
status := OperationStatus{Kind: StatusSuccess, TargetID: intent.TargetID}
if result.Err == nil {
notice = fmt.Sprintf("%s Target is now %s", a.targetDescriptions[selectedIndex].Name, inspection.State)
status.Summary = notice
} else {
progressPhase = TargetProgressFailed
status.Kind = StatusError
status.Summary = a.targetDescriptions[selectedIndex].Name + " Target update failed"
status.Detail = result.Err.Error()
}
return Result{Snapshot: a.Snapshot(), Notice: notice, Err: result.Err}
progress := TargetProgress{TargetID: intent.TargetID, Phase: progressPhase, State: inspection.State, Evidence: inspection.Evidence, Err: result.Err}
return Result{Snapshot: a.Snapshot(), Notice: notice, Status: status, Progress: []TargetProgress{progress}, FirstFailedTargetID: map[bool]string{true: intent.TargetID}[result.Err != nil], Err: result.Err}
}
func create(state profile.State, input profile.Profile) (profile.State, string, error) {
+228
View File
@@ -0,0 +1,228 @@
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
}
+149
View File
@@ -0,0 +1,149 @@
package app_test
import (
"context"
"errors"
"path/filepath"
"strings"
"testing"
"proxyctl/internal/app"
"proxyctl/internal/profile"
"proxyctl/internal/target"
)
type coordinatedOperationTarget struct {
id string
state target.State
started chan struct{}
release chan struct{}
confirmation string
failure error
}
func (t *coordinatedOperationTarget) ID() string { return t.id }
func (t *coordinatedOperationTarget) Description() target.Description {
return target.Description{Name: t.id, Summary: "operation stream test Target"}
}
func (t *coordinatedOperationTarget) Availability(context.Context) target.Availability {
return target.Availability{Available: true, Evidence: "available"}
}
func (t *coordinatedOperationTarget) Inspect(context.Context, *profile.Profile) target.Inspection {
return target.Inspection{State: t.state, Evidence: "fresh state"}
}
func (t *coordinatedOperationTarget) Confirmation(bool) string { return t.confirmation }
func (t *coordinatedOperationTarget) SetDesired(_ context.Context, enabled bool, _ *profile.Profile) target.OperationResult {
if t.started != nil {
close(t.started)
}
if t.release != nil {
<-t.release
}
if t.failure != nil {
t.state = target.Drifted
return target.OperationResult{Err: t.failure}
}
if enabled {
t.state = target.Enabled
} else {
t.state = target.Disabled
}
return target.OperationResult{Inspection: target.Inspection{State: t.state, Evidence: "completed"}}
}
func TestInteractiveOperationStreamSerializesMutationAndPublishesStructuredProgress(t *testing.T) {
adapter := &coordinatedOperationTarget{id: "shell", state: target.Disabled, started: make(chan struct{}), release: make(chan struct{})}
application, err := app.NewWithTargets(profile.NewFileStore(filepath.Join(t.TempDir(), "profiles.json")), []target.Target{adapter})
if err != nil {
t.Fatal(err)
}
if result := application.Dispatch(app.Intent{Kind: app.CreateProfile, Profile: profile.Profile{Name: "office", SingleURL: "http://proxy.example:8080"}}); result.Err != nil {
t.Fatal(result.Err)
}
if result := application.Dispatch(app.Intent{Kind: app.ActivateProfile, Name: "office"}); result.Err != nil {
t.Fatal(result.Err)
}
operation := application.StartOperation(app.Intent{Kind: app.SetTargetDesired, TargetID: "shell", Enabled: true})
running := <-operation.Updates
if running.Phase != app.OperationRunning || len(running.Progress) != 1 || running.Progress[0].Phase != app.TargetProgressRunning || running.Progress[0].TargetID != "shell" || running.Status.Kind != app.StatusProgress {
t.Fatalf("running update = %#v", running)
}
<-adapter.started
blocked := application.StartOperation(app.Intent{Kind: app.SetTargetDesired, TargetID: "shell", Enabled: false})
blockedUpdate := <-blocked.Updates
if blockedUpdate.Phase != app.OperationBlocked || blockedUpdate.Status.Kind != app.StatusBlocked || !strings.Contains(blockedUpdate.Status.Summary, "already in progress") {
t.Fatalf("blocked update = %#v", blockedUpdate)
}
if _, open := <-blocked.Updates; open {
t.Fatal("blocked operation stream did not close")
}
close(adapter.release)
final := <-operation.Updates
if final.Phase != app.OperationSucceeded || final.Snapshot.Targets[0].State != target.Enabled || len(final.Progress) != 1 || final.Progress[0].Phase != app.TargetProgressSucceeded {
t.Fatalf("final update = %#v", final)
}
if _, open := <-operation.Updates; open {
t.Fatal("completed operation stream did not close")
}
}
func TestInteractiveOperationStreamUsesStructuredConfirmation(t *testing.T) {
adapter := &coordinatedOperationTarget{id: "docker-daemon", state: target.Disabled, confirmation: "Docker will restart and may disrupt running work."}
application, err := app.NewWithTargets(profile.NewFileStore(filepath.Join(t.TempDir(), "profiles.json")), []target.Target{adapter})
if err != nil {
t.Fatal(err)
}
if result := application.Dispatch(app.Intent{Kind: app.CreateProfile, Profile: profile.Profile{Name: "office", SingleURL: "http://proxy.example:8080"}}); result.Err != nil {
t.Fatal(result.Err)
}
if result := application.Dispatch(app.Intent{Kind: app.ActivateProfile, Name: "office"}); result.Err != nil {
t.Fatal(result.Err)
}
operation := application.StartOperation(app.Intent{Kind: app.SetTargetDesired, TargetID: adapter.ID(), Enabled: true})
update := <-operation.Updates
if update.Phase != app.OperationAwaitingConfirmation || update.Confirmation == nil || update.Confirmation.Title == "" || update.Confirmation.CancelLabel == "" || update.Status.Kind != app.StatusConfirmation {
t.Fatalf("confirmation update = %#v", update)
}
if _, open := <-operation.Updates; open {
t.Fatal("confirmation operation stream did not close")
}
}
func TestInteractiveProfileTransitionReportsFirstFailureWithoutRollback(t *testing.T) {
first := &coordinatedOperationTarget{id: "first", state: target.Disabled}
second := &coordinatedOperationTarget{id: "second", state: target.Disabled}
application, err := app.NewWithTargets(profile.NewFileStore(filepath.Join(t.TempDir(), "profiles.json")), []target.Target{first, second})
if err != nil {
t.Fatal(err)
}
for _, name := range []string{"old", "new"} {
if result := application.Dispatch(app.Intent{Kind: app.CreateProfile, Profile: profile.Profile{Name: name, SingleURL: "http://proxy.example:8080"}}); result.Err != nil {
t.Fatal(result.Err)
}
}
if result := application.Dispatch(app.Intent{Kind: app.ActivateProfile, Name: "old"}); result.Err != nil {
t.Fatal(result.Err)
}
for _, id := range []string{"first", "second"} {
if result := application.Dispatch(app.Intent{Kind: app.SetTargetDesired, TargetID: id, Enabled: true}); result.Err != nil {
t.Fatal(result.Err)
}
}
second.failure = errors.New("second failed")
operation := application.StartOperation(app.Intent{Kind: app.ActivateProfile, Name: "new"})
var final app.OperationUpdate
for update := range operation.Updates {
final = update
}
if final.Phase != app.OperationFailed || final.FirstFailedTargetID != "second" || final.Snapshot.ActiveProfile != "new" {
t.Fatalf("final transition = %#v", final)
}
if final.Snapshot.Targets[0].State != target.Enabled || final.Snapshot.Targets[1].State != target.Drifted {
t.Fatalf("partial Target states = %#v", final.Snapshot.Targets)
}
}