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

303 lines
12 KiB
Go

package service
import (
"context"
"errors"
"sync/atomic"
"testing"
"time"
edgenode "iop/apps/edge/internal/node"
"iop/packages/go/config"
)
type recordingWorkspaceExecutor struct {
calls atomic.Int32
seen chan *SingleRequestBinding
}
func (e *recordingWorkspaceExecutor) ExecuteSingleRequest(_ context.Context, req SingleRequestRequest, _ SingleRequestController) error {
e.calls.Add(1)
e.seen <- req.Binding.Clone()
return nil
}
func (e *recordingWorkspaceExecutor) binding(t *testing.T) *SingleRequestBinding {
t.Helper()
select {
case binding := <-e.seen:
return binding
case <-time.After(time.Second):
t.Fatal("executor did not receive an admitted binding")
return nil
}
}
func newRecordingWorkspaceExecutor() *recordingWorkspaceExecutor {
return &recordingWorkspaceExecutor{seen: make(chan *SingleRequestBinding, 1)}
}
func workspaceDefinition(ref string, operations ...config.WorkspaceOperation) config.WorkspaceDefinition {
workspace := config.WorkspaceDefinition{
Ref: ref,
Platform: "darwin",
Root: "/Users/operator/project",
Operations: operations,
MaxReadBytes: 64,
MaxWriteBytes: 64,
MaxOutputBytes: 128,
MaxCommandTimeoutMS: 500,
}
for _, operation := range operations {
if operation == config.WorkspaceOpCommand {
workspace.Commands = []config.WorkspaceCommandDefinition{{ID: "test"}}
}
}
return workspace
}
func workspaceStore(nodeID string, workspace config.WorkspaceDefinition) *edgenode.NodeStore {
store := edgenode.NewNodeStore()
store.Add(&edgenode.NodeRecord{ID: nodeID, Alias: nodeID, Token: nodeID + "-token", Workspaces: []config.WorkspaceDefinition{workspace}})
return store
}
func readyWorkspaceService(t *testing.T, nodeID string, workspace config.WorkspaceDefinition, executor *recordingWorkspaceExecutor) (*Service, *edgenode.Registry) {
t.Helper()
registry := edgenode.NewRegistry()
registry.Register(&edgenode.NodeEntry{NodeID: nodeID, Alias: nodeID})
service := New(registry, nil)
service.SetNodeStore(workspaceStore(nodeID, workspace))
service.SetSingleRequestExecutor(executor)
return service, registry
}
func workspaceRequest(t *testing.T, ref string) SingleRequestRequest {
t.Helper()
binding := createTestBinding(t)
binding.WorkspaceRef = ref
return SingleRequestRequest{RequestID: "workspace-request", Binding: binding, Prompt: "complete task"}
}
func TestSingleRequestWorkspaceRejectsMissingRuntimeDependencies(t *testing.T) {
for name, service := range map[string]*Service{
"missing store and registry": {},
"missing store": New(edgenode.NewRegistry(), nil),
"missing registry": &Service{nodeStore: workspaceStore("node-a", workspaceDefinition("approved", config.WorkspaceOpRead))},
} {
t.Run(name, func(t *testing.T) {
executor := newRecordingWorkspaceExecutor()
service.SetSingleRequestExecutor(executor)
request := workspaceRequest(t, "approved")
_, err := service.StartSingleRequest(context.Background(), request)
if !errors.Is(err, ErrSingleRequestWorkspaceUnavailable) {
t.Fatalf("StartSingleRequest error = %v, want workspace unavailable", err)
}
if got := executor.calls.Load(); got != 0 {
t.Fatalf("executor calls = %d, want 0", got)
}
})
}
}
func TestSingleRequestWorkspaceOperationSpecificLimits(t *testing.T) {
tests := []struct {
name string
operations []config.WorkspaceOperation
limits SingleRequestWorkspaceLimits
commands []string
valid bool
}{
{"read", []config.WorkspaceOperation{config.WorkspaceOpRead}, SingleRequestWorkspaceLimits{MaxReadBytes: 1}, nil, true},
{"write", []config.WorkspaceOperation{config.WorkspaceOpWrite}, SingleRequestWorkspaceLimits{MaxWriteBytes: 1}, nil, true},
{"list", []config.WorkspaceOperation{config.WorkspaceOpList}, SingleRequestWorkspaceLimits{MaxOutputBytes: 1}, nil, true},
{"delete", []config.WorkspaceOperation{config.WorkspaceOpDelete}, SingleRequestWorkspaceLimits{}, nil, true},
{"command", []config.WorkspaceOperation{config.WorkspaceOpCommand}, SingleRequestWorkspaceLimits{MaxOutputBytes: 1, MaxCommandTimeoutMS: 1}, []string{"command"}, true},
{"read requires bound", []config.WorkspaceOperation{config.WorkspaceOpRead}, SingleRequestWorkspaceLimits{}, nil, false},
{"list requires output", []config.WorkspaceOperation{config.WorkspaceOpList}, SingleRequestWorkspaceLimits{}, nil, false},
{"command requires id", []config.WorkspaceOperation{config.WorkspaceOpCommand}, SingleRequestWorkspaceLimits{MaxOutputBytes: 1, MaxCommandTimeoutMS: 1}, nil, false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
workspace := workspaceDefinition("approved", test.operations...)
workspace.MaxReadBytes = test.limits.MaxReadBytes
workspace.MaxWriteBytes = test.limits.MaxWriteBytes
workspace.MaxOutputBytes = test.limits.MaxOutputBytes
workspace.MaxCommandTimeoutMS = test.limits.MaxCommandTimeoutMS
workspace.Commands = make([]config.WorkspaceCommandDefinition, len(test.commands))
for i, id := range test.commands {
workspace.Commands[i] = config.WorkspaceCommandDefinition{ID: id}
}
_, err := compileSingleRequestWorkspaceBinding(workspace, &edgenode.NodeEntry{NodeID: "node-a", ConnectionGeneration: 1})
if test.valid && err != nil {
t.Fatalf("compileSingleRequestWorkspaceBinding: %v", err)
}
if !test.valid && !errors.Is(err, errSingleRequestWorkspaceMalformed) {
t.Fatalf("compileSingleRequestWorkspaceBinding error = %v, want malformed", err)
}
})
}
}
func TestSingleRequestWorkspaceAdmissionMatrix(t *testing.T) {
approved := workspaceDefinition("approved", config.WorkspaceOpRead)
for _, test := range []struct {
name string
setup func(*edgenode.Registry) *edgenode.NodeStore
ref string
}{
{
name: "unapproved ref",
setup: func(registry *edgenode.Registry) *edgenode.NodeStore {
registry.Register(&edgenode.NodeEntry{NodeID: "node-a"})
return workspaceStore("node-a", approved)
},
ref: "unapproved",
},
{
name: "foreign ready node is not a fallback",
setup: func(registry *edgenode.Registry) *edgenode.NodeStore {
registry.Register(&edgenode.NodeEntry{NodeID: "node-b"})
return workspaceStore("node-a", approved)
},
ref: "approved",
},
{
name: "configured owner is pending",
setup: func(registry *edgenode.Registry) *edgenode.NodeStore {
registry.RegisterIfAbsent(&edgenode.NodeEntry{NodeID: "node-a"})
registry.Register(&edgenode.NodeEntry{NodeID: "node-b"})
return workspaceStore("node-a", approved)
},
ref: "approved",
},
{
name: "malformed empty workspace operations",
setup: func(registry *edgenode.Registry) *edgenode.NodeStore {
registry.Register(&edgenode.NodeEntry{NodeID: "node-a"})
return workspaceStore("node-a", workspaceDefinition("approved"))
},
ref: "approved",
},
{
name: "duplicate workspace operation",
setup: func(registry *edgenode.Registry) *edgenode.NodeStore {
registry.Register(&edgenode.NodeEntry{NodeID: "node-a"})
workspace := workspaceDefinition("approved", config.WorkspaceOpRead, config.WorkspaceOpRead)
return workspaceStore("node-a", workspace)
},
ref: "approved",
},
{
name: "unsupported workspace operation",
setup: func(registry *edgenode.Registry) *edgenode.NodeStore {
registry.Register(&edgenode.NodeEntry{NodeID: "node-a"})
workspace := workspaceDefinition("approved", config.WorkspaceOperation("unsupported"))
return workspaceStore("node-a", workspace)
},
ref: "approved",
},
{
name: "command without command operation",
setup: func(registry *edgenode.Registry) *edgenode.NodeStore {
registry.Register(&edgenode.NodeEntry{NodeID: "node-a"})
workspace := workspaceDefinition("approved", config.WorkspaceOpRead)
workspace.Commands = []config.WorkspaceCommandDefinition{{ID: "test"}}
return workspaceStore("node-a", workspace)
},
ref: "approved",
},
} {
t.Run(test.name, func(t *testing.T) {
registry := edgenode.NewRegistry()
executor := newRecordingWorkspaceExecutor()
service := New(registry, nil)
service.SetNodeStore(test.setup(registry))
service.SetSingleRequestExecutor(executor)
_, err := service.StartSingleRequest(context.Background(), workspaceRequest(t, test.ref))
if !errors.Is(err, ErrSingleRequestWorkspaceUnavailable) {
t.Fatalf("StartSingleRequest error = %v, want unavailable", err)
}
if got := executor.calls.Load(); got != 0 {
t.Fatalf("executor calls = %d, want 0", got)
}
})
}
}
func TestSingleRequestWorkspaceFreezesOwnerAcrossRefreshAndReconnect(t *testing.T) {
workspace := workspaceDefinition("approved", config.WorkspaceOpCommand)
executor := newRecordingWorkspaceExecutor()
service, registry := readyWorkspaceService(t, "node-a", workspace, executor)
handoff := make(chan struct{})
refreshDone := make(chan struct{})
actorDone := make(chan struct{})
service.beforeSingleRequestHandoff = func() {
close(handoff)
<-refreshDone
}
go func() {
defer close(actorDone)
<-handoff
service.SetNodeStore(workspaceStore("node-b", workspaceDefinition("approved", config.WorkspaceOpRead)))
close(refreshDone)
}()
if _, err := service.StartSingleRequest(context.Background(), workspaceRequest(t, "approved")); err != nil {
t.Fatalf("StartSingleRequest: %v", err)
}
<-actorDone
bound := executor.binding(t)
if bound.Workspace.NodeID != "node-a" || bound.Workspace.ConnectionGeneration == 0 {
t.Fatalf("executor binding = %#v, want original ready owner", bound.Workspace)
}
if len(bound.Workspace.CommandIDs) != 1 || bound.Workspace.CommandIDs[0] != "test" {
t.Fatalf("executor binding lost frozen command capability: %#v", bound.Workspace)
}
if !registry.IsCurrentOwnerGeneration("node-a", bound.Workspace.ConnectionGeneration) {
t.Fatal("refresh retargeted the admitted owner")
}
}
func TestSingleRequestWorkspaceRejectsStaleGenerationBeforeExecutor(t *testing.T) {
workspace := workspaceDefinition("approved", config.WorkspaceOpRead)
executor := newRecordingWorkspaceExecutor()
service, registry := readyWorkspaceService(t, "node-a", workspace, executor)
handoff := make(chan struct{})
reconnectDone := make(chan struct{})
actorDone := make(chan struct{})
service.beforeSingleRequestHandoff = func() {
close(handoff)
<-reconnectDone
}
go func() {
defer close(actorDone)
<-handoff
registry.Unregister("node-a")
registry.Register(&edgenode.NodeEntry{NodeID: "node-a", Alias: "node-a"})
close(reconnectDone)
}()
_, err := service.StartSingleRequest(context.Background(), workspaceRequest(t, "approved"))
<-actorDone
if !errors.Is(err, ErrSingleRequestWorkspaceStale) {
t.Fatalf("StartSingleRequest error = %v, want stale workspace", err)
}
if got := executor.calls.Load(); got != 0 {
t.Fatalf("executor calls = %d, want 0", got)
}
}
func TestSingleRequestWorkspaceAppliesPresetMinima(t *testing.T) {
workspace := workspaceDefinition("approved", config.WorkspaceOpCommand)
preset := createTestBinding(t).Limits
workspace.MaxOutputBytes = preset.MaxOutputBytes + 1
workspace.MaxCommandTimeoutMS = preset.StageTimeoutMS + 1
executor := newRecordingWorkspaceExecutor()
service, _ := readyWorkspaceService(t, "node-a", workspace, executor)
if _, err := service.StartSingleRequest(context.Background(), workspaceRequest(t, "approved")); err != nil {
t.Fatalf("StartSingleRequest: %v", err)
}
bound := executor.binding(t)
if bound.Workspace.Limits.MaxOutputBytes != preset.MaxOutputBytes || bound.Workspace.Limits.MaxCommandTimeoutMS != preset.StageTimeoutMS {
t.Fatalf("effective limits = %#v, want preset minima", bound.Workspace.Limits)
}
}