iop/apps/agent/internal/localcontrol/client_operations.go

280 lines
7.4 KiB
Go

package localcontrol
import (
"context"
"errors"
"fmt"
"iop/apps/agent/internal/clientprocess"
iop "iop/proto/gen/iop"
)
// ClientProcessController is the daemon-owned process boundary consumed by
// local control. It deliberately exposes no executable, environment, or raw
// subprocess output.
type ClientProcessController interface {
Start(
context.Context,
clientprocess.ClientKind,
string,
) (clientprocess.Result, error)
Stop(
context.Context,
clientprocess.ClientKind,
string,
) (clientprocess.Result, error)
Focus(
context.Context,
clientprocess.ClientKind,
string,
) (clientprocess.Result, error)
StartOrFocusFlutter(context.Context, string) (clientprocess.Result, error)
}
// ClientOperations applies the S11 authorization, request validation, replay,
// and durable command-id contract before calling the S15 process owner.
type ClientOperations struct {
controller ClientProcessController
ledger *Ledger
}
func NewClientOperations(
controller ClientProcessController,
ledger *Ledger,
) (*ClientOperations, error) {
if controller == nil {
return nil, fmt.Errorf("localcontrol: client process controller is required")
}
if ledger == nil {
return nil, fmt.Errorf("localcontrol: client command ledger is required")
}
return &ClientOperations{controller: controller, ledger: ledger}, nil
}
func (operations *ClientOperations) Handle(
ctx context.Context,
authorized bool,
request *iop.AgentLocalEnvelope,
) *iop.AgentLocalEnvelope {
if !authorized {
return errorEnvelope(
request,
protocolError(ErrorPermissionDenied, "local-control peer is not authorized"),
)
}
if failure := ValidateRequest(request); failure != nil {
return errorEnvelope(request, failure)
}
if !isClientMutation(request.GetOperation()) {
return errorEnvelope(
request,
protocolError(
ErrorUnsupportedOperation,
"local-control operation is not a client mutation",
),
)
}
if failure := validateClientOperation(request); failure != nil {
return errorEnvelope(request, failure)
}
replayed, _, failure := operations.requestReplay(ctx, request.GetRequest())
if failure != nil {
return errorEnvelope(request, failure)
}
commandID := request.GetRequest().GetCommandId()
requestHash, err := immutableCommandHash(
request.GetOperation(),
request.GetRequest(),
)
if err != nil {
return errorEnvelope(
request,
protocolError(ErrorInternal, "command arguments could not be validated"),
)
}
accepted := mutationResponse(
request,
replayed,
&iop.AgentLocalMutationResult{
Accepted: true,
SubjectId: request.GetRequest().GetClient().GetClientKind(),
State: "accepted",
Summary: "client command accepted",
},
)
for {
begin, beginFailure := operations.ledger.BeginCommand(
ctx,
commandID,
requestHash,
accepted,
)
if beginFailure != nil {
return errorEnvelope(request, beginFailure)
}
switch begin.ownership {
case commandCompleted:
return begin.response
case commandWaiter:
if waitFailure := operations.ledger.WaitCommand(
ctx,
begin.flight,
); waitFailure != nil {
return errorEnvelope(request, waitFailure)
}
continue
case commandOwner:
defer operations.ledger.ReleaseCommandOwner(
commandID,
begin.flight,
)
default:
return errorEnvelope(
request,
protocolError(ErrorInternal, "command ownership is invalid"),
)
}
break
}
result, err := operations.dispatch(ctx, request)
if err != nil {
return operations.finish(
ctx,
request,
requestHash,
errorEnvelope(
request,
clientOperationError(err),
),
nil,
)
}
wireResult := &iop.AgentLocalMutationResult{
Accepted: true,
SubjectId: string(result.Record.Kind),
State: string(result.Record.State),
Summary: result.Action,
}
response := mutationResponse(request, replayed, wireResult)
event := &iop.AgentLocalEvent{
EventType: request.GetOperation(),
SubjectId: wireResult.GetSubjectId(),
Mutation: wireResult,
}
return operations.finish(ctx, request, requestHash, response, event)
}
func (operations *ClientOperations) requestReplay(
ctx context.Context,
request *iop.AgentLocalRequest,
) ([]*iop.AgentLocalEvent, LedgerView, *ProtocolError) {
if request.ReplayAfterSequence == nil {
view, failure := operations.ledger.View(ctx)
return nil, view, failure
}
return operations.ledger.Replay(
ctx,
request.GetReplayDaemonId(),
request.GetReplayAfterSequence(),
)
}
func (operations *ClientOperations) dispatch(
ctx context.Context,
request *iop.AgentLocalEnvelope,
) (clientprocess.Result, error) {
commandID := request.GetRequest().GetCommandId()
kind := clientprocess.ClientKind(
request.GetRequest().GetClient().GetClientKind(),
)
switch request.GetOperation() {
case OperationClientStart:
return operations.controller.Start(ctx, kind, commandID)
case OperationClientStop:
return operations.controller.Stop(ctx, kind, commandID)
case OperationClientFocus:
return operations.controller.Focus(ctx, kind, commandID)
case OperationClientDetail:
return operations.controller.StartOrFocusFlutter(ctx, commandID)
default:
return clientprocess.Result{}, clientprocess.ErrUnknownClient
}
}
func (operations *ClientOperations) finish(
ctx context.Context,
request *iop.AgentLocalEnvelope,
requestHash string,
response *iop.AgentLocalEnvelope,
event *iop.AgentLocalEvent,
) *iop.AgentLocalEnvelope {
final, failure := operations.ledger.FinishCommand(
ctx,
request.GetRequest().GetCommandId(),
requestHash,
response,
event,
)
if failure != nil {
return errorEnvelope(request, failure)
}
return final
}
func validateClientOperation(
request *iop.AgentLocalEnvelope,
) *ProtocolError {
arguments := request.GetRequest().GetClient()
kind := arguments.GetClientKind()
capability := arguments.GetCapability()
switch request.GetOperation() {
case OperationClientStart, OperationClientStop:
if kind != string(clientprocess.ClientFlutter) &&
kind != string(clientprocess.ClientUnity) {
return malformed("client kind must be flutter or unity")
}
if capability != "" {
return malformed("client start and stop do not accept a capability")
}
case OperationClientFocus:
if kind != string(clientprocess.ClientFlutter) || capability != "" {
return malformed("client focus is supported only for Flutter")
}
case OperationClientDetail:
if kind != string(clientprocess.ClientUnity) || capability == "" {
return malformed(
"client detail requires a Unity capability routed through Flutter",
)
}
}
return nil
}
func clientOperationError(err error) *ProtocolError {
switch {
case errors.Is(err, clientprocess.ErrUnknownClient),
errors.Is(err, clientprocess.ErrClientNotConfigured),
errors.Is(err, clientprocess.ErrClientNotRunning),
errors.Is(err, clientprocess.ErrFocusUnsupported),
errors.Is(err, clientprocess.ErrReceiptCapacityReached):
return protocolError(ErrorInvalidState, "client command is not available")
case errors.Is(err, clientprocess.ErrCommandActionMismatch):
return protocolError(
ErrorCommandIDConflict,
"command_id was already used with a different client action",
)
case errors.Is(err, clientprocess.ErrIdentityAmbiguous),
errors.Is(err, clientprocess.ErrStateConflict),
errors.Is(err, clientprocess.ErrCommandPending):
failure := protocolError(
ErrorInvalidState,
"client process identity requires reconciliation",
)
failure.Retryable = true
return failure
default:
return protocolError(ErrorInternal, "client command could not be applied")
}
}