package localcontrol import ( "context" "fmt" iop "iop/proto/gen/iop" "google.golang.org/protobuf/proto" ) // StatusSnapshot is the narrow, client-neutral projection returned by the host. // Implementations must not place private paths, credentials, or raw subprocess // output in these fields. type StatusSnapshot struct { SubjectID string State string Summary string Entries []StatusEntry } type StatusEntry struct { Kind string SubjectID string State string Summary string } type ProjectCommand struct { CommandID string ProjectID string WorkspaceID string MilestoneID string } type MutationResult struct { SubjectID string State string Summary string } // StateReader exposes only coherent host projections required by the protocol. type StateReader interface { RuntimeStatus(context.Context) (StatusSnapshot, error) ProjectStatus(context.Context, string) (StatusSnapshot, error) OverlayStatus(context.Context, string, string) (StatusSnapshot, error) IntegrationStatus(context.Context, string, string) (StatusSnapshot, error) BlockerList(context.Context, string) (StatusSnapshot, error) ProcessStatus(context.Context, string) (StatusSnapshot, error) } // ProjectController delegates lifecycle ownership to the shared runtime host. type ProjectController interface { StartProject(context.Context, ProjectCommand) (MutationResult, error) StopProject(context.Context, ProjectCommand) (MutationResult, error) ResumeProject(context.Context, ProjectCommand) (MutationResult, error) } type Service struct { reader StateReader controller ProjectController ledger *Ledger clientOps *ClientOperations } const maxStatusEntries = 256 func NewService( reader StateReader, controller ProjectController, ledger *Ledger, ) (*Service, error) { if reader == nil { return nil, fmt.Errorf("localcontrol: state reader is required") } if controller == nil { return nil, fmt.Errorf("localcontrol: project controller is required") } if ledger == nil { return nil, fmt.Errorf("localcontrol: ledger is required") } return &Service{ reader: reader, controller: controller, ledger: ledger, }, nil } func (s *Service) WithClientOperations(ops *ClientOperations) *Service { s.clientOps = ops return s } // Handle applies authorization and protocol validation before touching a read // or mutation port. Server sessions pass authorized=true only after the kernel // peer credential check succeeds. func (s *Service) 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) } replayed, view, failure := s.requestReplay(ctx, request.GetRequest()) if failure != nil { return errorEnvelope(request, failure) } switch { case isReadOperation(request.GetOperation()): return s.handleRead(ctx, request, replayed, view) case isProjectMutation(request.GetOperation()): return s.handleProjectMutation(ctx, request, replayed) case isClientMutation(request.GetOperation()): if s.clientOps != nil { return s.clientOps.Handle(ctx, authorized, request) } return errorEnvelope( request, protocolError( ErrorUnsupportedOperation, "client process operations are not enabled by this service", ), ) default: return errorEnvelope( request, protocolError(ErrorUnsupportedOperation, "local-control operation is not supported"), ) } } func (s *Service) requestReplay( ctx context.Context, request *iop.AgentLocalRequest, ) ([]*iop.AgentLocalEvent, LedgerView, *ProtocolError) { if request.ReplayAfterSequence == nil { view, failure := s.ledger.View(ctx) return nil, view, failure } return s.ledger.Replay( ctx, request.GetReplayDaemonId(), request.GetReplayAfterSequence(), ) } func (s *Service) handleRead( ctx context.Context, request *iop.AgentLocalEnvelope, replayed []*iop.AgentLocalEvent, view LedgerView, ) *iop.AgentLocalEnvelope { arguments := request.GetRequest().GetRead() var ( snapshot StatusSnapshot err error ) switch request.GetOperation() { case OperationRuntimeStatus: snapshot, err = s.reader.RuntimeStatus(ctx) case OperationProjectStatus: snapshot, err = s.reader.ProjectStatus(ctx, arguments.GetProjectId()) case OperationOverlayStatus: snapshot, err = s.reader.OverlayStatus( ctx, arguments.GetProjectId(), arguments.GetWorkUnitId(), ) case OperationIntegrationStatus: snapshot, err = s.reader.IntegrationStatus( ctx, arguments.GetProjectId(), arguments.GetWorkUnitId(), ) case OperationBlockerList: snapshot, err = s.reader.BlockerList(ctx, arguments.GetProjectId()) case OperationProcessStatus: snapshot, err = s.reader.ProcessStatus(ctx, arguments.GetClientKind()) } if err != nil { return errorEnvelope( request, protocolError(ErrorInvalidState, "requested host state is unavailable"), ) } wireSnapshot, failure := encodeStatusSnapshot(snapshot, view) if failure != nil { return errorEnvelope(request, failure) } response := &iop.AgentLocalEnvelope{ ProtocolVersion: ProtocolVersion, Kind: iop.AgentLocalKind_AGENT_LOCAL_KIND_RESPONSE, MessageId: derivedMessageID("response", request.GetMessageId()), CorrelationId: request.GetMessageId(), Operation: request.GetOperation(), Payload: &iop.AgentLocalEnvelope_Response{ Response: &iop.AgentLocalResponse{ StateRevision: view.StateRevision, SnapshotMarker: snapshotMarker( view.DaemonID, view.StateRevision, ), ReplayDaemonId: view.DaemonID, ReplayCursor: view.ReplayCursor, Payload: &iop.AgentLocalResponse_Snapshot{ Snapshot: wireSnapshot, }, ReplayEvents: cloneEvents(replayed), }, }, } if proto.Size(response) > MaxFrameBytes { return errorEnvelope( request, protocolError(ErrorInternal, "host snapshot exceeds the local-control frame limit"), ) } return response } func (s *Service) handleProjectMutation( ctx context.Context, request *iop.AgentLocalEnvelope, replayed []*iop.AgentLocalEvent, ) *iop.AgentLocalEnvelope { 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().GetProject().GetProjectId(), State: "accepted", Summary: "project command accepted", }, ) for { begin, failure := s.ledger.BeginCommand( ctx, commandID, requestHash, accepted, ) if failure != nil { return errorEnvelope(request, failure) } switch begin.ownership { case commandCompleted: return begin.response case commandWaiter: if failure := s.ledger.WaitCommand(ctx, begin.flight); failure != nil { return errorEnvelope(request, failure) } continue case commandOwner: defer s.ledger.ReleaseCommandOwner(commandID, begin.flight) default: return errorEnvelope( request, protocolError(ErrorInternal, "command ownership is invalid"), ) } break } arguments := request.GetRequest().GetProject() command := ProjectCommand{ CommandID: commandID, ProjectID: arguments.GetProjectId(), WorkspaceID: arguments.GetWorkspaceId(), MilestoneID: arguments.GetMilestoneId(), } var result MutationResult switch request.GetOperation() { case OperationProjectStart: result, err = s.controller.StartProject(ctx, command) case OperationProjectStop: result, err = s.controller.StopProject(ctx, command) case OperationProjectResume: result, err = s.controller.ResumeProject(ctx, command) } if err != nil { final, finishFailure := s.ledger.FinishCommand( ctx, commandID, requestHash, errorEnvelope( request, protocolError(ErrorInvalidState, "project command was not applied"), ), nil, ) if finishFailure != nil { return errorEnvelope(request, finishFailure) } return final } wireResult, failure := encodeMutationResult(result) if failure != nil { final, finishFailure := s.ledger.FinishCommand( ctx, commandID, requestHash, errorEnvelope(request, failure), nil, ) if finishFailure != nil { return errorEnvelope(request, finishFailure) } return final } wireResult.Accepted = true response := mutationResponse(request, replayed, wireResult) event := &iop.AgentLocalEvent{ EventType: request.GetOperation(), SubjectId: wireResult.GetSubjectId(), Mutation: wireResult, } final, finishFailure := s.ledger.FinishCommand( ctx, commandID, requestHash, response, event, ) if finishFailure != nil { return errorEnvelope(request, finishFailure) } return final } func mutationResponse( request *iop.AgentLocalEnvelope, replayed []*iop.AgentLocalEvent, result *iop.AgentLocalMutationResult, ) *iop.AgentLocalEnvelope { return &iop.AgentLocalEnvelope{ ProtocolVersion: ProtocolVersion, Kind: iop.AgentLocalKind_AGENT_LOCAL_KIND_RESPONSE, MessageId: derivedMessageID("response", request.GetMessageId()), CorrelationId: request.GetMessageId(), Operation: request.GetOperation(), Payload: &iop.AgentLocalEnvelope_Response{ Response: &iop.AgentLocalResponse{ Payload: &iop.AgentLocalResponse_Mutation{ Mutation: result, }, ReplayEvents: cloneEvents(replayed), }, }, } } func encodeStatusSnapshot( snapshot StatusSnapshot, view LedgerView, ) (*iop.AgentLocalSnapshot, *ProtocolError) { if !validOptionalIdentifier(snapshot.SubjectID) || !validOptionalHostText(snapshot.State) || !validOptionalHostText(snapshot.Summary) || len(snapshot.Entries) > maxStatusEntries { return nil, protocolError(ErrorInternal, "host snapshot contains invalid data") } entries := make([]*iop.AgentLocalStatusEntry, 0, len(snapshot.Entries)) for _, entry := range snapshot.Entries { if !validOptionalIdentifier(entry.Kind) || !validOptionalIdentifier(entry.SubjectID) || !validOptionalHostText(entry.State) || !validOptionalHostText(entry.Summary) { return nil, protocolError(ErrorInternal, "host snapshot contains invalid data") } entries = append(entries, &iop.AgentLocalStatusEntry{ Kind: entry.Kind, SubjectId: entry.SubjectID, State: entry.State, Summary: entry.Summary, }) } return &iop.AgentLocalSnapshot{ DaemonId: view.DaemonID, StateRevision: view.StateRevision, ReplayCursor: view.ReplayCursor, SubjectId: snapshot.SubjectID, State: snapshot.State, Summary: snapshot.Summary, Entries: entries, }, nil } func encodeMutationResult( result MutationResult, ) (*iop.AgentLocalMutationResult, *ProtocolError) { if !validIdentifier(result.SubjectID, maxIdentifierBytes) || !validOptionalHostText(result.State) || !validOptionalHostText(result.Summary) { return nil, protocolError(ErrorInternal, "host mutation result contains invalid data") } return &iop.AgentLocalMutationResult{ SubjectId: result.SubjectID, State: result.State, Summary: result.Summary, }, nil } func validOptionalHostText(value string) bool { return value == "" || validBoundedText(value, maxSafeTextBytes) } func cloneEvents(events []*iop.AgentLocalEvent) []*iop.AgentLocalEvent { if len(events) == 0 { return nil } clones := make([]*iop.AgentLocalEvent, 0, len(events)) for _, event := range events { if event == nil { continue } clones = append(clones, &iop.AgentLocalEvent{ EventSequence: event.GetEventSequence(), EventType: event.GetEventType(), SubjectId: event.GetSubjectId(), StateRevision: event.GetStateRevision(), Mutation: &iop.AgentLocalMutationResult{ Accepted: event.GetMutation().GetAccepted(), SubjectId: event.GetMutation().GetSubjectId(), State: event.GetMutation().GetState(), Summary: event.GetMutation().GetSummary(), }, }) } return clones }