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

189 lines
6.4 KiB
Go

package workspace
import (
"errors"
"fmt"
"strings"
"sync"
"testing"
"go.uber.org/zap"
"go.uber.org/zap/zaptest/observer"
iop "iop/proto/gen/iop"
)
func TestWorkspaceObservation(t *testing.T) {
root := t.TempDir()
core, logs := observer.New(zap.InfoLevel)
runtime, err := NewRuntime([]*iop.WorkspaceConfig{testWorkspaceConfig(root)}, "darwin", zap.New(core))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = runtime.Close() })
if _, err := runtime.Open(testRequestAuthority("request-observation")); err != nil {
t.Fatal(err)
}
const sentinel = "SECRET_PATH_COMMAND_OUTPUT_BEARER"
if result := runtime.Write("request-observation", sentinel, []byte(sentinel)); result.Status != iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS {
t.Fatalf("write result = %+v", result)
}
cleanup := runtime.Cleanup(t.Context(), "request-observation")
if cleanup.Status != iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS {
t.Fatalf("cleanup result = %+v", cleanup)
}
allowed := map[string]bool{
"correlation": true, "event": true, "operation": true, "outcome": true, "error_code": true,
"duration_ms": true, "truncated": true, "process_count": true, "artifact_count": true,
}
entries := logs.All()
if len(entries) != 2 {
t.Fatalf("workspace logs = %d, want 2", len(entries))
}
for _, entry := range entries {
if entry.Message != workspaceObservationLogKey {
t.Fatalf("log message = %q", entry.Message)
}
if strings.Contains(strings.ToLower(fmt.Sprint(entry.ContextMap()["correlation"])), "secret") {
t.Fatalf("secret correlation leaked: %+v", entry)
}
if len(entry.Context) != len(allowed) {
t.Fatalf("log field count = %d, want %d", len(entry.Context), len(allowed))
}
for _, field := range entry.Context {
if !allowed[field.Key] {
t.Fatalf("unexpected log key %q", field.Key)
}
if strings.Contains(strings.ToLower(field.String), "secret") {
t.Fatalf("secret sentinel leaked in %q", field.Key)
}
}
}
}
func TestWorkspaceObservationFailureIsolation(t *testing.T) {
runtime, err := NewRuntime([]*iop.WorkspaceConfig{testWorkspaceConfig(t.TempDir())}, "darwin", zap.NewNop())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = runtime.Close() })
if _, err := runtime.Open(testRequestAuthority("request-failure-isolation")); err != nil {
t.Fatal(err)
}
runtime.observer = &workspaceSafeObserver{inner: workspaceObserverFunc(func(workspaceObservation) error {
panic("observer panic")
})}
if result := runtime.Write("request-failure-isolation", "result.txt", []byte("kept")); result.Status != iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS {
t.Fatalf("write changed by observer panic: %+v", result)
}
if cleanup := runtime.Cleanup(t.Context(), "request-failure-isolation"); cleanup.Status != iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS {
t.Fatalf("cleanup changed by observer panic: %+v", cleanup)
}
if got := runtime.observer.failureCount(); got != 2 {
t.Fatalf("isolated panic failures = %d, want 2", got)
}
runtime.observer = &workspaceSafeObserver{inner: workspaceObserverFunc(func(workspaceObservation) error { return errors.New("observer error") })}
if result := runtime.Write("unknown-request", "result.txt", nil); result.Code != iop.WorkspaceErrorCode_WORKSPACE_ERROR_CODE_INVALID_REQUEST {
t.Fatalf("expected normal invalid result, got %+v", result)
}
if got := runtime.observer.failureCount(); got != 1 {
t.Fatalf("isolated error failures = %d, want 1", got)
}
}
type workspaceObserverFunc func(workspaceObservation) error
func (fn workspaceObserverFunc) Emit(observation workspaceObservation) error { return fn(observation) }
type capturingObserver struct {
mu sync.Mutex
observations []workspaceObservation
}
func (c *capturingObserver) Emit(obs workspaceObservation) error {
c.mu.Lock()
defer c.mu.Unlock()
c.observations = append(c.observations, obs)
return nil
}
func TestWorkspaceObservationCorrelationSurvivesCleanupOverlap(t *testing.T) {
root := t.TempDir()
runtime, err := NewRuntime([]*iop.WorkspaceConfig{testWorkspaceConfig(root)}, "darwin", zap.NewNop())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = runtime.Close() })
if _, err := runtime.Open(testRequestAuthority("request-cleanup-overlap")); err != nil {
t.Fatal(err)
}
request, err := runtime.Request("request-cleanup-overlap")
if err != nil {
t.Fatal(err)
}
expectedCorrelation := request.correlation
if expectedCorrelation == "" {
t.Fatalf("expected non-empty correlation at open, got empty")
}
capturer := &capturingObserver{}
runtime.observer = &workspaceSafeObserver{inner: capturer}
entered := make(chan struct{})
release := make(chan struct{})
request.entry.beforeRename = func() error {
close(entered)
<-release
return nil
}
writeDone := make(chan Result, 1)
go func() {
writeDone <- runtime.Write("request-cleanup-overlap", "result.txt", []byte("overlap"))
}()
<-entered
cleanup := runtime.Cleanup(t.Context(), "request-cleanup-overlap")
if cleanup.Status != iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS {
t.Fatalf("cleanup result = %+v, want success", cleanup)
}
close(release)
writeResult := <-writeDone
if writeResult.Status != iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS {
t.Fatalf("write after cleanup overlap = %+v, want success", writeResult)
}
capturer.mu.Lock()
defer capturer.mu.Unlock()
if len(capturer.observations) != 2 {
t.Fatalf("observations count = %d, want 2 (1 cleanup + 1 tool)", len(capturer.observations))
}
var cleanupCount, toolCount int
var cleanupCorrelation, toolCorrelation string
for _, obs := range capturer.observations {
switch obs.event {
case workspaceObservationCleanup:
cleanupCount++
cleanupCorrelation = obs.correlation
case workspaceObservationTool:
toolCount++
toolCorrelation = obs.correlation
}
}
if cleanupCount != 1 {
t.Fatalf("cleanup observation count = %d, want 1", cleanupCount)
}
if toolCount != 1 {
t.Fatalf("tool observation count = %d, want 1", toolCount)
}
if cleanupCorrelation == "" {
t.Fatalf("cleanup correlation is empty")
}
if toolCorrelation == "" {
t.Fatalf("tool correlation is empty after cleanup overlap")
}
if cleanupCorrelation != toolCorrelation {
t.Fatalf("cleanup correlation=%q != tool correlation=%q, want shared correlation", cleanupCorrelation, toolCorrelation)
}
if cleanupCorrelation != expectedCorrelation {
t.Fatalf("correlation=%q != expected=%q, want immutable request-local correlation", cleanupCorrelation, expectedCorrelation)
}
}