280 lines
7.4 KiB
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")
|
|
}
|
|
}
|