Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions pkg/engine/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
103 changes: 103 additions & 0 deletions pkg/engine/service_integration_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
30 changes: 26 additions & 4 deletions pkg/engine/supervisor.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ type AgentSession struct {
tooling toolingv0.ToolingClient
builderMu sync.Mutex
builderOK bool
runtimeMu sync.Mutex
runtimeOK bool
closeOnce sync.Once
}

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down
10 changes: 10 additions & 0 deletions pkg/gateway/code_unit_project_info_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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", `
Expand Down
Loading