diff --git a/go.mod b/go.mod index 66360b1a..4a817602 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( github.com/asottile/dockerfile v3.1.0+incompatible github.com/blang/semver v3.5.1+incompatible github.com/briandowns/spinner v1.23.2 - github.com/codefly-dev/core v0.2.85 + github.com/codefly-dev/core v0.2.86 github.com/codefly-dev/golor v0.1.3 github.com/codefly-dev/llm v0.1.0 github.com/codefly-dev/sdk-go v0.1.58 diff --git a/go.sum b/go.sum index 6bba3f9d..33582dda 100644 --- a/go.sum +++ b/go.sum @@ -95,8 +95,8 @@ github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJ github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= -github.com/codefly-dev/core v0.2.85 h1:FcOKENGLHW2tbRomMNxbJDMscePS0ARa2Y4+44P3F5k= -github.com/codefly-dev/core v0.2.85/go.mod h1:hNxTk7ZnR5AU8imfvNJcGpoULQ1uzML5M3E4/RffWU4= +github.com/codefly-dev/core v0.2.86 h1:GA5fFN+H4Igr51QtOysDI+aMLdMaXhM7AaLvGGdkiL4= +github.com/codefly-dev/core v0.2.86/go.mod h1:hNxTk7ZnR5AU8imfvNJcGpoULQ1uzML5M3E4/RffWU4= github.com/codefly-dev/golor v0.1.3 h1:xmo+ceyJFRYZdvpWE2fNd0jeaadp/Ibm1BnganiGKOc= github.com/codefly-dev/golor v0.1.3/go.mod h1:sl/u/K1l7J0Pr3xyVZp8fOJYQItKKst1No9JqgzLLoY= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= diff --git a/pkg/gateway/prepared_mutation.go b/pkg/gateway/prepared_mutation.go index 7fe27c4e..547f43b0 100644 --- a/pkg/gateway/prepared_mutation.go +++ b/pkg/gateway/prepared_mutation.go @@ -19,6 +19,7 @@ import ( "strings" "time" + "github.com/codefly-dev/core/failures" codev0 "github.com/codefly-dev/core/generated/go/codefly/services/code/v0" gatewayv1 "github.com/codefly-dev/core/generated/go/mind/gateway/v1" "github.com/codefly-dev/core/policy" @@ -113,9 +114,9 @@ func (s *Server) ConfigureMutationAuthority(_ context.Context, req *gatewayv1.Co return &gatewayv1.ConfigureMutationAuthorityResponse{AuthorityId: authorityID, WorkspaceId: workspaceID}, nil } -// PrepareMutation resolves the first production slice—one ApplyEdit—through -// the real language agent in dry-run mode. The resulting bytes and hashes are -// sealed into an immutable protobuf; no write occurs in this RPC. +// PrepareMutation resolves one typed text or symbol edit through the real +// language agent in dry-run mode. The resulting bytes and hashes are sealed +// into an immutable protobuf; no write occurs in this RPC. func (s *Server) PrepareMutation(ctx context.Context, req *gatewayv1.PrepareMutationRequest) (*gatewayv1.PrepareMutationResponse, error) { if req == nil { return prepareFailure("prepare mutation request is required"), nil @@ -132,41 +133,84 @@ func (s *Server) PrepareMutation(ctx context.Context, req *gatewayv1.PrepareMuta if service == "" || service != req.GetService() || workspaceVersion == "" || workspaceVersion != req.GetWorkspaceVersion() { return prepareFailure("service and workspace_version are required and must be canonical"), nil } - edit := req.GetApplyEdit() - if edit == nil { - return prepareFailure("only apply_edit preparation is currently supported"), nil - } - path, err := cleanGatewayPath(edit.GetFile()) - if err != nil || path == "" { - if err == nil { - err = errors.New("edit file is required") + edit, symbolPatch := req.GetApplyEdit(), req.GetSymbolPatch() + if (edit == nil) == (symbolPatch == nil) { + return prepareFailure("exactly one apply_edit or symbol_patch mutation is required"), nil + } + var path, strategy, symbolID string + var fixActions []string + var after []byte + var previewBeforeHash, previewAfterHash string + if edit != nil { + path, err = cleanGatewayPath(edit.GetFile()) + if err != nil || path == "" { + if err == nil { + err = errors.New("edit file is required") + } + return prepareFailure(err.Error()), nil } - return prepareFailure(err.Error()), nil - } - if edit.GetFind() == "" { - return prepareFailure("apply_edit find text is required"), nil - } - preview, err := s.ApplyEdit(ctx, &gatewayv1.ApplyEditRequest{ - Service: service, File: path, Find: edit.GetFind(), Replace: edit.GetReplace(), - FixMode: edit.GetFixMode(), DryRun: true, - }) - if err != nil { - return prepareFailure(err.Error()), nil - } - if preview == nil || !preview.GetSuccess() { - return prepareFailure(preview.GetError()), nil - } - if preview.GetWrote() || !preview.GetChanged() { - return prepareFailure("prepared edit must change bytes without writing them"), nil + if edit.GetFind() == "" { + return prepareFailure("apply_edit find text is required"), nil + } + preview, previewErr := s.ApplyEdit(ctx, &gatewayv1.ApplyEditRequest{ + Service: service, File: path, Find: edit.GetFind(), Replace: edit.GetReplace(), + FixMode: edit.GetFixMode(), DryRun: true, + }) + if previewErr != nil { + return prepareFailure(previewErr.Error()), nil + } + if preview == nil || !preview.GetSuccess() { + return prepareFailure(preview.GetError()), nil + } + if preview.GetWrote() || !preview.GetChanged() { + return prepareFailure("prepared edit must change bytes without writing them"), nil + } + after = []byte(preview.GetContent()) + strategy, fixActions = preview.GetStrategy(), append([]string(nil), preview.GetFixActions()...) + previewBeforeHash, previewAfterHash = preview.GetBeforeSha256(), preview.GetAfterSha256() + } else { + path, err = cleanGatewayPath(symbolPatch.GetFile()) + if err != nil || path == "" { + if err == nil { + err = errors.New("symbol patch file is required") + } + return prepareFailure(err.Error()), nil + } + symbolID = strings.TrimSpace(symbolPatch.GetSymbolId()) + qualifiedName := strings.TrimSpace(symbolPatch.GetQualifiedName()) + if symbolID == "" || symbolID != symbolPatch.GetSymbolId() || qualifiedName == "" || qualifiedName != symbolPatch.GetQualifiedName() || !validSHA256(symbolPatch.GetExpectedDeclarationSha256()) { + return prepareFailure("symbol_patch symbol_id, qualified_name, and expected_declaration_sha256 are required and must be canonical"), nil + } + raw, previewErr := s.executeSymbolPatch(ctx, &gatewayv1.ApplySymbolPatchRequest{ + Service: service, File: path, QualifiedName: qualifiedName, + ExpectedDeclarationSha256: symbolPatch.GetExpectedDeclarationSha256(), + NewSource: symbolPatch.GetNewSource(), FixMode: symbolPatch.GetFixMode(), DryRun: true, + }, path) + if previewErr != nil { + return prepareFailure(previewErr.Error()), nil + } + preview := raw.GetApplySymbolPatch() + if preview == nil || !preview.GetSuccess() { + projected := gatewaySymbolPatchResponse(raw) + return &gatewayv1.PrepareMutationResponse{ + Success: false, Error: projected.GetError(), Failure: failures.Clone(projected.GetFailure()), + SymbolPatchFailureReason: projected.GetFailureReason(), + }, nil + } + if preview.GetWrote() || !preview.GetChanged() { + return prepareFailure("prepared symbol patch must change bytes without writing them"), nil + } + after = []byte(preview.GetContent()) + strategy, fixActions = preview.GetStrategy(), append([]string(nil), preview.GetFixActions()...) + previewBeforeHash, previewAfterHash = preview.GetBeforeSha256(), preview.GetAfterSha256() } current, err := s.fileOps().ReadFile(ctx, path) if err != nil { return prepareFailure(fmt.Sprintf("read prepared target: %v", err)), nil } - after := []byte(preview.GetContent()) beforeHash := contentSHA256(current) afterHash := contentSHA256(after) - if preview.GetBeforeSha256() != beforeHash || preview.GetAfterSha256() != afterHash { + if previewBeforeHash != beforeHash || previewAfterHash != afterHash { return prepareFailure("language agent preview hashes do not match authoritative project bytes"), nil } prepared := &gatewayv1.PreparedMutation{ @@ -179,7 +223,7 @@ func (s *Server) PrepareMutation(ctx context.Context, req *gatewayv1.PrepareMuta Files: []*gatewayv1.PreparedFileMutation{{ Path: path, Operation: gatewayv1.PreparedFileOperation_PREPARED_FILE_OPERATION_MODIFY, BeforeSha256: beforeHash, AfterSha256: afterHash, - Strategy: preview.GetStrategy(), FixActions: append([]string(nil), preview.GetFixActions()...), + Strategy: strategy, FixActions: fixActions, SymbolId: symbolID, }}, PreparedAt: timestamppb.Now(), ExpiresAt: timestamppb.New(time.Now().UTC().Add(preparedMutationLifetime)), } @@ -423,6 +467,9 @@ func validatePreparedMutation(prepared *gatewayv1.PreparedMutation) error { if !validSHA256(file.GetBeforeSha256()) || !validSHA256(file.GetAfterSha256()) || file.GetBeforeSha256() == file.GetAfterSha256() { return errors.New("prepared mutation requires distinct lowercase SHA-256 before/after hashes") } + if file.GetSymbolId() != strings.TrimSpace(file.GetSymbolId()) { + return errors.New("prepared mutation symbol_id must be canonical when present") + } digest, err := computePreparedMutationDigest(prepared) if err != nil { return err @@ -570,13 +617,15 @@ func permitCoversPreparedFiles(binding *mutationPermitBinding, files []*gatewayv for _, file := range files { covered := false for _, fence := range binding.Fences { - if fence.Kind == "file" && fence.Path == file.GetPath() && fence.SymbolID == "" && fence.FenceToken > 0 { + fileFence := file.GetSymbolId() == "" && fence.Kind == "file" && fence.SymbolID == "" + symbolFence := file.GetSymbolId() != "" && fence.Kind == "symbol" && fence.SymbolID == file.GetSymbolId() + if fence.Path == file.GetPath() && fence.FenceToken > 0 && (fileFence || symbolFence) { covered = true break } } if !covered { - return fmt.Errorf("mutation permit has no file fence for prepared target %q", file.GetPath()) + return fmt.Errorf("mutation permit has no exact fence for prepared target %q symbol %q", file.GetPath(), file.GetSymbolId()) } } return nil diff --git a/pkg/gateway/prepared_mutation_test.go b/pkg/gateway/prepared_mutation_test.go index 50a8c7e7..d2912b41 100644 --- a/pkg/gateway/prepared_mutation_test.go +++ b/pkg/gateway/prepared_mutation_test.go @@ -5,7 +5,6 @@ import ( "context" "crypto/ed25519" "crypto/rand" - "net" "os" "path/filepath" "strings" @@ -13,15 +12,10 @@ import ( "testing" "time" - codecore "github.com/codefly-dev/core/code" basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" - codev0 "github.com/codefly-dev/core/generated/go/codefly/services/code/v0" gatewayv1 "github.com/codefly-dev/core/generated/go/mind/gateway/v1" "github.com/codefly-dev/core/policy" "github.com/google/uuid" - "google.golang.org/grpc" - "google.golang.org/grpc/credentials/insecure" - "google.golang.org/grpc/test/bufconn" "google.golang.org/protobuf/types/known/timestamppb" ) @@ -107,6 +101,95 @@ func TestPreparedMutationRequiresPinnedAuthorityAndAppliesSignedPermitOnce(t *te } } +func TestPreparedSymbolPatchRetainsBytesAndRequiresExactSymbolFence(t *testing.T) { + server, privateKey, root := newPreparedMutationGateway(t) + path := filepath.Join(root, "pkg", "service.go") + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + before := "package service\n\nfunc Value() int { return 1 }\n" + declaration := "func Value() int { return 1 }" + if err := os.WriteFile(path, []byte(before), 0o644); err != nil { + t.Fatal(err) + } + preparedResponse, err := server.PrepareMutation(t.Context(), &gatewayv1.PrepareMutationRequest{ + Service: "app", WorkspaceVersion: "workspace-symbol-v1", + Mutation: &gatewayv1.PrepareMutationRequest_SymbolPatch{SymbolPatch: &gatewayv1.PrepareSymbolPatchMutation{ + File: "pkg/service.go", SymbolId: "symbol-service-value", QualifiedName: "service.Value", + ExpectedDeclarationSha256: contentSHA256([]byte(declaration)), + NewSource: "func Value() int { return 2 }", FixMode: basev0.FixMode_FIX_MODE_NONE, + }}, + }) + if err != nil || !preparedResponse.GetSuccess() { + t.Fatalf("prepare symbol mutation: response=%+v err=%v", preparedResponse, err) + } + prepared := preparedResponse.GetPrepared() + if len(prepared.GetFiles()) != 1 || prepared.GetFiles()[0].GetSymbolId() != "symbol-service-value" { + t.Fatalf("prepared symbol resource = %+v", prepared.GetFiles()) + } + unchanged, err := os.ReadFile(path) + if err != nil || string(unchanged) != before { + t.Fatalf("preparation changed source: content=%q err=%v", unchanged, err) + } + wrongFencePermit := signPreparedMutationPermitWithFence(t, privateKey, prepared, mutationPermitFence{ + Kind: "file", Path: "pkg/service.go", FenceToken: 1, + }, time.Now().UTC().Add(-time.Second), time.Minute) + rejected, err := server.ApplyPreparedMutation(t.Context(), &gatewayv1.ApplyPreparedMutationRequest{ + Service: "app", PreparationId: prepared.GetPreparationId(), + MutationDigest: prepared.GetMutationDigest(), MutationPermit: wrongFencePermit, + }) + if err != nil || rejected.GetSuccess() || !strings.Contains(rejected.GetError(), "no exact fence") { + t.Fatalf("file fence authorized symbol mutation: response=%+v err=%v", rejected, err) + } + permit := signPreparedMutationPermit(t, privateKey, prepared, time.Now().UTC().Add(-time.Second), time.Minute) + applied, err := server.ApplyPreparedMutation(t.Context(), &gatewayv1.ApplyPreparedMutationRequest{ + Service: "app", PreparationId: prepared.GetPreparationId(), + MutationDigest: prepared.GetMutationDigest(), MutationPermit: permit, + }) + if err != nil || !applied.GetSuccess() { + t.Fatalf("apply prepared symbol mutation: response=%+v err=%v", applied, err) + } + after, err := os.ReadFile(path) + if err != nil || string(after) != "package service\n\nfunc Value() int { return 2 }\n" { + t.Fatalf("applied source=%q err=%v", after, err) + } +} + +func TestSymbolPatchRecoveryReasonSurvivesGatewayAndPreparation(t *testing.T) { + server, _, root := newPreparedMutationGateway(t) + path := filepath.Join(root, "pkg", "service.go") + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + declaration := "func Value() int { return 1 }" + if err := os.WriteFile(path, []byte("package service\n\n"+declaration+"\n"), 0o644); err != nil { + t.Fatal(err) + } + request := &gatewayv1.ApplySymbolPatchRequest{ + Service: "app", File: "pkg/service.go", QualifiedName: "service.Value", + ExpectedDeclarationSha256: strings.Repeat("0", 64), + NewSource: "func Value() int { return 2 }", DryRun: true, + } + direct, err := server.ApplySymbolPatch(t.Context(), request) + if err != nil || direct.GetSuccess() || direct.GetFailureReason() != basev0.SymbolPatchFailureReason_SYMBOL_PATCH_FAILURE_REASON_STALE_ANCHOR { + t.Fatalf("direct stale response=%+v err=%v", direct, err) + } + prepared, err := server.PrepareMutation(t.Context(), &gatewayv1.PrepareMutationRequest{ + Service: "app", WorkspaceVersion: "workspace-stale-v1", + Mutation: &gatewayv1.PrepareMutationRequest_SymbolPatch{SymbolPatch: &gatewayv1.PrepareSymbolPatchMutation{ + File: request.GetFile(), SymbolId: "symbol-service-value", QualifiedName: request.GetQualifiedName(), + ExpectedDeclarationSha256: request.GetExpectedDeclarationSha256(), NewSource: request.GetNewSource(), + }}, + }) + if err != nil || prepared.GetSuccess() || prepared.GetSymbolPatchFailureReason() != basev0.SymbolPatchFailureReason_SYMBOL_PATCH_FAILURE_REASON_STALE_ANCHOR { + t.Fatalf("prepared stale response=%+v err=%v", prepared, err) + } + unchanged, err := os.ReadFile(path) + if err != nil || string(unchanged) != "package service\n\n"+declaration+"\n" { + t.Fatalf("stale attempts changed project bytes: content=%q err=%v", unchanged, err) + } +} + func TestPreparedMutationRejectsWrongSignatureBindingExpiryAndAuthorityReplacement(t *testing.T) { server, privateKey, root := newPreparedMutationGateway(t) path := filepath.Join(root, "main.go") @@ -192,23 +275,7 @@ func newPreparedMutationGateway(t *testing.T) (*Server, ed25519.PrivateKey, stri if err != nil { t.Fatal(err) } - server.mindYAML = &MindYAML{Service: "app", Plugin: "generic"} - - codeServer := codecore.NewDefaultCodeServer(root) - t.Cleanup(func() { _ = codeServer.Close() }) - listener := bufconn.Listen(1 << 20) - grpcServer := grpc.NewServer() - codev0.RegisterCodeServer(grpcServer, codeServer) - go func() { _ = grpcServer.Serve(listener) }() - t.Cleanup(grpcServer.Stop) - connection, err := grpc.NewClient("passthrough:///real-code-agent", grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { - return listener.Dial() - }), grpc.WithTransportCredentials(insecure.NewCredentials())) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = connection.Close() }) - server.serviceBehavior = &mockServiceExecution{code: codev0.NewCodeClient(connection)} + t.Cleanup(func() { _ = server.Close() }) publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) if err != nil { @@ -224,6 +291,16 @@ func newPreparedMutationGateway(t *testing.T) (*Server, ed25519.PrivateKey, stri } func signPreparedMutationPermit(t *testing.T, privateKey ed25519.PrivateKey, prepared *gatewayv1.PreparedMutation, issuedAt time.Time, ttl time.Duration) string { + t.Helper() + fence := mutationPermitFence{Kind: "file", Path: prepared.GetFiles()[0].GetPath(), FenceToken: 1} + if symbolID := prepared.GetFiles()[0].GetSymbolId(); symbolID != "" { + fence.Kind = "symbol" + fence.SymbolID = symbolID + } + return signPreparedMutationPermitWithFence(t, privateKey, prepared, fence, issuedAt, ttl) +} + +func signPreparedMutationPermitWithFence(t *testing.T, privateKey ed25519.PrivateKey, prepared *gatewayv1.PreparedMutation, fence mutationPermitFence, issuedAt time.Time, ttl time.Duration) string { t.Helper() tenantID := uuid.NewString() binding := mutationPermitBinding{ @@ -231,7 +308,7 @@ func signPreparedMutationPermit(t *testing.T, privateKey ed25519.PrivateKey, pre TenantID: tenantID, PlanID: "plan-1", PlanRevision: 1, PlanContentHash: contentSHA256([]byte("plan-1")), LeaseSetID: uuid.NewString(), OwnerAttemptID: "attempt-1", WorkspaceVersion: prepared.GetWorkspaceVersion(), - Fences: []mutationPermitFence{{Kind: "file", Path: prepared.GetFiles()[0].GetPath(), FenceToken: 1}}, + Fences: []mutationPermitFence{fence}, } token, _, err := policy.MintEd25519(policy.MintInput{ Principal: &policy.Principal{ID: prepared.GetAuthorityId(), Kind: policy.KindService, OrgID: tenantID}, diff --git a/pkg/gateway/server.go b/pkg/gateway/server.go index 06fdd98e..3d3671d8 100644 --- a/pkg/gateway/server.go +++ b/pkg/gateway/server.go @@ -954,8 +954,8 @@ func (s *Server) ApplyEdit(ctx context.Context, req *gatewayv1.ApplyEditRequest) return &gatewayv1.ApplyEditResponse{Success: false, Error: err.Error()}, nil } if req.GetDryRun() { - if err := validateOptionalExecutionContext(ctx); err != nil { - return nil, err + if contextErr := validateOptionalExecutionContext(ctx); contextErr != nil { + return nil, contextErr } } else { operationInputSHA256, digestErr := deterministicProtoSHA256(&codev0.ApplyEditRequest{ @@ -1084,6 +1084,125 @@ func (s *Server) applyEditWithReceipt( return response, nil } +// ApplySymbolPatch forwards one exact analyzer-qualified declaration mutation +// to the owning Codefly agent. The Gateway response is source-free: complete +// post-edit bytes remain inside Codefly even for dry-run preparation. +func (s *Server) ApplySymbolPatch(ctx context.Context, req *gatewayv1.ApplySymbolPatchRequest) (*gatewayv1.ApplySymbolPatchResponse, error) { + if err := s.validateService(req.GetService()); err != nil { + return nil, err + } + rel, err := cleanGatewayPath(req.GetFile()) + if err != nil { + return &gatewayv1.ApplySymbolPatchResponse{Success: false, Error: err.Error()}, nil + } + if req.GetDryRun() { + if contextErr := validateOptionalExecutionContext(ctx); contextErr != nil { + return nil, contextErr + } + } else { + operationInputSHA256, digestErr := deterministicProtoSHA256(&codev0.ApplySymbolPatchRequest{ + File: rel, QualifiedName: req.GetQualifiedName(), ExpectedDeclarationSha256: req.GetExpectedDeclarationSha256(), + NewSource: req.GetNewSource(), FixMode: req.GetFixMode(), DryRun: false, + }) + if digestErr != nil { + return nil, status.Errorf(codes.InvalidArgument, "encode apply-symbol-patch input: %v", digestErr) + } + var beforeSHA256 string + if content, readErr := s.fileOps().ReadFile(ctx, rel); readErr == nil { + digest := sha256.Sum256(content) + beforeSHA256 = hex.EncodeToString(digest[:]) + } + attempt, _, beginErr := s.beginGovernedExecution(ctx, executionrecorder.BeginInput{ + OperationKind: "code.apply-symbol-patch", + OperationInputSHA256: operationInputSHA256, + Assurance: executionv1.ExecutionAssurance_EXECUTION_ASSURANCE_PLUGIN_EXECUTED, + Target: executionTarget(s.executionService(req.GetService())), + Resources: []*executionv1.ExecutionResourceV1{ + pathExecutionResource(rel, beforeSHA256, "", false), + }, + }) + if beginErr != nil { + return nil, beginErr + } + if attempt != nil { + return s.applySymbolPatchWithReceipt(ctx, req, rel, beforeSHA256, attempt) + } + } + raw, err := s.executeSymbolPatch(ctx, req, rel) + if err != nil { + return &gatewayv1.ApplySymbolPatchResponse{Success: false, Error: err.Error()}, nil + } + return gatewaySymbolPatchResponse(raw), nil +} + +func (s *Server) executeSymbolPatch(ctx context.Context, req *gatewayv1.ApplySymbolPatchRequest, rel string) (*codev0.CodeResponse, error) { + execute := s.proxyExecute + if s.mindYAML == nil { + execute = s.sourceExecute + } + return execute(ctx, &codev0.CodeRequest{Operation: &codev0.CodeRequest_ApplySymbolPatch{ApplySymbolPatch: &codev0.ApplySymbolPatchRequest{ + File: rel, QualifiedName: req.GetQualifiedName(), ExpectedDeclarationSha256: req.GetExpectedDeclarationSha256(), + NewSource: req.GetNewSource(), FixMode: req.GetFixMode(), DryRun: req.GetDryRun(), + }}}) +} + +func gatewaySymbolPatchResponse(raw *codev0.CodeResponse) *gatewayv1.ApplySymbolPatchResponse { + if raw == nil { + return &gatewayv1.ApplySymbolPatchResponse{Success: false, Error: "Codefly agent returned no symbol-patch response"} + } + result := raw.GetApplySymbolPatch() + if result == nil { + return &gatewayv1.ApplySymbolPatchResponse{Success: false, Error: codeFailureMessage(raw), Failure: failures.Clone(raw.GetFailure())} + } + return &gatewayv1.ApplySymbolPatchResponse{ + Success: result.GetSuccess(), Error: codeFailureMessage(raw), Strategy: result.GetStrategy(), + FixActions: append([]string(nil), result.GetFixActions()...), Changed: result.GetChanged(), + BeforeSha256: result.GetBeforeSha256(), AfterSha256: result.GetAfterSha256(), + DeclarationSha256: result.GetDeclarationSha256(), Wrote: result.GetWrote(), Output: result.GetOutput(), + Failure: failures.Clone(raw.GetFailure()), FailureReason: result.GetFailureReason(), + } +} + +func (s *Server) applySymbolPatchWithReceipt(ctx context.Context, req *gatewayv1.ApplySymbolPatchRequest, rel, beforeSHA256 string, attempt *executionrecorder.Attempt) (*gatewayv1.ApplySymbolPatchResponse, error) { + effectStarted := time.Now() + raw, err := s.proxyExecute(ctx, &codev0.CodeRequest{Operation: &codev0.CodeRequest_ApplySymbolPatch{ApplySymbolPatch: &codev0.ApplySymbolPatchRequest{ + File: rel, QualifiedName: req.GetQualifiedName(), ExpectedDeclarationSha256: req.GetExpectedDeclarationSha256(), + NewSource: req.GetNewSource(), FixMode: req.GetFixMode(), DryRun: false, + }}}) + if err != nil { + finishGovernedExecution(ctx, attempt, executionrecorder.FinishInput{ + Stage: executionv1.ExecutionStage_EXECUTION_STAGE_UNCERTAIN, + Resources: []*executionv1.ExecutionResourceV1{ + pathExecutionResource(rel, beforeSHA256, "", false), + }, + Result: &executionv1.ExecutionResultV1{Status: "uncertain", ErrorCode: errorCode("gateway-rpc-outcome-unknown"), DurationMs: durationMilliseconds(effectStarted)}, + }) + return &gatewayv1.ApplySymbolPatchResponse{Success: false, Error: err.Error()}, nil + } + response := gatewaySymbolPatchResponse(raw) + stage := executionv1.ExecutionStage_EXECUTION_STAGE_FAILED + statusValue := "failed" + errorCodeValue := (*string)(nil) + if response.GetSuccess() { + stage = executionv1.ExecutionStage_EXECUTION_STAGE_SUCCEEDED + statusValue = "succeeded" + } else if response.GetError() != "" { + errorCodeValue = errorCode("apply-symbol-patch-failed") + } + resultBefore := response.GetBeforeSha256() + if !canonicalSHA256(resultBefore) { + resultBefore = beforeSHA256 + } + finishGovernedExecution(ctx, attempt, executionrecorder.FinishInput{ + Stage: stage, + Resources: []*executionv1.ExecutionResourceV1{ + pathExecutionResource(rel, resultBefore, response.GetAfterSha256(), response.GetChanged()), + }, + Result: &executionv1.ExecutionResultV1{Status: statusValue, ErrorCode: errorCodeValue, DurationMs: durationMilliseconds(effectStarted)}, + }) + return response, nil +} + func (s *Server) BatchApplyEdits(ctx context.Context, req *gatewayv1.BatchApplyEditsRequest) (*gatewayv1.BatchApplyEditsResponse, error) { type stagedEdit struct { path string