diff --git a/pkg/engine/service.go b/pkg/engine/service.go index 4be3e05c..403de248 100644 --- a/pkg/engine/service.go +++ b/pkg/engine/service.go @@ -77,6 +77,9 @@ func (s *Service) GetSemanticIndex(ctx context.Context, request *toolingv0.GetSe func (s *Service) Build(ctx context.Context, request *runtimev0.BuildRequest) (*runtimev0.BuildResponse, error) { var response *runtimev0.BuildResponse err := s.withSession(ctx, "runtime.Build", true, func(session *AgentSession) error { + if err := ensureRuntime(ctx, session); err != nil { + return err + } var err error response, err = session.runtime.Build(ctx, request) return err @@ -88,6 +91,9 @@ func (s *Service) Build(ctx context.Context, request *runtimev0.BuildRequest) (* func (s *Service) Test(ctx context.Context, request *runtimev0.TestRequest) (*runtimev0.TestResponse, error) { var response *runtimev0.TestResponse err := s.withSession(ctx, "runtime.Test", true, func(session *AgentSession) error { + if err := ensureRuntime(ctx, session); err != nil { + return err + } var err error response, err = session.runtime.Test(ctx, request) return err @@ -134,6 +140,9 @@ func (s *Service) Configure(ctx context.Context, request *builderv0.ConfigureReq func (s *Service) Lint(ctx context.Context, request *runtimev0.LintRequest) (*runtimev0.LintResponse, error) { var response *runtimev0.LintResponse err := s.withSession(ctx, "runtime.Lint", true, func(session *AgentSession) error { + if err := ensureRuntime(ctx, session); err != nil { + return err + } var err error response, err = session.runtime.Lint(ctx, request) return err @@ -168,6 +177,9 @@ func (s *Service) RunCommand(ctx context.Context, request *agentv0.RunPluginComm func (s *Service) Stop(ctx context.Context, request *runtimev0.StopRequest) (*runtimev0.StopResponse, error) { var response *runtimev0.StopResponse err := s.withSession(ctx, "runtime.Stop", true, func(session *AgentSession) error { + if err := ensureRuntime(ctx, session); err != nil { + return err + } var err error response, err = session.runtime.Stop(ctx, request) return err diff --git a/pkg/engine/service_integration_test.go b/pkg/engine/service_integration_test.go new file mode 100644 index 00000000..10c75378 --- /dev/null +++ b/pkg/engine/service_integration_test.go @@ -0,0 +1,103 @@ +package engine + +import ( + "os" + "path/filepath" + "testing" + + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + codev0 "github.com/codefly-dev/core/generated/go/codefly/services/code/v0" + runtimev0 "github.com/codefly-dev/core/generated/go/codefly/services/runtime/v0" + toolingv0 "github.com/codefly-dev/core/generated/go/codefly/services/tooling/v0" +) + +// TestReadOnlyCodeAndToolingRunWithoutRuntimeInitialization exercises the real +// production agent against malformed source. GetProjectInfo/GetSemanticIndex +// must recover typed evidence without ever running Runtime Load/Init, and the +// first runtime call must initialize it lazily. +func TestReadOnlyCodeAndToolingRunWithoutRuntimeInitialization(t *testing.T) { + root := t.TempDir() + writeSourceFile(t, root, "pyproject.toml", "[project]\nname = \"probe\"\nversion = \"0.0.0\"\n") + writeSourceFile(t, root, "broken.py", "def oops(:\n return\n") + + agent, err := DetectSourceAgent(root) + if err != nil { + t.Fatalf("detect source agent: %v", err) + } + + host, err := NewWorkspaceHost(Config{Root: root}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = host.Close() }) + + service, err := host.Service(ServiceTarget{Root: root, Agent: agent, ForceSource: true}) + if err != nil { + t.Fatal(err) + } + + ctx := t.Context() + + project, err := service.ExecuteCode(ctx, &codev0.CodeRequest{ + Operation: &codev0.CodeRequest_GetProjectInfo{GetProjectInfo: &codev0.GetProjectInfoRequest{}}, + }) + if err != nil { + t.Fatalf("GetProjectInfo transport error: %v", err) + } + info := project.GetGetProjectInfo() + if info.GetModule() != "probe" || info.GetLanguage() != "python" { + t.Fatalf("project info did not recover identity from malformed source: %+v", info) + } + + semantic, err := service.GetSemanticIndex(ctx, &toolingv0.GetSemanticIndexRequest{}) + if err != nil { + t.Fatalf("GetSemanticIndex transport error: %v", err) + } + index := semantic.GetIndex() + if index.GetState() != basev0.SemanticIndexState_SEMANTIC_INDEX_STATE_DEGRADED { + t.Fatalf("malformed source should degrade, not fail: state=%s issues=%+v", index.GetState(), index.GetIssues()) + } + if len(index.GetLanguages()) != 1 || index.GetLanguages()[0] != "python" { + t.Fatalf("semantic languages = %v, want [python]", index.GetLanguages()) + } + if !hasIssueCode(index.GetIssues(), "parse_failed") { + t.Fatalf("semantic recovery should report the parse failure, got %+v", index.GetIssues()) + } + + // The single decoupling invariant: read-only inspection started the agent + // but never ran Runtime Load/Init. + session, err := service.supervisor.acquire(ctx, service.target) + if err != nil { + t.Fatalf("acquire session: %v", err) + } + if session.runtimeOK { + t.Fatal("read-only Code/Tooling calls must not initialize the runtime") + } + + if _, err := service.Test(ctx, &runtimev0.TestRequest{}); err != nil { + t.Fatalf("Test transport error: %v", err) + } + if !session.runtimeOK { + t.Fatal("the first runtime call must initialize the runtime lazily") + } +} + +func hasIssueCode(issues []*basev0.SemanticIssue, code string) bool { + for _, issue := range issues { + if issue.GetCode() == code { + return true + } + } + return false +} + +func writeSourceFile(t *testing.T, root, relative, body string) { + t.Helper() + name := filepath.Join(root, filepath.FromSlash(relative)) + if err := os.MkdirAll(filepath.Dir(name), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(name, []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} diff --git a/pkg/engine/supervisor.go b/pkg/engine/supervisor.go index bdbc2491..2cd9d175 100644 --- a/pkg/engine/supervisor.go +++ b/pkg/engine/supervisor.go @@ -53,6 +53,8 @@ type AgentSession struct { tooling toolingv0.ToolingClient builderMu sync.Mutex builderOK bool + runtimeMu sync.Mutex + runtimeOK bool closeOnce sync.Once } @@ -166,10 +168,6 @@ func (s *AgentSupervisor) acquire(ctx context.Context, target ServiceTarget) (*A runtime: runtimev0.NewRuntimeClient(connection.GRPCConn()), tooling: toolingv0.NewToolingClient(connection.GRPCConn()), } - if err := initializeRuntime(loadCtx, session); err != nil { - connection.Close() - return nil, &agentInitializationError{err: fmt.Errorf("initialize agent %s runtime: %w", agentName, err)} - } s.mu.Lock() if s.closed { s.mu.Unlock() @@ -210,6 +208,30 @@ func initializeBuilder(ctx context.Context, session *AgentSession) error { return nil } +// ensureRuntime lazily runs Runtime Load+Init on the already-running service +// agent. Read-only Code/Tooling traffic never pays this lifecycle cost; the +// first Build/Test/Lint/Stop initializes it and later calls reuse it. Failures +// are returned as *agentInitializationError so Test can surface the typed +// env-blocked response, and are not cached so a later call can retry once the +// environment is ready. +func ensureRuntime(ctx context.Context, session *AgentSession) error { + if session == nil || session.runtime == nil || session.descriptor == nil { + return fmt.Errorf("runtime client is unavailable") + } + session.runtimeMu.Lock() + defer session.runtimeMu.Unlock() + if session.runtimeOK { + return nil + } + initCtx, cancel := context.WithTimeout(ctx, 60*time.Second) + defer cancel() + if err := initializeRuntime(initCtx, session); err != nil { + return &agentInitializationError{err: fmt.Errorf("initialize agent %s runtime: %w", session.agent.Name, err)} + } + session.runtimeOK = true + return nil +} + func initializeRuntime(ctx context.Context, session *AgentSession) error { descriptor := session.descriptor environmentProto, err := descriptor.environment.Proto() diff --git a/pkg/gateway/code_unit_project_info_integration_test.go b/pkg/gateway/code_unit_project_info_integration_test.go index 4193cc00..72403081 100644 --- a/pkg/gateway/code_unit_project_info_integration_test.go +++ b/pkg/gateway/code_unit_project_info_integration_test.go @@ -4,6 +4,7 @@ import ( "reflect" "testing" + "github.com/codefly-dev/cli/pkg/sourceworkspace" basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" gatewayv1 "github.com/codefly-dev/core/generated/go/mind/gateway/v1" ) @@ -13,6 +14,15 @@ import ( // and returns repository-relative typed evidence. No project manifest or // source file crosses into the caller for local interpretation. func TestGatewayInspectsJVMAndDotNetCodeUnitsThroughGenericAgent(t *testing.T) { + // The generic agent binds its Code source directory during Runtime.Load. With + // read-only inspection decoupled from the runtime lifecycle (issue #269), that + // binding has to move to agent startup, which is codefly-dev/mind#369. Until a + // generic agent carrying that fix is published and pinned here, this proof + // cannot run without the very runtime lifecycle it now avoids. Bumping + // GenericPluginVersion past the pre-decoupling release re-enables it. + if sourceworkspace.GenericPluginVersion == "0.0.21" { + t.Skip("pinned generic agent 0.0.21 predates runtime-decoupled Code inspection (codefly-dev/mind#369)") + } root := t.TempDir() writeCodeUnitFixture(t, root, "src/ads/settings.gradle", "rootProject.name = 'adservice'\n") writeCodeUnitFixture(t, root, "src/ads/build.gradle", `