iop/apps/node/internal/bootstrap/workspace_runtime_test.go
toki d7a150c7fe feat(agent): 단일 요청 실행 경로를 완성한다
Claude의 단일 Anthropic 요청 안에서 IOP가 Plan, Work, Review와 workspace 도구 실행을 끝내고 실제 dev smoke로 계약을 검증할 수 있어야 한다.\n\n완료 task evidence와 마일스톤 검토 상태도 같은 변경에 고정한다.
2026-08-08 23:35:13 +09:00

150 lines
5.8 KiB
Go

package bootstrap
import (
"context"
"fmt"
"strings"
"testing"
"time"
"go.uber.org/zap"
"go.uber.org/zap/zaptest/observer"
"iop/apps/node/internal/transport"
"iop/packages/go/config"
iop "iop/proto/gen/iop"
)
func TestWorkspaceRuntimeCompositionBeforeReady(t *testing.T) {
for _, hostOS := range []string{config.WorkspacePlatformDarwin, config.WorkspacePlatformLinux} {
t.Run(hostOS, func(t *testing.T) {
t.Chdir(t.TempDir())
root := t.TempDir()
configPayload := &iop.NodeConfigPayload{
Runtime: &iop.NodeRuntimeConfig{Concurrency: 1},
Workspaces: []*iop.WorkspaceConfig{{
Ref: "workspace-1", Platform: hostOS, Root: root,
Operations: []iop.WorkspaceOperation{iop.WorkspaceOperation_WORKSPACE_OPERATION_READ},
MaxReadBytes: 8,
}},
}
dialer := func(context.Context, string, string, *zap.Logger) (*transport.RegisterResult, error) {
return &transport.RegisterResult{NodeID: "node-1", Config: configPayload}, nil
}
events := make([]string, 0, 2)
handlerInstalled := false
var installedWorkspaceHandler transport.WorkspaceHandler
owner, err := connectRuntime(context.Background(), &config.NodeConfig{}, zap.NewNop(), nil, connectRuntimeOptions{
dialer: dialer,
hostOS: func() string { return hostOS },
setHandler: func(_ *transport.Session, handler transport.Handler) {
workspaceHandler, ok := handler.(transport.WorkspaceHandler)
if !ok {
t.Fatal("composed handler does not implement WorkspaceHandler")
}
installedWorkspaceHandler = workspaceHandler
response, callErr := workspaceHandler.OnWorkspaceOpen(context.Background(), nil, &iop.WorkspaceOpenRequest{
RequestId: "request-1", WorkspaceRef: "workspace-1",
Operations: []iop.WorkspaceOperation{iop.WorkspaceOperation_WORKSPACE_OPERATION_READ},
MaxReadBytes: 8,
})
if callErr != nil || response.GetStatus() != iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS {
t.Fatalf("workspace handler before ready = %+v, %v", response, callErr)
}
handlerInstalled = true
events = append(events, "handler")
},
signalReady: func(_ *transport.Session, _ time.Duration) error {
if !handlerInstalled {
t.Fatal("ready signalled before handler installation")
}
events = append(events, "ready")
return nil
},
})
if err != nil {
t.Fatalf("connectRuntime: %v", err)
}
owner.close()
closedResponse, callErr := installedWorkspaceHandler.OnWorkspaceOpen(context.Background(), nil, &iop.WorkspaceOpenRequest{
RequestId: "request-2", WorkspaceRef: "workspace-1",
Operations: []iop.WorkspaceOperation{iop.WorkspaceOperation_WORKSPACE_OPERATION_READ}, MaxReadBytes: 8,
})
if callErr != nil || closedResponse.GetErrorCode() != iop.WorkspaceErrorCode_WORKSPACE_ERROR_CODE_NOT_READY {
t.Fatalf("workspace runtime after owner close = %+v, %v", closedResponse, callErr)
}
if strings.Join(events, ",") != "handler,ready" {
t.Fatalf("composition order = %v", events)
}
})
}
}
func TestWorkspaceRuntimeCatalogFailureIsRedactedBeforeReady(t *testing.T) {
t.Chdir(t.TempDir())
const sentinel = "workspace-startup-root-sentinel-8403"
core, observed := observer.New(zap.DebugLevel)
logger := zap.New(core)
readyReached := false
_, err := connectRuntime(context.Background(), &config.NodeConfig{}, logger, nil, connectRuntimeOptions{
dialer: func(context.Context, string, string, *zap.Logger) (*transport.RegisterResult, error) {
return &transport.RegisterResult{NodeID: "node-1", Config: &iop.NodeConfigPayload{
Runtime: &iop.NodeRuntimeConfig{Concurrency: 1},
Workspaces: []*iop.WorkspaceConfig{{
Ref: "workspace-1", Platform: "darwin", Root: t.TempDir() + "/" + sentinel,
Operations: []iop.WorkspaceOperation{iop.WorkspaceOperation_WORKSPACE_OPERATION_READ}, MaxReadBytes: 1,
}},
}}, nil
},
hostOS: func() string { return "darwin" },
setHandler: func(*transport.Session, transport.Handler) {
t.Fatal("handler installed after invalid workspace catalog")
},
signalReady: func(*transport.Session, time.Duration) error {
readyReached = true
return nil
},
})
if err == nil || readyReached {
t.Fatalf("catalog error=%v ready=%v", err, readyReached)
}
if strings.Contains(err.Error(), sentinel) || strings.Contains(fmt.Sprint(observed.All()), sentinel) {
t.Fatalf("workspace root leaked: error=%q logs=%v", err, observed.All())
}
}
func TestWorkspaceRuntimeOwnerCloseOrderAndReconnectReplacement(t *testing.T) {
var events []string
owner := &runtimeOwner{
closeRegistry: func() { events = append(events, "registry") },
closeWorkspace: func() { events = append(events, "workspace") },
closeSession: func() { events = append(events, "session") },
closeStore: func() { events = append(events, "store") },
}
owner.close()
owner.close()
if strings.Join(events, ",") != "registry,workspace,session,store" {
t.Fatalf("direct close order = %v", events)
}
events = nil
previous := &runtimeOwner{
closeRegistry: func() { events = append(events, "old-registry") },
closeWorkspace: func() { events = append(events, "old-workspace") },
closeSession: func() { events = append(events, "old-session") },
closeStore: func() { events = append(events, "old-store") },
}
next := &runtimeOwner{
closeRegistry: func() { events = append(events, "new-registry") },
closeWorkspace: func() { events = append(events, "new-workspace") },
closeSession: func() { events = append(events, "new-session") },
closeStore: func() { events = append(events, "new-store") },
}
supervisor := &runtimeSupervisor{current: previous}
supervisor.swapOwner(next)
supervisor.clearCurrent()
want := "old-registry,old-workspace,old-session,old-store,new-registry,new-workspace,new-session,new-store"
if strings.Join(events, ",") != want {
t.Fatalf("replacement close order = %v", events)
}
}