iop/apps/edge/internal/service/single_request_workspace.go
toki dc9a9a8c59 feat(agent): 단일 요청 Agent 실행 경계를 구현한다
승인된 execution preset을 Edge 조정 경계와 Node workspace/tool 실행 경계로 연결해 단일 요청 수명주기와 관측 계약을 일관되게 처리한다.
2026-08-07 07:03:55 +09:00

127 lines
4.9 KiB
Go

package service
import (
"errors"
"fmt"
"sort"
edgenode "iop/apps/edge/internal/node"
"iop/packages/go/config"
)
var (
ErrSingleRequestWorkspaceUnavailable = errors.New("single-request workspace: approved workspace is unavailable")
ErrSingleRequestWorkspaceStale = errors.New("single-request workspace: ready connection changed before executor handoff")
)
// bindSingleRequestWorkspace compiles the caller's already-authorized opaque
// workspace ref into a request-stable capability projection. It deliberately
// resolves only the configured catalog owner and then only that exact node id's
// ready owner; aliases, caller Node/path input, and implicit registry selection
// are not admission paths.
func bindSingleRequestWorkspace(binding *SingleRequestBinding, store *edgenode.NodeStore, registry *edgenode.Registry) (*SingleRequestBinding, error) {
base, err := cloneValidatedSingleRequestBinding(binding)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrSingleRequestInvalidBinding, err)
}
if store == nil || registry == nil {
return nil, ErrSingleRequestWorkspaceUnavailable
}
owner, workspace, err := store.ResolveWorkspace(base.WorkspaceRef)
if err != nil || owner == nil || owner.ID == "" || workspace.Ref != base.WorkspaceRef {
return nil, fmt.Errorf("%w: workspace ref %q", ErrSingleRequestWorkspaceUnavailable, base.WorkspaceRef)
}
ready, ok := registry.ReadyOwnerSnapshot(owner.ID)
if !ok || ready == nil || ready.NodeID != owner.ID || ready.ConnectionGeneration == 0 {
return nil, fmt.Errorf("%w: node %q is not ready", ErrSingleRequestWorkspaceUnavailable, owner.ID)
}
workspaceBinding, err := compileSingleRequestWorkspaceBinding(workspace, ready)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrSingleRequestWorkspaceUnavailable, err)
}
base.Workspace = workspaceBinding
if err := applySingleRequestWorkspaceEffectiveLimits(base); err != nil {
return nil, fmt.Errorf("%w: %v", ErrSingleRequestWorkspaceUnavailable, err)
}
return base, nil
}
func compileSingleRequestWorkspaceBinding(workspace config.WorkspaceDefinition, ready *edgenode.NodeEntry) (*SingleRequestWorkspaceBinding, error) {
if ready == nil || workspace.Ref == "" || ready.NodeID == "" || ready.ConnectionGeneration == 0 {
return nil, errSingleRequestWorkspaceMalformed
}
operations := make([]string, 0, len(workspace.Operations))
commandEnabled := false
for _, operation := range workspace.Operations {
switch operation {
case config.WorkspaceOpRead, config.WorkspaceOpList, config.WorkspaceOpWrite, config.WorkspaceOpDelete, config.WorkspaceOpCommand:
operations = append(operations, string(operation))
commandEnabled = commandEnabled || operation == config.WorkspaceOpCommand
default:
return nil, errSingleRequestWorkspaceMalformed
}
}
if len(operations) == 0 || (len(workspace.Commands) > 0 && !commandEnabled) {
return nil, errSingleRequestWorkspaceMalformed
}
sort.Strings(operations)
if !sortedUniqueNonEmpty(operations) {
return nil, errSingleRequestWorkspaceMalformed
}
commandIDs := make([]string, 0, len(workspace.Commands))
for _, command := range workspace.Commands {
if command.ID == "" {
return nil, errSingleRequestWorkspaceMalformed
}
commandIDs = append(commandIDs, command.ID)
}
sort.Strings(commandIDs)
if !sortedUniqueNonEmpty(commandIDs) {
return nil, errSingleRequestWorkspaceMalformed
}
environmentNames := append([]string(nil), workspace.EnvironmentAllowlist...)
sort.Strings(environmentNames)
if !sortedUniqueNonEmpty(environmentNames) {
return nil, errSingleRequestWorkspaceMalformed
}
for _, name := range environmentNames {
if !validEnvironmentName(name) {
return nil, errSingleRequestWorkspaceMalformed
}
}
limits := SingleRequestWorkspaceLimits{
MaxReadBytes: workspace.MaxReadBytes,
MaxWriteBytes: workspace.MaxWriteBytes,
MaxOutputBytes: workspace.MaxOutputBytes,
MaxCommandTimeoutMS: workspace.MaxCommandTimeoutMS,
}
if err := validateSingleRequestWorkspaceCapabilities(operations, commandIDs, limits); err != nil {
return nil, errSingleRequestWorkspaceMalformed
}
return &SingleRequestWorkspaceBinding{
Ref: workspace.Ref,
NodeID: ready.NodeID,
ConnectionGeneration: ready.ConnectionGeneration,
OperationIDs: operations,
CommandIDs: commandIDs,
EnvironmentNames: environmentNames,
Limits: limits,
}, nil
}
func applySingleRequestWorkspaceEffectiveLimits(binding *SingleRequestBinding) error {
if binding == nil || binding.Workspace == nil {
return errSingleRequestWorkspaceMalformed
}
if binding.Limits.MaxOutputBytes < binding.Workspace.Limits.MaxOutputBytes {
binding.Workspace.Limits.MaxOutputBytes = binding.Limits.MaxOutputBytes
}
if binding.Limits.StageTimeoutMS < binding.Workspace.Limits.MaxCommandTimeoutMS {
binding.Workspace.Limits.MaxCommandTimeoutMS = binding.Limits.StageTimeoutMS
}
_, err := validateAndCloneWorkspaceBinding(binding.Workspace)
return err
}